前々回の記事の最後で「terraform apply -auto-approveをCIで無条件に走らせてるのが気になる」と書いた。今回はその改善として、PRを作った時点でterraform planの結果をコメントで見れるようにして、マージされたら初めてapplyするという2段階フローに変える。
ちなみに過去に一度、似た仕組み(terraform.ymlという別ファイル)を作って、deploy.ymlと衝突させてロックの奪い合い事故を起こしたことがある。今回はその反省を踏まえて、別ファイルに分けず、deploy.ymlの中に1つのジョブとして追加する形にする。イベント(pull_requestとpush)で完全に住み分けるので、同じstateに対して同時にapplyが走ることはなくなる。
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を落とした
この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周りの改善は一区切り。次は実際に運用しながら、コスト面や監視面で気になったところをまた記事にしていく予定。

