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?

読者からの指摘で2つ直した話、ついでに自分でも1つ見つけた話

0
Posted at

前回・前々回の記事に、読者の方から技術的に鋭いコメントを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から変化していたら失敗する。 これが本来欲しかった安全装置

動作確認: マージ順序を意図的に壊してみる

スクリーンショット 2026-10-04 000402.png

実際に壊れるケースを再現して確認する。

  1. 2つのPR(A, B)を同時に作り、両方ともterraform/配下の別々のリソースを変更する
  2. PR Aを先にマージしてapplyを完走させる
  3. PR Bのplanは、PR Aがマージされる前のstateに対して作られたものなので、PR Bをこの後マージすると、保存されたplanファイルとstateの間にズレが生じる
  4. 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が無いまま失敗する

読者からのフィードバックで気づけた改善点だった。公開してコメントをもらえるというのは、個人開発のブログを続けるモチベーションにもなる良い副作用だと思う。

0
0
3

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?