前回・前々回の記事に、読者の方から技術的に鋭いコメントを2件もらった。どちらも「言われてみれば確かに」という実害のある指摘だったので対応した。さらに、その対応の最中に自分でも構造的な問題を1つ見つけたので、合わせて3件まとめる。
もらったコメント
①「バックアップ・DR編」への指摘(selene-nyx-aiさん)
pg_dump --table=todos --data-onlyの出力には、INSERT文に加えて所有シーケンスの値を設定するsetvalも含まれます。id 1〜3のダンプを、全削除後にid 4・5を追加した表へ流したところ、行は復元されましたが、シーケンスの値は3に戻り、次のINSERTがid 4の重複で止まりました。
②「PRレビュー付き2段階フロー」への指摘(rakiさん)
Terraformのバージョンが低いので上げましょう。setup-terraformもv4.0.1が最新です。Actionsのバージョンはハッシュでpin留めしたほうがいいでしょう。このかたちだと複数PRが出ている時に、マージ順序が前後したり複合したりするとアウトになります。
③ 対応中に自分で見つけた問題
②の対応(Actionsのコミットハッシュpin留め)を進めている最中、もう1つ問題を見つけた。「Actionsをpinする」という変更自体は.github/workflows/deploy.ymlの更新なので、実際にこれをPRにして試したところ、deployジョブが
対応するplan実行が見つかりませんでした
で失敗した。調べてみると、このPRの差分が.github/workflows/deploy.ymlのみでterraform/配下を一切含んでいなかったことが原因だった。pull_requestトリガーに付けていたpaths: ["terraform/**"]フィルタのせいで、terraform/配下を含まない変更ではplanジョブ自体が起動せず、マージ後のdeployジョブが前提にしているplanのartifactが存在しないまま失敗する、という構造的な問題。
1つずつ対応していく。
①の対応: 復元後にシーケンスを明示的に合わせ直す
何が起きていたか
PITR復元の手順では、「クローンから壊れる前のデータだけpg_dumpで取り出し、本番にpsqlで流し込む」という形にしていた。ここで使っていた
pg_dump -h 127.0.0.1 -p 5433 -d todoapp -U appuser `
--table=todos --data-only --column-inserts --file=todos_recover.sql
というコマンドの出力には、実はINSERT文だけでなく、todos_id_seq(主キーの連番用シーケンス)の値をダンプ時点の値に設定するSELECT pg_catalog.setval(...)文も含まれている。
これが流し込まれると、シーケンスの値がダンプ時点(壊れる前)まで巻き戻る。もし復元の作業中に本番側で新しいtodoが追加されていたら(今回の記事ではまさにそのケースを想定して「必要なデータだけ戻す」設計にしていた)、次に新しいtodoを作ろうとした時に、巻き戻ったシーケンスが既に使われているidを採番してしまい、主キー重複でエラーになる。
直し方
流し込みの後に、シーケンスをテーブルの実際の最大idに明示的に合わせ直す一手間を加える。
psql -h 127.0.0.1 -p 5432 -d todoapp -U appuser -f todos_recover.sql
psql -h 127.0.0.1 -p 5432 -d todoapp -U appuser -c "SELECT setval('todos_id_seq', (SELECT MAX(id) FROM todos));"
シーケンス名がうろ覚えの場合は、以下で確認できる。
SELECT pg_get_serial_sequence('todos', 'id');
注意点として、todosテーブルが空になった状態でこれを実行するとMAX(id)がNULLを返し、setvalがエラーになる。 今回は復元後に必ず行が存在する前提の手順なので問題ないが、「全件削除した状態のままシーケンスだけ整えたい」というケースでは、COALESCEで0にフォールバックさせる形が安全。
SELECT setval('todos_id_seq', COALESCE((SELECT MAX(id) FROM todos), 1), false);
「バックアップ・DR編」の記事本文、3-3節にもこのコマンドを追記しておいた。
②の対応: バージョン更新・SHA pin・plan/applyの整合性
3つの指摘があったので、順番に対応する。
2-1. Terraform / setup-terraformのバージョンを上げる
確認した時点での最新は、Terraform本体が1.16.1、hashicorp/setup-terraformアクションがv4.0.1。deploy.ymlを以下のように更新する。
- uses: hashicorp/setup-terraform@v3
with:
- terraform_version: "1.9.8"
+ terraform_version: "1.16.1"
hashicorp/setup-terraform@v3の方は、次のSHA pinの対応と合わせて書き換える。
2-2. Actionsをコミットハッシュでpin留めする
uses: actions/checkout@v4のような書き方は、v4というタグが指す中身が後から(善意・悪意問わず)差し替えられるリスクがある。コミットハッシュで固定すれば、「このワークフローが実際に実行するコードは常に同じもの」という保証ができる。
ハッシュは時間が経てば更新対象になるものなので、記事に固定値を書くより自分で取得するコマンドを用意しておく。
# 使ってる各Actionsの現在のタグが指すコミットハッシュを一覧取得
$actions = @(
"actions/checkout@v4",
"google-github-actions/auth@v2",
"google-github-actions/setup-gcloud@v2",
"hashicorp/setup-terraform@v4.0.1",
"actions/github-script@v7",
"actions/upload-artifact@v4",
"actions/download-artifact@v4"
)
foreach ($a in $actions) {
$repo, $tag = $a -split "@"
$sha = git ls-remote "https://github.com/$repo.git" "refs/tags/$tag" | ForEach-Object { ($_ -split "\t")[0] }
Write-Host "$a -> $repo@$sha # $tag"
}
出力された<repo>@<SHA> # <tag>の形式を、ワークフローのuses:に反映する。たとえばactions/checkout@v4は
- uses: actions/checkout@<ここに出力されたSHA> # v4
という形になる。コメントで元のタグ名を残しておくと、後で「これが何のバージョンか」を見失わずに済む。
運用上の注意: コミットハッシュでpinすると、Dependabotなどの自動更新の仕組みが働かなくなる(タグを見てないので)。GitHubはDependabotがuses: owner/repo@<SHA> # vX.Y.Zというコメント付きの書式を認識してpin留めされたActionsもバージョンアップのPRを出してくれるので、コメントの書式はできるだけ上記の形に揃えておくのがおすすめ。
2-3. マージ順序が壊れた時にplan/applyの整合性が崩れる問題
これが一番本質的な指摘だった。
何が問題だったか
今の設計は「PRでterraform planを見せる」→「マージ時に改めてterraform planし直してapplyする」という流れ。つまりPRでレビューしたplanの内容と、実際にapplyされる内容が保証として繋がっていない。
複数のPRが並行して開いている状況を考える。PR #10、#11、#12がそれぞれterraform/配下を変更していて、マージされる順番が#12 → #10 → #11のように前後すると、各PRがplanを取った時点のstateと、実際にマージされてapplyが走る時点のstateがズレる。今の設計だと、このズレに気づかないままapply -auto-approveが走ってしまう。
直し方: plan結果をファイルとして保存し、マージ時はそのまま適用する
terraform plan -out=tfplanでplan結果をファイルに保存し、それをGitHub Actionsのartifactとして持っておく。マージ時は、**保存されたファイルをそのままapply**する(再planしない)。
Terraformは、保存されたplanファイルをapplyする際、適用対象のstateが「planを作った時点」から変化していないかを検証する。マージ順序が壊れてstateがズレていれば、ここでエラーになって止まる。これがrakiさんの提案の核心。
.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
actions: read
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@<SHA> # 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@<SHA> # v4
- uses: google-github-actions/auth@<SHA> # v2
with:
credentials_json: "${{ secrets.GCP_SA_KEY }}"
- uses: hashicorp/setup-terraform@<SHA> # v4.0.1
with:
terraform_version: "1.16.1"
- 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 -out=tfplan -var="image_tag=${{ github.event.pull_request.head.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: Show plan (human readable, for the PR comment)
if: steps.plan.outcome == 'success'
working-directory: terraform/environments/production
run: terraform show -no-color tfplan
- name: Upload plan file as artifact
if: steps.plan.outcome == 'success'
uses: actions/upload-artifact@<SHA> # v4
with:
name: tfplan-${{ github.event.pull_request.number }}
path: terraform/environments/production/tfplan
retention-days: 3
- name: Comment plan result on PR
uses: actions/github-script@<SHA> # 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@<SHA> # v4
- name: Find the merged PR for this commit
id: find_pr
uses: actions/github-script@<SHA> # v7
with:
script: |
const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.sha,
});
if (prs.data.length === 0) {
core.setFailed("このコミットに対応するPRが見つかりませんでした");
return;
}
const pr = prs.data[0];
core.setOutput("pr_number", pr.number);
core.setOutput("pr_head_sha", pr.head.sha);
- name: Find the plan workflow run for that PR
id: find_run
uses: actions/github-script@<SHA> # v7
with:
script: |
const runs = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: "deploy.yml",
event: "pull_request",
status: "success",
});
const match = runs.data.workflow_runs.find(
(r) => r.head_sha === "${{ steps.find_pr.outputs.pr_head_sha }}"
);
if (!match) {
core.setFailed("対応するplan実行が見つかりませんでした");
return;
}
core.setOutput("run_id", match.id);
- name: Download the saved plan file
uses: actions/download-artifact@<SHA> # v4
with:
name: tfplan-${{ steps.find_pr.outputs.pr_number }}
path: terraform/environments/production
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ steps.find_run.outputs.run_id }}
- uses: google-github-actions/auth@<SHA> # v2
with:
credentials_json: "${{ secrets.GCP_SA_KEY }}"
- uses: google-github-actions/setup-gcloud@<SHA> # v2
- name: Configure Docker
run: gcloud auth configure-docker asia-northeast1-docker.pkg.dev
- name: Build and push image (同じタグでplan時と揃える)
run: |
docker build -t asia-northeast1-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/todo-api-repo/todo-api:${{ steps.find_pr.outputs.pr_head_sha }} .
docker push asia-northeast1-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/todo-api-repo/todo-api:${{ steps.find_pr.outputs.pr_head_sha }}
- uses: hashicorp/setup-terraform@<SHA> # v4.0.1
with:
terraform_version: "1.16.1"
- name: Terraform apply (保存されたplanをそのまま適用)
working-directory: terraform/environments/production
run: |
terraform init
terraform apply -input=false tfplan
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 }}
変更点の補足
-
image_tagをgithub.shaではなくgithub.event.pull_request.head.shaにした。pull_requestイベントのgithub.shaはGitHubが作る一時的なマージコミットのハッシュで、PRのhead(実際のコミット)とは別物。保存したplanファイルに焼き込まれたimage_tagと、実際にビルド&プッシュするDockerイメージのタグを一致させるには、PRのheadのSHAで揃える必要がある -
deployジョブの先頭で、マージされたコミットから元のPR番号を特定する。listPullRequestsAssociatedWithCommitはGitHubのAPIで、squash mergeでも正しく元のPRを返してくれる -
その後、PRのhead SHAを使って、該当する
planジョブの実行(workflow run)を特定し、そこにアップロードされたplanファイルをダウンロードする。 run間でのartifact受け渡しはactions/download-artifactのrun-idパラメータでできる(別ワークフロー実行からの取得はgithub-tokenの指定が必要) -
terraform apply tfplanは、保存時点のstateから変化していたら失敗する。 これが本来欲しかった安全装置
動作確認: マージ順序を意図的に壊してみる
実際に壊れるケースを再現して確認する。
- 2つのPR(A, B)を同時に作り、両方とも
terraform/配下の別々のリソースを変更する - PR Aを先にマージして
applyを完走させる - PR Bの
planは、PR Aがマージされる前のstateに対して作られたものなので、PR Bをこの後マージすると、保存されたplanファイルとstateの間にズレが生じる - PR Bをマージして
deployジョブを実行し、terraform apply tfplanが
Error: Saved plan is stale
のようなエラーで安全に失敗することを確認する
失敗した場合の対処は、PR Bをもう一度最新のmainにリベースしてplanからやり直すこと。これにより、「古いplanのまま誤って適用される」事故が構造的に防げる。
ただし注意点がある。 この対処が効くのは「deployのapplyが失敗する前、PRがまだオープンな状態でstaleに気づけた場合」のみ。今回のようにマージ後のdeployジョブでapplyが失敗したケースでは、PR自体はもうクローズ済みなので、そのブランチをリベースしてpushしてもpull_requestイベントが発火せず、planは再実行されない。
この場合は、新しいPR(差分が空でもいい)を作って、改めてplan→applyのサイクルを通す必要がある。
git checkout main
git pull
git checkout -b fix/reapply-state
git commit --allow-empty -m "stale planで失敗したapplyを再実行"
git push -u origin fix/reapply-state
gh pr create --title "stateを最新化" --body "stale planで失敗したapplyの再実行" --base main
コード自体はsquash mergeで既にmainに反映済みでも、applyが失敗した以上、実際のGCPリソース(state)はまだ古いままという点がポイント。「マージ済み」と「stateに反映済み」は別物、というのが今回の実地検証で分かった教訓。
③の対応: pathsフィルタを外す
何が起きていたか
deploy.ymlのトリガーは
on:
push:
branches: [main]
pull_request:
branches: [main]
paths:
- "terraform/**"
となっていて、pull_requestイベント全体(testもplanも)がpaths: ["terraform/**"]で絞られている。つまりterraform/配下を含まない変更では、PRの時点でplanジョブそのものが起動しない。マージ後のdeployジョブは「対応するplanのartifactが必ずある」前提で動くので、これが存在しないと即座に失敗する。
なぜsrc配下のアプリケーションコードの変更でも同じことが起きるか
最初は「src配下だけの変更ならインフラは変わらないんだから、planをスキップしてもいいのでは」と考えたが、これは誤りだった。前回の記事で、Cloud Runのイメージタグの更新自体をterraform管理に一本化している。
image = "...todo-api:${var.image_tag}"
つまりsrc配下の変更でDockerイメージの中身が変わった場合も、それを反映するにはgoogle_cloud_run_v2_service.todo_apiのimage属性をterraformで更新する必要があり、必ずplanに差分が出る(~ image = "...タグA" -> "...タグB")。srcの変更も.github/workflowsの変更も、どちらも「terraform/配下を含まないからplanは不要」という前提自体が成立していなかった。
直し方
pathsフィルタそのものを外す。
on:
push:
branches: [main]
pull_request:
branches: [main]
- paths:
- - "terraform/**"
これで、srcの変更でも.github/workflowsの変更でも、常にtest→planが走るようになる。terraform側に実質的な差分が無ければ0 to change(あるいはimageタグだけの差分)で済むだけなので、害はない。むしろ「どんな変更でも必ずplan/applyの整合性チェックを通す」という設計(2-3節)の前提とも筋が通る形になった。
まとめ
-
pg_dump --data-onlyは、serial/identity列のシーケンス値(setval)も一緒にダンプするため、復元後は明示的にシーケンスを実データの最大値に合わせ直す必要がある - Terraform/Actionsのバージョンは放置すると古くなる。たまに最新版を確認する習慣をつける
- Actionsをコミットハッシュでpin留めすると、タグの差し替えによる意図しないコード実行を防げる。ただしDependabotのpin対応書式を守っておくと更新の手間は減らせる
-
terraform plan -out=tfplanでplan結果をファイルに保存し、マージ時は保存されたファイルをそのままapplyする形にすることで、マージ順序の崩れによるplan/applyの不整合を「エラーで安全に止まる」形に変えられる -
applyがstaleで失敗した後の復旧は、PRがまだオープンならリベースでいいが、マージ後に失敗したケースではPRがクローズ済みなのでリベースが効かない。この場合は新しいPR(差分が空でもいい)で改めてplan→applyを回す必要がある。「マージ済み」と「実際のstateに反映済み」は別物 -
pull_requestトリガーにpathsフィルタを付けると、「terraformを含まない変更はplanが不要」という前提が成り立つかどうかを個別に検証しないと、ワークフロー自体の変更やイメージタグ更新を伴うアプリコードの変更が素通りして、後続のジョブが前提とするartifactが無いまま失敗する
読者からのフィードバックで気づけた改善点だった。公開してコメントをもらえるというのは、個人開発のブログを続けるモチベーションにもなる良い副作用だと思う。
