0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PRでterraform planをレビューしてからapplyする2段階フローにする

0
Posted at

前々回の記事の最後で「terraform apply -auto-approveをCIで無条件に走らせてるのが気になる」と書いた。今回はその改善として、PRを作った時点でterraform planの結果をコメントで見れるようにして、マージされたら初めてapplyするという2段階フローに変える。

ちなみに過去に一度、似た仕組み(terraform.ymlという別ファイル)を作って、deploy.ymlと衝突させてロックの奪い合い事故を起こしたことがある。今回はその反省を踏まえて、別ファイルに分けず、deploy.ymlの中に1つのジョブとして追加する形にする。イベント(pull_requestとpush)で完全に住み分けるので、同じstateに対して同時にapplyが走ることはなくなる。

スクリーンショット 2026-09-30 231352.png

before/after

Before: mainにpushした瞬間、いきなりterraform apply -auto-approveが走る。差分を人間が事前に見る機会がない。

After: PRを作るとterraform planが走ってPRにコメントされる → 人間がレビュー → mainにマージされて初めてapplyが走る。

Before: push to main ──────────────→ terraform apply(いきなり本番反映)

After:  PR作成 → terraform plan → PRにコメント → レビュー → mainにマージ → terraform apply

設計のポイント

  • 別ワークフローファイルを作らない。 deploy.ymlの中にplanジョブを追加するだけ。イベントトリガーをpull_requestとpushで完全に分けるので、同じproduction stateに対してplanとapplyが同時に動くことはない
  • hashicorp/setup-terraform@v3には、terraformコマンドをラップして実行結果をsteps.<id>.outputs.stdoutとして自動的に取れるようにする機能(terraform_wrapper、デフォルトで有効)がある。これを使ってplan結果をPRコメントに貼り付ける
  • 前回ハマった「TF_VAR_alert_emailを渡し忘れて対話入力待ちでハングする」を繰り返さないよう、planジョブにもapplyジョブと同じenvを必ず揃える

.github/workflows/deploy.yml の全体

name: Deploy to Cloud Run

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
    paths:
      - "terraform/**"

permissions:
  contents: read
  pull-requests: write

concurrency:
  group: terraform-production
  cancel-in-progress: false

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: todoapp_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"
      - run: npm ci
      - run: npm test
        env:
          DB_HOST: 127.0.0.1
          DB_PORT: 5432
          DB_USER: testuser
          DB_PASSWORD: testpass
          DB_NAME: todoapp_test

  plan:
    if: github.event_name == 'pull_request'
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: google-github-actions/auth@v2
        with:
          credentials_json: "${{ secrets.GCP_SA_KEY }}"

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.9.8"

      - name: Terraform Init
        working-directory: terraform/environments/production
        run: terraform init

      - name: Terraform Plan
        id: plan
        working-directory: terraform/environments/production
        run: terraform plan -no-color -var="image_tag=${{ github.sha }}"
        continue-on-error: true
        env:
          TF_VAR_project_id: ${{ secrets.GCP_PROJECT_ID }}
          TF_VAR_region: asia-northeast1
          TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
          TF_VAR_alert_email: ${{ secrets.ALERT_EMAIL }}

      - name: Comment plan result on PR
        uses: actions/github-script@v7
        with:
          script: |
            const output = `#### Terraform Plan
            \`\`\`
            ${{ steps.plan.outputs.stdout }}
            \`\`\`
            `;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            });

      - name: Fail if plan failed
        if: steps.plan.outcome == 'failure'
        run: exit 1

  deploy:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: google-github-actions/auth@v2
        with:
          credentials_json: "${{ secrets.GCP_SA_KEY }}"

      - uses: google-github-actions/setup-gcloud@v2

      - name: Configure Docker
        run: gcloud auth configure-docker asia-northeast1-docker.pkg.dev

      - name: Build and push image
        run: |
          docker build -t asia-northeast1-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/todo-api-repo/todo-api:${{ github.sha }} .
          docker push asia-northeast1-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/todo-api-repo/todo-api:${{ github.sha }}

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.9.8"

      - name: Terraform apply (production)
        working-directory: terraform/environments/production
        run: |
          terraform init
          terraform apply -auto-approve -input=false \
            -var="image_tag=${{ github.sha }}"
        env:
          TF_VAR_project_id: ${{ secrets.GCP_PROJECT_ID }}
          TF_VAR_region: asia-northeast1
          TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
          TF_VAR_alert_email: ${{ secrets.ALERT_EMAIL }}

変更点を1つずつ説明する

1. トリガーにpull_requestを追加

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
    paths:
      - "terraform/**"

