🛡️

Terraform 変更を安全にレビューする — plan の読み方・CI destroy 検知・承認フロー設計

に公開

Terraform のレビューはコードレビューではない

Terraform の PR レビューを「コードレビュー」と同じ感覚でやると本番が壊れる。
plan 出力に埋もれた destroy 行、resource 追加に紛れた IAM 権限の昇格、セキュアな値の漏洩。これらは「コードが綺麗か」を見る目では捕まらない。

差分を読む順序、CI で機械的に弾く仕組み、誰がどこを承認するかというフロー設計。3つが揃って初めてレビューが機能する。

なぜ Terraform 変更は普通の PR レビューでは守れないのか

理由は3つある。

plan 出力が長すぎて危険な行が埋もれる: 本番環境の terraform plan は数百行を超えることがある。+ resource ... の山の中に - aws_db_instance.main の1行が紛れ込む。レビュアーは出力を見落とす可能性が高まる。

コード差分と plan 出力は別物: prevent_destroy を外した1行のコード変更が、plan 上では無関係に見える destroy 連鎖を引き起こすことがある。コード差分だけを見ても影響範囲が読めない。

承認フローが曖昧だと止まらない: 「誰でも apply できる」「特定の1人が承認すれば全領域に通る」状態では、本番リソースの変更を止める壁がない。チーム全体の合意なしに本番が変わる。

これらを踏まえて、レビューを次の3層で組み立てる。

  1. plan 出力を人間が読むときの観点
  2. CI で機械的に弾く仕組み
  3. チームの承認フロー

plan 出力の読み方 — 上から順に読まない

plan を最初の行から最後まで通読するのは効率が悪い。重要なのは「何を見落とすと事故になるか」を逆算してから読むことだ。

優先して確認するのは4つ。

1. destroy と replace を最初に探す

terraform plan の summary 行(Plan: X to add, Y to change, Z to destroy.)を最初に見る。Z が 0 でなければ内訳を全て確認する。

terraform plan -no-color | grep -E "^\s+# .* will be destroyed$"
terraform plan -no-color | grep -E "^\s+# .* must be replaced$"

will be destroyed は明示的な削除、must be replaced は force_new 属性の変更による削除+再作成だ。RDS や EBS のような状態を持つリソースが replaced に入っていれば、データが消える。

2. IAM の権限拡張を見る

aws_iam_policyaws_iam_role_policy の差分は、文字列1つ違うだけで権限範囲が大きく変わる。

   "Resource": [
-    "arn:aws:s3:::my-bucket/*"
+    "arn:aws:s3:::*"
   ]

この変更は plan の ~ jsonencode(...) 行で1文字差として表現される。レビュアーは「以前と何が変わったか」を decode して読む必要がある。

IAM 差分は次の観点で見る。

  • Resource のワイルドカードが広がっていないか
  • Action* や管理系 API(iam:PassRole iam:CreateAccessKey など)が追加されていないか
  • Condition ブロックが弱まっていないか(IP 制限が外れる等)

3. sensitive 値の漏洩を確認する

(sensitive value) でマスクされていない秘密情報がコード差分に紛れていないか確認する。RDS のパスワード、API キー、SSH 鍵がリポジトリに混入する事故は今でも起きる。

git diff origin/main...HEAD -- '*.tf' '*.tfvars' | \
  grep -E "(password|secret|token|api_key)\s*=\s*\""

*.tfvars を gitignore に入れるのが基本だが、PR 段階で機械的に検知する仕組みがあれば漏洩リスクが下がる。

4. count / for_each の変化を見る

count = var.enabled ? 1 : 0 のような条件分岐は、変数が変わるとリソースごと消える。for_each の map キー変更も既存リソースの destroy / create を引き起こす。

count や for_each の元になる変数の変更は、plan summary で意図しない destroy として現れる。コード差分で「count や for_each に渡している変数が変わっていないか」を確認する。

CI で destroy を機械的に弾く

人間のレビューは見落とす。CI で機械的に弾く仕組みを置く。

GitHub Actions で plan の destroy を検知して PR にコメントを残す例。

