⚙️ なぜ Terraform を CI/CD で動かすのか
本記事はシリーズ 「Terraform IaC 実践ガイド」第 4 回(CI/CD パイプライン編) です。第 1 回 概要 と第 2 回 準備編 で扱った設計判断を、 「Git にマージされたコードだけが本番に適用される」自動化 に落とし込みます。
ローカルから terraform apply を叩く運用には、以下の致命的な弱点があります。
- レビュー不能:誰が・いつ・何を変えたかが残らない
- 資格情報の偏在:本番権限を個々人の端末に持たせざるを得ない
- State drift の温床:手元の state と本番が常にズレるリスク
- 属人化:「あのスタックは○○さんしか触れない」状態を生む
CI/CD で運用すれば、これらは構造的に解消できます。
🎯 目指すパイプラインの全体像
本パイプラインの設計原則は 3 つに集約されます。
- plan と apply を分離する:MR 段階では plan のみ、merge 後に apply
- 変更があったスタックだけを実行する:差分検出による動的ジョブ生成
- 資格情報を CI に置かない:WIF(Workload Identity Federation) でキーレス認証
🔐 認証はキーレス(WIF)一択
第 2 回 準備編 で述べたとおり、Terraform 用のサービスアカウントキーは GitLab Variables にも置きません 。GitLab CI の id_tokens キーワードで OIDC ID トークンを取得し、GCP の WIF Pool が短命の access token に交換します。
.terraform_base:
image:
name: hashicorp/terraform:1.10
entrypoint: [""]
id_tokens:
GCP_ID_TOKEN:
aud: https://iam.googleapis.com/projects/${GCP_PROJECT_NUMBER}/locations/global/workloadIdentityPools/terraform-pool/providers/gitlab-saas
before_script:
- echo "$GCP_ID_TOKEN" > /tmp/id-token.jwt
- |
cat <<EOF > /tmp/cred.json
{
"type": "external_account",
"audience": "//iam.googleapis.com/projects/${GCP_PROJECT_NUMBER}/locations/global/workloadIdentityPools/terraform-pool/providers/gitlab-saas",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1/token",
"credential_source": { "file": "/tmp/id-token.jwt" }
}
EOF
- export GOOGLE_APPLICATION_CREDENTIALS=/tmp/cred.json
WIF Pool の作成手順・attribute-condition の最小権限設計(namespace_id で自社グループ限定、refs/heads/main でブランチ限定 等)は、独立記事 WIF でキーレス認証 で詳しく扱っています。 本記事はその上に組み上げる「IaC 自動化レイヤ」の設計図 です。
impersonation あり / なし — 重要な設計分岐
WIF の認証経路には、中継となるサービスアカウント(Principal SA)を経由するか否かという重要な設計分岐があります。これが社内で言う サービスアカウント impersonation の「あり / なし」です(どちらも対象 SA のトークンを借りる点は同じで、違いは中継 SA を挟むか)。本基盤の bootstrap 認証とスタック認証は、この使い分けの上に成り立っています。
| 方式 | 経路 | 使いどころ |
|---|---|---|
| impersonation なし(基本) | WIF Provider → 目的の SA を直接借りる(1 段) | 単一プロジェクト内で完結。経路が短く最小権限・監査が容易。まずこちらを選ぶ |
| impersonation あり | WIF Provider → 中継 SA → 目的の SA(2 段) | 複数プロジェクトを横断して権限を借りる必要があるとき |
選定指針: 原則は impersonation なし(中継 SA なしの 1 段)。中継を挟むほど権限委譲の経路が伸び、追跡と最小権限の維持が難しくなります。impersonation あり(2 段)は「1 つの入口から複数プロジェクトの SA を使い分けたい」など、横断が本当に必要な場合に限定します。bootstrap 段(設定取り出し)とスタック段(Terraform 実行)で、どちらを採るかを意図的に分けるのが設計のキモです(経路の実装はスクリプトに閉じます)。
マルチクラウド時の認証
GCP と AWS の両方を 1 本のパイプラインで管理する場合、 GCP のサービスアカウントが発行する OIDC トークン で AWS の IAM Role を AssumeRole します。GitLab → GCP → AWS と直列で連鎖させることで、 AWS アクセスキーも CI に置かない 構成が成立します。
GitLab Runner
└→ GCP WIF(OIDC)→ Google Cloud に認証
└→ GCP SA の OIDC トークン
→ AWS STS AssumeRoleWithWebIdentity
→ AWS リソースに認証
🧩 動的 child pipeline 生成 — モジュール管理 Infra CI/CD の核心
本パイプラインで最大の革新がここです。スタック数が増えても、変更があったスタックだけを自動検出して plan / apply する仕組みを、GitLab の child pipeline(子パイプライン) の動的生成で実現します。
あなたの .gitlab-ci.yml、「秘伝のタレ」になっていませんか?
その
.gitlab-ci.yml、いつの間にか数千行〜数万行に育っていませんか?
動的生成を使わない場合、新しいクライアント・スタックを追加するたびに、.gitlab-ci.yml へ rules: の条件分岐を足していく運用になります。これ自体は素直なやり方で、スタックが少ないうちは十分機能します。ただしスタックが数十〜数百規模に育つと、いくつかの課題が効いてきます。
- スタックが増えるほど
.gitlab-ci.ymlが条件分岐で線形に長くなり、誰も全体を追いきれない「秘伝のタレ」状態に近づく - 全スタック走査だと CI 時間が線形に伸び、無関係な plan の差分が MR レビューのノイズになる
- 設定の維持が特定メンバーに依存しやすくなる(属人化)
これは「書いた人が悪い」のではなく、静的記述というアプローチがスケールで頭打ちになるだけの話です。動的生成は、この「スケール時の保守性」を構造的に解決する手段になります。
GitLab 公式も「動的 child pipeline」を位置づけている
これは奇策ではありません。GitLab 公式ドキュメントが、ジョブ内で生成した YAML から子パイプラインをトリガーする手法を正式に位置づけています。
“You can trigger a child pipeline from a YAML file generated in a job, instead of a static file saved in your project.” “This can be very powerful for generating pipelines targeting content that changed …” — GitLab Docs: Dynamic child pipelines
公式は Jsonnet 等のテンプレート言語による生成例を示しています。本基盤はこれを 「変更検出 → 子パイプライン生成」を Python スクリプトで実装する形に発展させ、.gitlab-ci.yml 本体は薄いまま、無限にスタックを足せる構成にしています。新規スタックを追加しても .gitlab-ci.yml には一切手を入れません。
変更検出のロジック
変更検出は、main との差分からどのスタックが影響を受けたかを判定するスクリプトで行います。判定方針(=設計のキモ)は次の 2 点です。
environments/<env>/<stack>/単位 でジョブを切る(State の単位とそろえる)modules/配下が変わった場合は全スタック対象 に切り替える(影響範囲が読めないため安全側に倒す)
この「差分 → 対象スタック集合」を求める判定ロジックをスクリプト化し、次ステップのジョブ生成に渡します(スクリプトの実装そのものはプロジェクト内に閉じます)。
parent-child pipelines で動的にジョブを発行
GitLab CI の trigger:include: を使って、変更スタック分の YAML を動的生成し、子パイプラインとして実行します。
generate-pipeline:
stage: prepare
script:
- ./scripts/generate-pipeline.sh > generated-pipeline.yml
artifacts:
paths: [generated-pipeline.yml]
trigger-children:
stage: pipeline
needs: [generate-pipeline]
trigger:
include:
- artifact: generated-pipeline.yml
job: generate-pipeline
strategy: depend
子パイプライン側には、変更されたスタック数だけ plan / apply ジョブが動的に生成されます。
strategy: dependの意味: GitLab 公式仕様では、これは「子パイプラインの artifact reports(test report 等)を親パイプラインの MR widget に表示する 」ためのオプションです。指定しないと、子で生成した plan サマリ等の成果物が親 MR の差分ビューに反映されません。「親が子の完了を待つ」副次効果も付随します。
🧱 この基盤を支える 4 つのコンポーネント
本パイプラインの実体は、役割を絞った 4 つの小さなスクリプトの組み合わせです。巨大な .gitlab-ci.yml の代わりに、この 4 つが疎結合に連携します(各スクリプトの実装はプロジェクト内に閉じ、ここでは役割のみ示します)。
| コンポーネント | 役割 | 効果 |
|---|---|---|
| bootstrap 認証 | CI の最初の入口。bootstrap の WIF で Secret Manager にだけアクセスし、各スタックの設定を取り出す | 鍵ゼロで「設定の取り出し口」を一本化 |
| スタック認証 | スタックごとの WIF で、その環境の Terraform 実行権限へ昇格 | スタック単位の最小権限・キーレス |
| クロスクラウド認証 | GCP の認証を起点に AWS STS へ連鎖し、AWS リソースも鍵なしで操作 | GCP/AWS をまたいでも長寿命キーゼロ |
| 動的パイプライン生成 | 変更スタックを検出し、子パイプラインの YAML を生成して発行 | スタックが増えても .gitlab-ci.yml は薄いまま |
この 4 点が噛み合うことで、「単一リポジトリ・無数のスタック・マルチクラウド・キーレス・.gitlab-ci.yml は最小」 が同時に成立します。新しいクライアントやサービスを追加するときに触るのは モジュールと environments/ のディレクトリだけ。CI 設定本体は不変です。
導入を検討する CI/CD 担当者へ: この構成の価値は、機能の派手さではなく 「スケールしても CI 設定が肥大化しない」 一点に集約されます。スタック数が 3 桁になっても、パイプラインの保守コストはほぼ一定に保てます。
.gitlab-ci.ymlの維持が重くなってきたなら、動的 child pipeline + キーレス認証の 4 コンポーネント構成が一つの選択肢になります。
🛠️ plan / apply の分離
| ステージ | トリガー | ジョブ | 失敗時の挙動 |
|---|---|---|---|
| plan | MR 作成・更新時 | terraform plan -out=plan.tfplan | MR にコメントして失敗扱い |
| apply | main へ merge 後 | terraform apply plan.tfplan | パイプライン停止、Slack 通知 |
plan:
extends: .terraform_base
stage: plan
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- cd terraform/environments/${ENV}/${STACK}
- terraform init
- terraform plan -out=plan.tfplan -detailed-exitcode
artifacts:
paths:
- terraform/environments/${ENV}/${STACK}/plan.tfplan
expire_in: 1 day
apply:
extends: .terraform_base
stage: apply
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
environment:
name: ${ENV}/${STACK}
script:
- cd terraform/environments/${ENV}/${STACK}
- terraform init
- terraform apply -auto-approve plan.tfplan
needs:
- job: plan
artifacts: true
-detailed-exitcode の活用
terraform plan -detailed-exitcode は次の 3 値を返します。
| 終了コード | 意味 | パイプライン挙動 |
|---|---|---|
0 | 差分なし | apply を skip |
1 | エラー | パイプライン失敗 |
2 | 差分あり | apply 候補 |
これを使うと 「差分が無いのに apply される」事故 を構造的に防げます。
🛡️ 安全装置
| 機構 | 目的 | 実装 |
|---|---|---|
| state lock | 同時 apply の防止 | GCS バックエンドのデフォルト機能で自動 |
| 手動承認 | 本番 apply のヒューマンゲート | when: manual + environment: |
| 環境ごとの保護ブランチ | main 直 push 禁止 | GitLab Project Settings |
| レビュー必須化 | 単独 merge を禁止 | MR Approval Rules(最低 1 名) |
| plan artifact の TTL | 古い plan の流用防止 | expire_in: 1 day |
| CODEOWNERS | 重要スタックの自動レビュアー指定 | terraform/environments/prod/* @sre-team |
同時 apply を防ぐ理由
GCS バックエンドは state lock をネイティブサポートしているため、複数ジョブが同じ state に対して apply するとロック競合で 1 本目以外が即失敗します。これに加えて GitLab の resource_group: を併用すると、 「ロック取得失敗による無駄なリトライ」 を CI レベルで抑止できます。
apply:
resource_group: ${ENV}/${STACK} # 同一スタックの apply は直列化
環境保護
environment:
name: prod/my-app
deployment_tier: production
deployment_tier: production を宣言しておくと、GitLab UI 上で本番デプロイを視覚的に区別でき、 誤って staging を本番として扱うミス を減らせます。
🔄 マルチスタック・マルチクラウドのパイプライン
GCP・AWS のスタックを 1 本のパイプラインに同居させる際は、 State バックエンドを GCS に統合した上で(準備編 参照)、ジョブ単位で必要なクラウドの認証だけをロードする構成が運用しやすくなります。
AWS スタック向けのジョブでは、前掲の クロスクラウド認証コンポーネントが働きます。GCP の認証で得た OIDC トークンを起点に AWS STS へ連鎖させ、短命の AWS 一時クレデンシャルだけを取得してから Terraform を実行します(連鎖の実装はスクリプトに閉じます)。
要点:
- AWS 単独スタックは AWS 認証を組み込んだジョブテンプレートを
extends:する - AWS アクセスキーを CI Variables に持たない(取得するのは短命の一時クレデンシャルのみ)
- セッション名に パイプライン ID・スタック名を含めることで CloudTrail 追跡が容易
🚫 ありがちな失敗
| 失敗 | 何が起きるか | 対策 |
|---|---|---|
terraform apply を script: に直書き | レビューなしで本番反映される事故 | 必ず plan / apply を分離し、apply は when: manual |
| state ファイルをローカルに置く | チーム間で State が分散 | リモートバックエンド(GCS / S3)必須 |
local-exec で gcloud を呼ぶ | プロビジョナがべき等性を失い、再実行不能 | リソースは Terraform Provider で表現 |
count でスタックを切り替える | 環境差分が tfstate に残り State migration 不能 | 環境はディレクトリで分け、environments/<env> 単位の State |
| 同時 apply の競合 | state lock 取得失敗で CI が大量失敗 | resource_group: で直列化 |
terraform fmt / validate を CI に入れない | フォーマット崩れと簡易ミスが merge される | pre-commit + CI の両方で実行 |
| plan 結果を MR にコメントしない | レビュアーが影響範囲を把握できない | gitlab-terraform 等で plan サマリを自動コメント |
📝 CTS-EC での運用
CTS-EC では、本構成を以下のように運用しています。
- GitLab CI 1 本 で GCP・AWS の全スタックを管理
- 認証は WIF(GitLab → GCP → AWS)の連鎖、長寿命キーゼロ
- staging は MR merge で自動 apply、 prod のみ
when: manual resource_group:でスタック単位の apply を直列化terraform planの結果を MR コメント Bot で自動投稿し、レビュアーが差分を即時確認
これにより、 「インフラ変更が PR 上で完結」「本番反映は人間ゲート」「鍵漏洩リスクゼロ」 の 3 点が成立しています。
🎯 まとめ
| やめるべき運用 | 置き換え先 |
|---|---|
ローカルから terraform apply | GitLab CI で plan / apply を分離 |
| サービスアカウントキー | WIF(Workload Identity Federation) |
数万行に育つ .gitlab-ci.yml(秘伝のタレ化) | 動的 child pipeline で CI 設定を薄く保つ |
| 全スタック走査 | 動的パイプラインで変更分のみ実行 |
| 自動 apply(無条件) | 本番のみ when: manual のヒューマンゲート |
| State の各クラウド散在 | GCS バックエンドへ統合 |
Terraform は「書く」より「運用する」フェーズの設計が事故を分けます。CI/CD パイプラインを キーレス・動的・分離 の 3 原則で組み上げれば、IaC は安全な日常運用に乗ります。とりわけ 動的 child pipeline 生成は、スタックが何百に増えても CI 設定を肥大化させない——スケールしても保守コストが一定という、モジュール管理 Infra CI/CD の決定的な差になります。
📚 シリーズ記事
| # | タイトル | 内容 |
|---|---|---|
| 1 | 概要 | マルチクラウド構成の全体像と AI 駆動運用 |
| 2 | 準備編 | アカウント設計・WIF 認証・bootstrap・State 管理 |
| 3 | モジュール設計編 | 再利用可能なモジュールの設計原則 |
| 4 | CI/CD パイプライン編(本記事) | 動的 child pipeline・WIF キーレス認証・GCP↔AWS |
| 5 | 運用編 | State drift 解消・障害対応・AI 運用ループ |
関連記事
- WIF でキーレス認証 — 本記事の認証レイヤの詳細(GitLab→GCP・GitLab→AWS・GCP↔AWS)
- GitLab → GCP 認証方式の 3 世代と CTS の選択 — 本パイプラインで使い分けている認証方式の技術選定
- GCP → AWS キーレス認証の落とし穴 — マルチクラウドパイプラインで踏む 7 つの罠
- BtoB SaaS セキュリティ設計総覧 — インフラ・キーレス認証の全体位置づけ
関連用語
- child pipeline(子パイプライン) — 本記事の核心。動的生成で CI 設定を薄く保つ
- Workload Identity Federation — キーレス認証の中心概念
- GitLab id_tokens — 本記事 YAML 例の OIDC トークン取得キーワード
- OpenID Connect — 認証プロトコルの基盤
外部資料
- GitLab: Dynamic child pipelines — ジョブ内で生成した YAML から子パイプラインを発行する公式手法
- GitLab Parent-Child Pipelines
- Terraform State Locking — HashiCorp
- Terraform GCS Backend