Terraform 運用 #5 大規模の構造化 — スタック分割の基準、スタック間参照、Terragrunt 導入のタイミング

読了 6分

実践 #9の modules と envs の構造は環境を分離しましたが、1 つの環境の中は依然として 1 つの state です。myapp の規模ならそれで十分です。ところがリソースが数百個になり、複数のチームが同じコードを触り始めると、1 つの state がボトルネックになります。今回は、その時点で必要になる構造の話です。先に言っておくと、これらの構造は大きくなる前に導入すると、むしろ荷物になります。各段階の導入シグナルを一緒に整理する理由がそこにあります。

state が 1 つのまま大きくなると起きる 3 つの問題 #

  1. plan が遅くなります: plan は管理中のすべてのリソースを refresh します(基礎 #5)。リソースが数百個あると、タグ 1 つ直すだけの plan にも数分かかります。
  2. ブラスト半径が広がります: ミス 1 回の影響範囲が state 全体です。アプリの設定を直していて誤った plan を承認すると、ネットワークまで波及しかねません。
  3. ロック競合が生まれます: 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 を取ってきます。

envs/prod/app/main.tf
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)を比較し、選択基準を立てます。

X