name: terraform-plan-check

on:
  pull_request:
    paths:
      - '**.tf'
      - '**.tfvars'

jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.11.0

      - name: terraform init
        run: terraform init

      - name: terraform plan
        run: terraform plan -no-color -out=tfplan > plan.txt 2>&1 || true

      - name: detect destroy
        id: detect
        run: |
          DESTROY=$(grep -cE "^\s+# .* will be destroyed$" plan.txt || true)
          REPLACE=$(grep -cE "^\s+# .* must be replaced$" plan.txt || true)
          echo "destroy=${DESTROY}" >> $GITHUB_OUTPUT
          echo "replace=${REPLACE}" >> $GITHUB_OUTPUT

      - name: comment on PR
        if: steps.detect.outputs.destroy != '0' || steps.detect.outputs.replace != '0'
        uses: actions/github-script@v7
        with:
          script: |
            const destroy = '${{ steps.detect.outputs.destroy }}';
            const replace = '${{ steps.detect.outputs.replace }}';
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `⚠️ destroy: ${destroy} / replace: ${replace}\n破壊的変更が含まれています。レビュアーは plan を確認してください。`
            });

これで PR を開いた瞬間に「破壊的変更があるか」が PR コメントに残る。レビュアーは plan を全文読まなくても、まずコメントを見て確認すべきポイントを掴める。

terraform:destroy のような label を自動付与すれば、branch protection の require-approval 数を引き上げる仕組みと組み合わせられる。「破壊的変更が入っている PR は2人承認」という運用が機械的に強制できる。

チームの承認フロー設計

技術的な検知ができても、誰が承認するかの設計がないと止まらない。

CODEOWNERS で領域別の承認者を決める

.github/CODEOWNERS でディレクトリ別に承認者を分ける。

# .github/CODEOWNERS

# 本番環境(SRE チーム必須)
/environments/production/   @org/sre-team

# ステージング(バックエンドチームでも可)
/environments/staging/      @org/sre-team @org/backend-team

# モジュール(プラットフォームチーム)
/modules/                   @org/platform-team

これと branch protection の Require review from Code Owners を組み合わせると、本番ディレクトリの変更には SRE チームの承認が必須になる。チーム外の人が apply まで通せなくなる。

Environment 別の承認ゲート

GitHub Environments を使うと、apply ジョブが起動する前に追加の手動承認を入れられる。

jobs:
  apply:
    runs-on: ubuntu-latest
    environment:
      name: production  # protection rules で承認者を指定する
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.11.0
      - run: terraform init
      - run: terraform apply -auto-approve tfplan

Settings > Environments > production で「Required reviewers」を設定すると、apply ジョブが動く前に承認画面が出る。CODEOWNERS が PR レビュー時の壁、Environment が apply 直前の壁、と二重化できる。

plan と apply を分離する

PR マージ=apply ではなく、plan は PR で確認、apply は明示的なトリガーで実行する。

  • PR で terraform plan を実行 → 出力をコメントで残す
  • main にマージしても apply は走らない
  • workflow_dispatch(手動実行)か tag push で apply 専用の workflow を起動 → Environment 承認 → apply

merge と apply の間に「人間が apply を起動する」一手間が入る。これが最終承認の壁になる。コードがマージされた瞬間に本番反映される設計は、レビューが甘くなったときに逃げ場がない。

自動化と人間の判断、両方がいる

CI の destroy 検知は「機械的に弾けるところ」を担当する。plan の精読と承認は「機械では判断できないところ」を担当する。

destroy が出ているとき、それが意図したマイグレーションなのか事故なのかは、文脈を知る人間しか判断できない。逆に、人間は数百行の plan を毎回精読するのは事故の元となる。CI の補助なしに「全 PR を完全にレビューする」のは現実的ではない。

3層を整えると、PR 単位で「誰の目を通って何を承認したか」が記録に残る。事故が起きてもなぜ通ったかが追える。
レビューフローの設計は、設計の良し悪しと同じくらい、事故が起きたときに学びを残せるかを左右する。

Discussion