はじめに
Terraformで複数環境(dev/stg/prod)を管理していると、backend.tf や provider.tf を環境ごとにコピペする問題に直面する。Workspaceで分ける手もあるが、state分離の粒度やprovider設定の柔軟性に限界がある。
この記事では、Terramateの globals と generate_hcl を使って、環境ごとの設定をDRYに管理する具体的な実装手順を紹介する。
📌 この記事で扱うこと:
- Terramate globalsの階層的な変数継承の仕組み
-
generate_hclで backend/provider を環境ごとに自動生成する方法 - 実際にハマった罠と解決策
環境
| 項目 | バージョン |
|---|---|
| Terraform | 1.9.x |
| Terramate | 0.11.x |
| AWS Provider | ~> 5.0 |
実装
ディレクトリ構成を設計する
まず、環境分離のためのディレクトリ構成を決める。ポイントはスタック(リソース群)と環境設定を分離することだ。
infra/
├── terramate.tm.hcl # ルート設定
├── globals.tm.hcl # 全環境共通のglobals
├── stacks/
│ ├── network/
│ │ ├── stack.tm.hcl
│ │ ├── _gen.tm.hcl # generate_hcl定義
│ │ ├── main.tf
│ │ └── variables.tf
│ └── app/
│ ├── stack.tm.hcl
│ ├── _gen.tm.hcl
│ ├── main.tf
│ └── variables.tf
└── envs/
├── dev/
│ ├── globals.tm.hcl # dev固有の値
│ └── stacks/ # → ../../../stacks をsymlink
├── stg/
│ ├── globals.tm.hcl
│ └── stacks/
└── prod/
├── globals.tm.hcl
└── stacks/
ここで envs/dev/stacks/ は ../../stacks/ へのシンボリックリンクだ。これにより、スタックの実体は1箇所で管理しつつ、環境ごとに異なる globals を注入できる。
ルートのglobals を定義する
globals.tm.hcl に全環境で共通の値を定義する:
# infra/globals.tm.hcl
globals {
project_name = "myproject"
aws_region = "ap-northeast-1"
# 全環境共通のタグ
common_tags = {
ManagedBy = "terraform"
Project = global.project_name
}
# tfstateバケット名のテンプレート
state_bucket = "tfstate-${global.project_name}-${global.environment}"
}
global.environment はこの時点では未定義だ。子ディレクトリの globals.tm.hcl で定義される前提で参照している。
環境ごとのglobals でオーバーライドする
各環境のディレクトリに globals.tm.hcl を置いて、環境固有の値を定義する:
# infra/envs/dev/globals.tm.hcl
globals {
environment = "dev"
account_id = "111111111111"
instance_type = "t3.small"
# dev だけ削除保護を無効化
deletion_protection = false
}
# infra/envs/prod/globals.tm.hcl
globals {
environment = "prod"
account_id = "999999999999"
instance_type = "m5.large"
deletion_protection = true
# prod だけ追加のタグ
common_tags = tm_merge(global.common_tags, {
CriticalLevel = "high"
})
}
globalsの継承ルール: Terramateはディレクトリツリーを上に辿って globals をマージする。子で同じキーを定義するとオーバーライドされる。
generate_hcl で backend/provider を自動生成する
各スタックに _gen.tm.hcl を置いて、globalsから .tf ファイルを生成する:
# stacks/network/_gen.tm.hcl
generate_hcl "_backend.tf" {
content {
terraform {
backend "s3" {
bucket = global.state_bucket
key = "${global.environment}/network/terraform.tfstate"
region = global.aws_region
dynamodb_table = "terraform-lock-${global.environment}"
encrypt = true
}
}
}
}
generate_hcl "_provider.tf" {
content {
provider "aws" {
region = global.aws_region
assume_role {
role_arn = "arn:aws:iam::${global.account_id}:role/TerraformRole"
}
default_tags {
tags = tm_merge(global.common_tags, {
Environment = global.environment
Stack = "network"
})
}
}
}
}
terramate generate を実行すると、各環境のスタックディレクトリに _backend.tf と _provider.tf が生成される:
$ cd infra
$ terramate generate
Code generation report:
envs/dev/stacks/network/_backend.tf: created
envs/dev/stacks/network/_provider.tf: created
envs/stg/stacks/network/_backend.tf: created
envs/stg/stacks/network/_provider.tf: created
envs/prod/stacks/network/_backend.tf: created
envs/prod/stacks/network/_provider.tf: created
生成された _backend.tf を見ると、globalsの値が展開されていることが確認できる:
# envs/dev/stacks/network/_backend.tf(生成結果)
terraform {
backend "s3" {
bucket = "tfstate-myproject-dev"
key = "dev/network/terraform.tfstate"
region = "ap-northeast-1"
dynamodb_table = "terraform-lock-dev"
encrypt = true
}
}
Terramate CLIでスタックを操作する
環境を指定してplanする場合、ディレクトリフィルタを使う:
# devの全スタックをplan
terramate run --chdir envs/dev -- terraform plan
# prodのnetworkスタックだけapply
terramate run --chdir envs/prod/stacks/network -- terraform apply
ハマったポイント
1. globals の遅延評価に気づかず無限ループ
globalsの中で他のglobalsを参照するとき、定義順序は関係ない(遅延評価される)。ただし、循環参照するとエラーになる:
# ❌ これは循環参照でエラー
globals {
foo = global.bar
bar = global.foo # Error: cycle detected
}
自分がハマったのは、もっと間接的なケースだ。ルートで state_bucket に global.environment を参照し、環境のglobalsで environment の中から global.state_bucket を参照してしまい、循環が発生した。
対処法: globalsの依存グラフを意識する。terramate debug show globals でどのglobalsがどこから来ているか確認できる。
$ terramate debug show globals
/infra:
project_name = "myproject"
aws_region = "ap-northeast-1"
/infra/envs/dev:
environment = "dev"
state_bucket = "tfstate-myproject-dev" # ← ルートで定義、devのenvironmentを参照
2. symlinkとterramate generate の相性
envs/dev/stacks/ を ../../stacks/ へのシンボリックリンクにした場合、terramate generate がリンク先の実体ディレクトリに生成ファイルを書いてしまうことがある。
対処法: Terramateの設定で terramate.config.generate.hcl_magic_header_comment_style を指定し、生成先を明示的に制御する。または、シンボリックリンクではなく terramate.config.run.env で環境変数としてパスを渡す方法に切り替えた。
最終的にはシンボリックリンクを廃止し、各環境ディレクトリにスタック定義(stack.tm.hcl)を直接配置する構成に落ち着いた:
envs/dev/network/
├── stack.tm.hcl # ← 各環境に配置
├── _gen.tm.hcl # ← importで共通化
└── main.tf # ← importまたはmodule呼び出し
3. tm_merge の罠 — mapのdeep mergeはされない
tm_merge はトップレベルのキーだけマージする。ネストされたmapはdeep mergeされず、上書きされる:
# ルート
globals {
config = {
timeout = 30
retries = 3
}
}
# 環境
globals {
# ❌ configの中身が全部上書きされる(timeoutが消える)
config = tm_merge(global.config, {
retries = 5
})
}
この場合は正しく retries = 5, timeout = 30 になるが、mapの中にさらにmapがある場合は注意が必要だ。ネストが深い場合は、globalsをフラットに保つか、個別のキーに分割するのが安全。
4. generate_hcl の outdated 検知
terramate generate を実行し忘れてglobalsだけ変更してコミットすると、生成ファイルと実際のglobalsに不整合が生じる。
対処法: CIで terramate generate → git diff --exit-code を実行し、差分があればfailさせる:
# CIで生成ファイルの不整合を検知
terramate generate
if ! git diff --quiet --exit-code; then
echo "ERROR: Generated files are outdated. Run 'terramate generate' locally."
git diff
exit 1
fi
これをPRのCIに入れておけば、生成忘れを防げる。
まとめ
Terramate globalsを使った環境分離のポイントをまとめると:
- ルートglobalsに共通値、環境globalsでオーバーライド — 継承の仕組みを理解する
- generate_hclで .tf ファイルを自動生成 — コピペを排除しつつ、生成結果はそのままTerraformで使える
-
CIで
terramate generateの差分チェック — 生成忘れを仕組みで防ぐ -
循環参照・symlinkの罠に注意 —
terramate debug show globalsでデバッグ
個人的に一番効果が大きかったのは「backend.tfのコピペミスが0になった」こと。環境が増えるたびに globals.tm.hcl を1ファイル追加するだけで済むのは快適だ。
🏀 NBA Trade Tracker: https://www.nba-iso-flow.com/