はじめに
こんにちは!ディップ株式会社でSRE見習いをしている新卒2年目ひよっこエンジニアの大賀です。
Terraform × AWSでプロダクトを運用していると、マルチアカウントで日々terraform applyを叩いたり複数の環境に水平展開したりするケースがよくあると思います。
多分多くの方が以下のような思いをした事があるのではないでしょうか。
- ルートモジュール(実行ディレクトリ)ごとに
backend.tfとかprovider.tfとかterraform.tf書くの面倒だし管理大変。。。 - 変数ファイル(
.tfvars)が環境ごとに散らばるし形が定まらない - モジュール間の依存関係が把握しづらい/値を渡したりがゴチャる
Terraformはすごく自由度が高く記述できるし、ここ最近のTerraformやAWS Providerのバージョンはよくできていてかなりよしなにやってくれるので、割と適当に書いても全然普通に動くと思います。
だからこそ人によってコーディングスタイルがかなり変わってくるのでキャッチアップに苦心する場面もあるかと思います。
そんな課題は、Terragruntを導入することで、綺麗さっぱり忘れ去る事ができます。
かくいう私も使い始めて古のスパゲッティコードやとんでもなく面倒なバージョンアップ対応からおさらばできてTerraformを書くのが本当に楽しくなりました。
本記事では、実際のプロダクトで運用しているTerragruntの構成を例に、DRYで保守しやすいTerraformコード管理の実践方法を解説しながら汎用的に使えるコードをまとめてみました!!
こんな人に読んでほしい
- Terraformを使っているが、複数環境の管理に困っている方
- Moduleの切り方、ディレクトリ構成に度々迷う方
- Terragruntを聞いたことはあるが、具体的な使い方がわからない方
- 既存のTerragrunt構成を改善したい方
Terragruntとは
Terragruntは、Gruntwork社が開発したTerraformのラッパーツールです。主な機能として、
- DRYな変数管理: 重複した変数の定義/代入や値の抜け漏れによるエラーを減らせる
- 設定の継承: 親設定ファイルから子へ設定を継承できる
- 依存関係管理: モジュール間の依存関係を明示的に定義して実行順を最適化
-
コード生成: 全ルートモジュールの
terraform.tf/backend.tf/provider.tfを1ファイルにまとめられる
があり、Terraformの記述と管理を楽にしてくれます。
とはいえ自分で書いておきながら言うのもアレですが、これ初見じゃあんまりピンと来ないだろうなぁ...と思うので早速コードベースで解説していきます!
ディレクトリ構成
今回紹介する構成は以下のとおりです。
terraform/
├── root.hcl # ルート設定(全環境共通)
├── modules/ # モジュール
│ ├── common/
│ ├── vpc/
│ ├── db/
│ ├── front/
│ ├── backend/
│ └── ...
└── envs/ # 環境別設定
├── dev/
│ ├── env.hcl # dev環境固有設定(変数値代入ファイル)
│ ├── vpc/ # ここの位置がルートモジュール
│ │ └── terragrunt.hcl # Terragrunt実行ファイル
│ ├── db/
│ │ └── terragrunt.hcl
│ └── ...
├── stg/
│ └── ...
└── prod/
└── ...
ポイント:
-
modules/に環境非依存のTerraformモジュールを配置 -
envs/配下に環境別のTerragrunt設定(実行ディレクトリ)を配置 -
root.hclで全環境共通の設定を管理
見た事ないファイルがいくつかあると思いますが、概ね一般的なTerraformの構成と変わりません。
root.hcl - 全環境共通設定
まず、プロジェクトのルートに配置するroot.hclを見てみましょう。
root.hcl全文
#-----------------------------------------------
# root.hcl
#
# Terragruntルート設定ファイル
#-----------------------------------------------
################################################
# Locals
################################################
locals {
tfstate_bucket_name = {
prod = "prod-tfstate"
stg = "stg-tfstate"
dev = "dev-tfstate"
}
product_name = "test"
env = basename(dirname(get_terragrunt_dir()))
aws_profile = lookup({
prod = "aws-prod"
stg = "aws-stg"
dev = "aws-dev"
}, local.env, "")
aws_account_id = {
prod = "012345678901"
stg = "234567890123"
dev = "456789012345"
}
}
################################################
# Backend Config
################################################
remote_state {
backend = "s3"
config = {
bucket = local.tfstate_bucket_name[local.env]
key = "${path_relative_to_include()}/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
profile = local.aws_profile
}
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
}
################################################
# Terraform Config
################################################
generate "terraform" {
path = "terraform.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "6.25.0"
}
}
required_version = "1.14.1"
}
EOF
}
################################################
# Provider Config
################################################
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "ap-northeast-1"
profile = "${local.aws_profile}"
default_tags {
tags = {
Product = "${local.product_name}"
env = "${local.env}"
management = "terraform"
}
}
}
EOF
}
各ブロックごとに解説していきます!
locals - 環境の判定
################################################
# Locals
################################################
locals {
# 環境別のtfstateバケット名
tfstate_bucket_name = {
prod = "prod-tfstate"
stg = "stg-tfstate"
dev = "dev-tfstate"
}
product_name = "test"
# 実行ディレクトリの絶対パスから環境名を取得
# envs/dev/vpc → "dev"
env = basename(dirname(get_terragrunt_dir()))
# AWSプロファイルを↑のenvから判定
aws_profile = lookup({
prod = "aws-prod"
stg = "aws-stg"
dev = "aws-dev"
}, local.env, "")
# 環境別のAWSアカウントID
aws_account_id = {
prod = "012345678901"
stg = "234567890123"
dev = "456789012345"
}
}
ポイント解説
1. basename(dirname(get_terragrunt_dir()))で環境名を自動取得
env = basename(dirname(get_terragrunt_dir()))
get_terragrunt_dir()はTerragruntの固有関数で、現在のterragrunt.hclがある実行ディレクトリの絶対パスを返します。
例:/path/to/terraform/envs/dev/vpc/terragrunt.hclを実行すると
-
get_terragrunt_dir()→/path/to/terraform/envs/dev/vpc -
dirname()→/path/to/terraform/envs/dev -
basename()→dev
といった感じで、ディレクトリ構造から環境名を判定できます。
2. lookup()で環境別設定をマッピング
aws_profile = lookup({
prod = "aws-prod"
stg = "aws-stg"
dev = "aws-dev"
}, local.env, "")
lookup()関数で環境名からAWSプロファイル名を取得します。第3引数はデフォルト値で、マッチしなかった場合は値を入れていればそれを、値を入れなければエラーになります。
remote_state - backend.tfの生成
################################################
# Backend Config
################################################
remote_state {
backend = "s3"
config = {
bucket = local.tfstate_bucket_name[local.env]
key = "${path_relative_to_include()}/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
profile = local.aws_profile
}
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
}
ポイント解説
1. path_relative_to_include()でユニークなstate key
key = "${path_relative_to_include()}/terraform.tfstate"
この関数は、root.hclから見た実行ディレクトリの相対パスを返します。
例:envs/dev/vpc/terragrunt.hclから呼ばれると
-
path_relative_to_include()→envs/dev/vpc - S3 Backendのkey →
envs/dev/vpc/terraform.tfstate
このため、モジュールごとに自動でユニークなstate keyを設定できます。
2. generateブロックで backend.tf を自動生成
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
この記述をするとterragrunt plan/apply実行時に、実行ディレクトリに作成される.terragrunt-cacheディレクトリ(←.gitignore対象)に以下のようなbackend.tfが自動生成され、これの設定を利用してTerraformがstateを書き込みます。
# 自動生成される backend.tf
terraform {
backend "s3" {
bucket = "dev-tfstate"
key = "envs/dev/vpc/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
profile = "aws-dev"
}
}
generate - provider.tf と terraform.tf の生成
################################################
# Terraform Config
################################################
generate "terraform" {
path = "terraform.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "6.25.0"
}
}
required_version = "1.14.1"
}
EOF
}
################################################
# Provider Config
################################################
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "ap-northeast-1"
profile = "${local.aws_profile}"
default_tags {
tags = {
Product = "${local.product_name}"
env = "${local.env}"
management = "terraform"
}
}
}
EOF
}
ポイント解説
1. Terraform/Providerのバージョン変更はここを触るだけでOK
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "6.25.0"
}
}
required_version = "1.14.1"
}
Terragruntの場合、Terraformや各種Providerのバージョンアップ時はこの箇所を操作して実行ディレクトリでterragrunt init -upgradeコマンドを実行するだけでOKです。
おそらくこれをご覧になって「え、どのModuleも固定されるのはちょっと...」と思われる方もいるかとは思いますが、基本的にはバージョンは高いものに合わせて変更していく方がコードヘルスの維持には効果的だと思いますので、Module側で巻き取る対応をお勧めします。(もちろん、>=などの比較演算子も引き続き利用可能ですのでご心配なく!)
2. default_tagsで全リソースに共通タグを自動付与
default_tags {
tags = {
Product = "${local.product_name}"
env = "${local.env}"
management = "terraform"
}
}
AWS Provider 3.38.0以降で使えるdefault_tagsにより、Resourceブロックでtagsを定義しなくてもタグ付けができるすべてのAWSリソースに自動でタグが付与されます。
Terragruntの場合はこれがより強化された形で、どの環境、どの実行ディレクトリでもterragrunt.hclでroot.hclを参照すれば必ず適切な環境のタグを付与できます。
- 必要なタグ(例:
CmBillingGroupなど)の付け忘れ防止 - 環境識別が容易
- Terraform管理リソースの識別が一括して可能
など、かなり便利です。
実行ディレクトリでの設定継承(include + expose)
各環境のterragrunt.hcl(例:envs/dev/vpc/terragrunt.hcl)では、root.hclの設定を継承します。
include "root" {
path = find_in_parent_folders("root.hcl")
expose = true # ← これが重要!
}
dependencies {
paths = ["../common"]
}
dependency "common" {
config_path = "../common"
}
terraform {
source = "../../../modules/vpc/"
}
inputs = {
# include.root.locals.xxx で親のlocalsを参照
product_name = include.root.locals.product_name
env = include.root.locals.env
# 環境固有の設定(terraformで言うmain.tfに記載する変数への値の代入)
cidr_block = "192.168.32.0/20"
public_subnets = [
"192.168.32.0/23",
"192.168.34.0/23",
"192.168.36.0/23"
]
private_subnets = [
"192.168.38.0/23",
"192.168.40.0/23",
"192.168.42.0/23"
]
# 他モジュールの出力を参照
log_bucket = dependency.common.outputs.log_bucket
}
ポイント解説
1. expose = trueで指定したファイルのlocalsを参照可能に
include "root" {
path = find_in_parent_folders("root.hcl")
expose = true
}
expose = trueを指定することで、include.root.locals.xxxの形式で親ファイル(root.hcl)のlocalsにアクセスできます。
inputs = {
product_name = include.root.locals.product_name # → "test"
env = include.root.locals.env # → "dev"
}
これにより、環境名やプロダクト名を各terragrunt.hclで定義する必要がなくなります。
ミスが減る上、使いまわしやすくもなるので本当に助かります。
find_in_parent_folders("root.hcl")はTerragruntの固有関数でカレントディレクトリから親ディレクトリまで遡って、最初に見つかったroot.hclを読み取る、という内容です。
2. dependencyブロックでモジュール間依存を管理
dependency "common" {
config_path = "../common"
}
inputs = {
log_bucket = dependency.common.outputs.log_bucket
}
Terragruntの目玉機能の1つです。dependencyブロックを使うと、他モジュールのoutputsを参照できます。
詳しい仕組みの説明は省きますが、Terragruntは他モジュールのoutputsとdependencyの記載から依存関係を解析し、適切な順序でplan/applyを実行します。(スゴい!😭)
実行コマンド
# 特定モジュールのplan
cd envs/dev/vpc
terragrunt run plan
# 特定モジュールのapply
terragrunt run apply
# 全モジュールを依存順にapply(envs/dev配下すべて)
cd envs/dev
terragrunt run --all apply
# 全環境の全モジュールのplan
cd envs
terragrunt run --all plan #(--terragrunt-parallelismオプションで最大並列実行数を指定可能)
# Terraform/Providerバージョンアップ時(plan前に実行)
cd envs #(任意の粒度)
terragrunt run --all init -upgrade
なお、TerragruntにはAuto-init機能があるためTerraform/Providerをバージョンアップした時以外はterragrunt run initコマンドを実行する必要はありません。
TerragruntのCLIは引数や利用法が多岐に渡るため、詳しくはドキュメントもご確認ください。
まとめ
いかがでしたでしょうか。
Terragruntを導入してみて、今まで素のTerraformでコピペしまくってたterraform.tf/backend.tf/provider.tfを書かなくて良くなったし、terragrunt.hclのうちかなりの割合が使いまわせるし、基本のタグ付けに悩まなくなったし、実行順がAWSリソースのリレーションに基づいて最適化されるので一括実行の頻度が上がったし、そのおかげでかれこれ半年以上余計な差分が出なくなったし、面倒だったバージョンアップも習慣化できて、コードの質がかなり高まりました。
ぜひTerraformを書かれる皆様は1度お試しいただければと嬉しいです!
長々と書いてしまいましたが、お読みいただきありがとうございました!