Terraform 運用 #5 大規模の構造化 — スタック分割の基準、スタック間参照、Terragrunt 導入のタイミング
実践 #9の modules と envs の構造は環境を分離しましたが、1 つの環境の中は依然として 1 つの state です。myapp の規模ならそれで十分です。ところがリソースが数百個になり、複数のチームが同じコードを触り始めると、1 つの state がボトルネックになります。今回は、その時点で必要になる構造の話です。先に言っておくと、これらの構造は大きくなる前に導入すると、むしろ荷物になります。各段階の導入シグナルを一緒に整理する理由がそこにあります。
state が 1 つのまま大きくなると起きる 3 つの問題 #
- plan が遅くなります: plan は管理中のすべてのリソースを refresh します(基礎 #5)。リソースが数百個あると、タグ 1 つ直すだけの plan にも数分かかります。
- ブラスト半径が広がります: ミス 1 回の影響範囲が state 全体です。アプリの設定を直していて誤った plan を承認すると、ネットワークまで波及しかねません。
- ロック競合が生まれます: 1 つの state に対する apply は同時に 1 つです(基礎 #9)。チームが複数あると、互いのデプロイを待つ行列ができます。
スタック分割 — 切り分ける基準は 3 つ #
解決策は state を複数のスタック(stack)に分けることです。スタックは自分の backend key を持つ独立した実行単位で、envs の中をさらに分けると考えれば十分です。
envs/prod/
├── network/ # VPC、サブネット、NAT (ほとんど変わらない)
├── data/ # RDS、S3、シークレット (ときどき変わる)
└── app/ # ECS、ALB、オートスケーリング (毎週変わる)何を基準に切るかが要点で、実務で実証された基準は 3 つです。
- 変更頻度: 毎週変わるアプリ層と、四半期に一度しか変わらないネットワークが同じ state にあると、頻繁なデプロイのたびにネットワークがブラスト半径に入ります。頻度が違うもの同士を分けます。
- 寿命: 基礎 #8のモジュールの基準と同じ原理です。一緒に作られ、一緒に消えるもの同士をまとめます。
- 所有チーム: チームが違えばスタックを分け、それぞれが自分のデプロイの列を持つようにします。ロック競合の直接的な解消です。
myapp をこの 3 つで分けると、アプリのデプロイの plan はアプリスタックのリソースだけを refresh するので速くなり、ミスの影響範囲もそのスタックの中に収まります。
スタック間参照 — remote state とパラメータ #
分けた瞬間に新しい問題が生まれます。app スタックが network スタックのサブネット ID を知る必要があります。方法は 2 つです。
1 つ目は terraform_remote_state データソースです。別のスタックの state を直接読んで output を取ってきます。
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "myapp-tfstate"
key = "myapp/prod/network/terraform.tfstate"
region = "ap-northeast-2"
}
}
# 使い方: data.terraform_remote_state.network.outputs.private_subnet_ids直感的ですが、結合が強い方式です。読む側が相手の state ファイル全体にアクセスできる必要があり(基礎 #9で見たとおり、state には機密情報が含まれ得ます)、相手のスタックが output の名前を変えると、こちらが即座に壊れます。
2 つ目は共有する値を SSM パラメータとして公開する方式です。network スタックがサブネット ID を SSM に書き込み(aws_ssm_parameter リソース)、app スタックは SSM から読みます(data ソース)。ひと手間増える代わりに、スタック間の契約が「パラメータのパス」という狭いインターフェースとして明示され、state へのアクセス権限をチーム間で配る必要がなくなります。チームの境界を越える参照はパラメータ方式、同じチーム内のスタック同士は remote state で簡潔に、というのが無難な落としどころです。
モノレポの運用ルール #
スタックが増えてもリポジトリは 1 つ(モノレポ)にしておくのが一般的です。モジュールの共有と、アトミックな変更(モジュールと利用側を 1 つの PR に)が容易だからです。その代わり、ルールが必要になります。
- CI のパスフィルタ: #1の paths フィルタをスタック単位に拡張し、変更されたスタックの plan だけが動くようにします。
- CODEOWNERS: スタックのディレクトリごとに所有チームを指定し、レビューの振り分けを自動化します。
- モジュールのバージョンポリシー: 共有モジュールが変わったときに、すべてのスタックが即座に追従するか(モノレポの相対パス)、スタックごとにバージョンを選ぶか(モジュールレジストリ・Git タグ参照)を決めます。チームが少なければ前者、多ければ後者が楽です。
Terragrunt — ボイラープレートが限界を超えたとき #
スタック × 環境が増えると、新しい繰り返しが目につきます。スタックのディレクトリごとに、backend ブロックと provider 設定がほぼ同じ内容でコピーされていることです。スタックが 5 個なら手で管理できますが、50 個なら地獄です。Terragrunt はこの繰り返しをなくすラッパーツールで、共通設定を 1 つのファイルに置けば各スタックの設定ファイルは数行に縮み、複数のスタックを依存順に一括実行する機能もついてきます。導入判断の基準はシンプルに保つことをおすすめします。目安はbackend・provider のボイラープレートのコピーが実際に苦痛になった時点で、おおよそスタック数十個からです。myapp の規模で先回りして導入しても、ツールの層が 1 枚増えるだけだというのが、コミュニティで一致している経験談です。
まとめ #
今回扱った内容です。
- 大きな state は plan の遅延、ブラスト半径、ロック競合を生みます。解決策はスタック分割で、基準は変更頻度、寿命、所有チームです
- スタック間参照は remote state(簡潔・強結合)と SSM パラメータ(明示的な契約・弱結合)から、境界の性質で選びます
- モノレポはパスフィルタ、CODEOWNERS、モジュールのバージョンポリシーという 3 つのルールとともに運用します
- Terragrunt は backend・provider のボイラープレートの苦痛が実在するようになったとき(スタック数十個)に導入します。先回りして導入すると荷物になります
- この回のすべての構造には「大きくなる前の導入は損」という但し書きがつきます。導入シグナルを覚えておくことが、構造そのものより重要です
次回(#6 実行プラットフォーム比較)では、ここまで GitHub Actions で自作してきたものを製品として提供するプラットフォーム(HCP Terraform、Atlantis、Spacelift)を比較し、選択基準を立てます。