pushは今まで通り。pull_requestはterraform/**配下に変更があった時だけ発火するようにして、アプリコードだけのPRで毎回planが走らないようにしてる。

2. concurrencyブロックを追加

concurrency:
  group: terraform-production
  cancel-in-progress: false

これは念のための保険。planとapplyは基本イベントで排他になってるけど、「PRマージと同時に誰かが直接pushした」みたいな極端なケースでも、同じgroup名の実行は順番待ちになるようにしておく。cancel-in-progress: falseにしてるので、先に走ってる方を強制中断せず、キューで順番に処理される。

3. planジョブを新設

if: github.event_name == 'pull_request'で、PRの時だけ動く。applyは絶対に実行しない(terraform planだけ)。

id: planとcontinue-on-error: trueがポイント。plan自体が失敗(構文エラーなど)してもジョブを即失敗させず、その内容もコメントで見れるようにしてから最後にFail if plan failedステップで改めて失敗扱いにする。こうすることで「なぜ失敗したか」がPR上で分かるようになる。

4. PRコメント投稿

- name: Comment plan result on PR
  uses: actions/github-script@v7
  with:
    script: |
      const output = `#### Terraform Plan
      \`\`\`
      ${{ steps.plan.outputs.stdout }}
      \`\`\`
      `;
      github.rest.issues.createComment(...)

steps.plan.outputs.stdoutは、hashicorp/setup-terraformのwrapper機能によって自動的に生成される。特別な設定を追加しなくても、terraformコマンドを実行した後は勝手にこの出力が使えるようになってる。

5. applyジョブはpush限定に

deploy:
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'

これで「PRの時はapplyが絶対に走らない」ことを明示的に保証してる。

手元で確認する

gh(GitHub CLI)が入ってる前提で、実際に動かして確認するコマンド一式。入ってなければwinget install --id GitHub.cliで入る。

1. 確認用ブランチを切って、terraformファイルに無害な差分を作る

差分が無いとplanの結果が味気ないので、コメントを1行足すだけの変更にする。

git checkout -b test/plan-comment
Add-Content -Path terraform\environments\production\provider.tf -Value "`n# plan comment flow test"
git add terraform\environments\production\provider.tf
git commit -m "test: PRコメントフローの動作確認用の無害な差分"
git push -u origin test/plan-comment

2. PRを作る

gh pr create --base main --head test/plan-comment --title "test: plan comment flow" --body "PRコメントフローの動作確認用"

実行すると作成されたPRのURLが返ってくる。

3. planジョブの実行状況をターミナルから確認する

ブラウザのActionsタブを開かなくても、CLIだけで進捗を追える。

gh pr checks --watch

planジョブが完了するまで自動で待機してくれる。

4. PRに付いたコメントを確認する

gh pr view --comments

#### Terraform Planから始まるコメントが自動で付いていれば成功。terraform planの実際の差分内容がそのまま読める。

5. 問題なければマージする

gh pr merge --squash --delete-branch

6. マージ後、deployジョブが走ることを確認する

gh run list --workflow=deploy.yml --limit=3

直近の実行一覧が出るので、マージ直後の実行を選んでログを見る。

gh run watch

これでapplyまで正常に完走すれば、2段階フローが一通り機能してることが確認できる。

後片付け

動作確認だけが目的の変更なので、テスト用に足したコメント行は次のPRで消しておく(残しても実害はないが、意図の分からないコメントが残るのは避けたい)。

実際にハマった: コスト対策で止めてたCloud SQLがdeployを落とした

スクリーンショット 2026-09-30 231421.png

この2段階フローを作った後、実際に動作確認をしようとしたらdeployジョブのterraform applyが失敗した。原因は単純で、別件でコスト対策としてCloud SQLインスタンスをactivation-policy=NEVER(停止)にしたまま忘れていたこと。

厄介だったのは、planジョブの方は問題なく完走していた点。PRコメントも普通に付いたし、レビューして「よし、マージしよう」となった後、mainへのマージをトリガーに走ったdeployジョブだけが失敗した。planは通ったのにapplyだけ落ちるという状況は焦る。

対処はシンプルで、Cloud SQLを再起動してから再実行するだけ。

gcloud sql instances patch todo-api-db --activation-policy=ALWAYS --project=my-hono-app-v2

起動には数分かかるので、状態を確認してから再実行する。

gcloud sql instances describe todo-api-db --project=my-hono-app-v2 --format="value(state)"

RUNNABLEになったのを確認してから、失敗したワークフローを再実行する。

gh run list --workflow=deploy.yml --limit=3
gh run rerun <失敗したrun ID>

教訓: コスト対策として何かを止めた時は、それを忘れて次のデプロイでハマる未来が高確率でやってくる。止めた対象と再開のコマンドを、こういう記事なりメモなりに残しておくと、未来の自分が助かる。理想を言えば、deployジョブの先頭に「対象リソースが起動してるか確認する」ステップを入れて、止まってたら分かりやすいエラーメッセージで早期に落とす、という改善もできそうだが、個人開発の規模ではそこまでやるかは判断が分かれるところ。

気をつけたこと

  • planジョブとapplyジョブ、どちらもgoogle-github-actions/authで同じサービスアカウント(GCP_SA_KEY)を使っている。planは読み取りだけのはずだが、Terraformのplanはリソースの現在の状態を読みに行く都合上、applyと同程度の権限が必要になる。権限を絞りたい場合は、読み取り専用ロールを持つ別のサービスアカウントをplan用に用意する余地がある
  • PRコメントのsteps.plan.outputs.stdoutは、planの内容が長いとGitHubコメントの文字数制限(65536文字)に引っかかる可能性がある。今回の規模では問題ないが、リソースが増えてきたら出力を要約する工夫が要る
  • concurrencyは同じgroup名の実行をキューイングするだけで、待ち時間中にstate lockが解放されないまま次のジョブが待ち続けるケースはまだあり得る。前回のようなロック事故を完全にゼロにするものではなく、あくまで「発生確率を下げる」保険
  • コスト対策(リソースを止める)と動作確認(リソースを動かす前提)は表裏一体。デプロイ前にリソースの起動状態を確認する習慣をつけておくと事故りにくい

まとめ

  • 別ワークフローファイルを増やすのではなく、既存のdeploy.ymlにpull_requestトリガーのジョブを1本足すだけで、PRレビュー付きの2段階フローが作れる
  • planとapplyをイベント(pull_request/push)で完全に分離することで、以前起きたような「2つのワークフローが同じstateを取り合う」事故を構造的に防げる
  • hashicorp/setup-terraformのwrapper機能を使えば、terraformコマンドの出力をそのままPRコメントに転用できる

これで一連のTerraform/CI周りの改善は一区切り。次は実際に運用しながら、コスト面や監視面で気になったところをまた記事にしていく予定。

0
0
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?