Terraform plan は通るのにapplyで失敗...ブランチデプロイ戦略で解決した運用改善事例

こんにちは!SRE一年生が終わりかけている墨(YOUTRUST/X)です!

terraform planは成功したのに、いざterraform applyしたらエラーで失敗...
こんな経験に悩まされているインフラエンジニアの方は多いのではないでしょうか?

今回はその打開策として弊社で行なった取り組みをご紹介します。

修正前の状態

現状把握のため、弊社のインフラ構成を整理します。
弊社では過去記事でも記載しましたが、インフラをTerraformにて管理しています。

  • 環境
    • ほぼ同じインフラ構成のsandboxとproductionが存在しています。
  • リポジトリ
    • Terraformリソースをモジュール化し、環境変数を注入することでリポジトリ1つで2つの環境を管理運用しています。
  • ブランチ
    • sandbox・main・feature/XXXが存在しており、mainブランチはproduction、featureブランチが作業ブランチとなります。
  • デプロイ
    • デプロイはGitHub Actionsを使用し、sandboxデプロイ後に検証後、問題なければproduction環境に対応するmainにマージすることで自動で反映されるようになっています。
  • デプロイフロー
    1. ローカルで作業ブランチを切り修正
    2. sandboxブランチに向けてプッシュ
    3. PR作成
    4. レビュー
    5. sandboxへマージ&デプロイ
    6. mainブランチへ向けたPRが自動作成
    7. レビュー
    8. production(mainブランチ)へマージ&デプロイ

tech.youtrust.co.jp tech.youtrust.co.jp

課題

一見問題なさそうですが、マージ後にデプロイ(terraform apply)されることでマージ後に失敗した場合問題のあるコードがマージされ、PR作成からやり直す必要があります。

実際に遭遇したエラーは下記のようなものがあり、plan段階ではエラーを検知できませんでした。

  • RDSストレージタイプ変更時:gp2からgp3への変更で、planでは問題なく表示されるが、applyで互換性エラーが発生
  • セキュリティグループ依存関係:削除順序の問題によりapplyが失敗
  • IAMポリシー整合性:記述は正しいが、実際のリソースとの整合性でapply時にエラーが発生

デプロイ戦略を変更

考えられる解決策は2点あります。
1. テストコードを書く
2. デプロイフローを変更

今回悩まされた問題の多くは、Terraformの記述方法は正しいものの、AWSリソースの実際の制約や依存関係によってエラーとなる場合でした。
テストコードは品質保証を行えるが、継続的なメンテナンスコストが発生し、全てのエッジケースを考慮した網羅性のあるテストコードを書くことは現状工数的にも難しいと判断し、2. を選択しました。

そこでブランチデプロイ戦略を導入しました。
ブランチデプロイ戦略とは、従来の「マージ後デプロイ」から「デプロイ後マージ」に変更する手法です。つまり、PRをマージする前に実際の環境でデプロイを実行し、成功を確認してからマージを行います。
弊社環境に落とし込むと下記のようなフローになります。

  1. ローカルで作業ブランチを切り修正
  2. sandboxブランチに向けてプッシュ
  3. PR作成
  4. レビュー
  5. sandbox環境へデプロイ
  6. sandboxブランチへマージ
  7. mainブランチへ向けたPRが自動作成
  8. レビュー
  9. productionへデプロイ
  10. mainブランチへマージ

このようにデプロイ後にマージすることで常に正しく、検証されたコードがブランチへ反映されます。
また、apply時にエラーが出た場合も最小の手戻りで修正・検証を行えます。

デプロイ方法の実装についてはbranch deploy actionを使用し、PRコメントからコマンド入力でterraform planterraform applyを実行できるようにしました。
セキュリティ対策として、production環境のみterraform applyは管理者権限を持つユーザーのみ実行可能としました。

実装した内容は下記となりますので参考にしてください。
※ assume role名、GitHubチーム名、リポジトリ名はマスクしてあります。

sandbox向けGitHub Action Workflow

name: Command Plan and Apply for Sandbox Terraform

on:
  issue_comment:
    types: [created]

env:
  SANDBOX_DIR: environments/sandbox
  PROD_DIR: environments/prod

permissions:
  id-token: write
  contents: write
  pull-requests: write
  deployments: write
  statuses: read
  checks: read
  issues: write

jobs:
  # Branch Deployの検証ジョブ
  branch_deploy_check:
    name: branch-deploy-check
    runs-on: ubuntu-latest
    if: ${{ github.event.issue.pull_request && (contains(github.event.comment.body, '.apply') || contains(github.event.comment.body, '.plan'))}}
    outputs:
      continue: ${{ steps.branch-deploy.outputs.continue }}
      noop: ${{ steps.branch-deploy.outputs.noop }}
      sha: ${{ steps.branch-deploy.outputs.sha }}
      pr_number: ${{ steps.get_pr_target_branch.outputs.pr_number }}
    steps:
      # ターゲットブランチを取得
      - name: Get PR details
        id: get_pr_target_branch
        run: |
          PR_NUMBER=${{ github.event.issue.number }}
          PR_INFO=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
            "https://api.github.com/repos/${{ github.repository }}/pulls/${PR_NUMBER}")
          BASE_BRANCH=$(echo "$PR_INFO" | jq -r .base.ref)
          echo "BASE_BRANCH: ${BASE_BRANCH}"
          echo "base_branch=$BASE_BRANCH" >> $GITHUB_OUTPUT
          echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT

      # ターゲットブランチがsandboxであるか確認
      - name: Check base branch sandbox
        if: steps.get_pr_target_branch.outputs.base_branch != 'sandbox'
        run: |
          echo "Target branch: ${{ steps.get_pr_target_branch.outputs.base_branch }}"
          echo "❌ ターゲットブランチがsandboxではないためこのワークフローを停止しました。"
          exit 1

      # Branch Deploy Actionの設定
      - name: branch-deploy
        id: branch-deploy
        uses: github/branch-deploy@v10.4.2
        with:
          trigger: '.apply'
          noop_trigger: '.plan'
          stable_branch: sandbox
          allow_non_default_target_branch_deployments: true

  # Sandbox環境のPlan実行ジョブ
  plan_sandbox:
    name: plan-sandbox
    runs-on: ubuntu-latest
    needs: branch_deploy_check
    if: ${{ needs.branch_deploy_check.outputs.continue == 'true' && needs.branch_deploy_check.outputs.noop == 'true' }}
    steps:
      # ブランチをチェックアウト
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ needs.branch_deploy_check.outputs.sha }}

      # AWS認証情報の設定(Sandbox)
      - name: Configure aws credentials for sandbox
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::XXXXXXXXXX:role/[your-assume-role]
          aws-region: ap-northeast-1

      # Terraformのセットアップ
      - name: Setup terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.11.1

      # tfcmtのセットアップ
      - name: Setup tfcmt
        uses: shmokmt/actions-setup-tfcmt@v2

      # github-commentのセットアップ
      - name: Setup github-comment
        uses: shmokmt/actions-setup-github-comment@v2

      # Sandbox環境のTerraform初期化
      - name: Initialize Sandbox
        run: terraform init -upgrade
        working-directory: ${{ env.SANDBOX_DIR }}

      # 古いコメントを非表示にする
      - name: Hide old comments
        run: |
          github-comment hide -org [your-team] -repo [your-repo] -pr ${{ needs.branch_deploy_check.outputs.pr_number }} -token ${GITHUB_TOKEN} \
          -condition 'Comment.HasMeta && (Comment.Meta.SHA1 != Commit.SHA1)'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      # Sandbox環境のTerraform planを実行
      - name: Terraform plan for sandbox
        id: plan_sandbox
        run: |
          tfcmt -log-level DEBUG -owner [your-team]-repo [your-repo] -pr ${{ needs.branch_deploy_check.outputs.pr_number }} -var target:sandbox plan -- terraform plan -no-color
        continue-on-error: true
        working-directory: ${{ env.SANDBOX_DIR }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      # Terraform planのエラーチェック(Sandbox)
      - name: Check Terraform plan output for sandbox
        if: steps.plan_sandbox.outcome == 'failure'
        run: |
          tfcmt -log-level DEBUG -owner [your-team] -repo [your-repo] -pr ${{ needs.branch_deploy_check.outputs.pr_number }} -var target:sandbox error -- "Terraform plan for sandbox failed"
          exit 1
        working-directory: ${{ env.SANDBOX_DIR }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  # Prod環境のPlan実行ジョブ
  plan_prod:
    name: plan-prod
    runs-on: ubuntu-latest
    needs: branch_deploy_check
    if: ${{ needs.branch_deploy_check.outputs.continue == 'true' && needs.branch_deploy_check.outputs.noop == 'true' }}
    steps:
      # ブランチをチェックアウト
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ needs.branch_deploy_check.outputs.sha }}

      # AWS認証情報の設定(Prod)
      - name: Configure aws credentials for prod
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::XXXXXXXXXX:role/[your-assume-role]
          aws-region: ap-northeast-1

      # Terraformのセットアップ
      - name: Setup terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.11.1

      # tfcmtのセットアップ
      - name: Setup tfcmt
        uses: shmokmt/actions-setup-tfcmt@v2

      # github-commentのセットアップ
      - name: Setup github-comment
        uses: shmokmt/actions-setup-github-comment@v2

      # Prod環境のTerraform初期化
      - name: Initialize Prod
        run: terraform init -upgrade
        working-directory: ${{ env.PROD_DIR }}

      # Prod環境のTerraform planを実行
      - name: Terraform plan for prod
        id: plan_prod
        run: |
          tfcmt -log-level DEBUG -owner [your-team] -repo [your-repo] -pr ${{ needs.branch_deploy_check.outputs.pr_number }} -var target:production plan -- terraform plan -no-color
        continue-on-error: true
        working-directory: ${{ env.PROD_DIR }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      # Terraform planのエラーチェック(Prod)
      - name: Check Terraform plan output for prod
        if: steps.plan_prod.outcome == 'failure'
        run: |
          tfcmt -log-level DEBUG -owner [your-team] -repo [your-repo] -pr ${{ needs.branch_deploy_check.outputs.pr_number }} -var target:production error -- "Terraform plan for production failed"
          exit 1
        working-directory: ${{ env.PROD_DIR }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  # Sandbox環境へのApply実行ジョブ
  apply_sandbox:
    name: apply-sandbox
    runs-on: ubuntu-latest
    needs: branch_deploy_check
    if: ${{ needs.branch_deploy_check.outputs.continue == 'true' && needs.branch_deploy_check.outputs.noop != 'true' }}
    steps:
      # ブランチをチェックアウト
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ needs.branch_deploy_check.outputs.sha }}

      # AWS認証情報の設定(Sandbox)
      - name: Configure aws credentials for sandbox
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::XXXXXXXXXX:role/[your-assume-role]
          aws-region: ap-northeast-1

      # Terraformのセットアップ
      - name: Setup terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.11.1

      # tfcmtのセットアップ
      - name: Setup tfcmt
        uses: shmokmt/actions-setup-tfcmt@v2

      # github-commentのセットアップ
      - name: Setup github-comment
        uses: shmokmt/actions-setup-github-comment@v2

      # Sandbox環境のTerraform初期化
      - name: Initialize Sandbox
        run: terraform init -upgrade
        working-directory: ${{ env.SANDBOX_DIR }}

      # 古いコメントを非表示にする
      - name: Hide old comments
        run: |
          github-comment hide -org [your-team] -repo [your-repo] -pr ${{ needs.branch_deploy_check.outputs.pr_number }} -token ${GITHUB_TOKEN} \
          -condition 'Comment.HasMeta && (Comment.Meta.SHA1 != Commit.SHA1)'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      # Terraform applyを実行(Sandboxのみ)
      - name: Terraform apply for sandbox
        id: apply
        run: |
          tfcmt -log-level DEBUG -owner [your-team]-repo [your-repo] -pr ${{ needs.branch_deploy_check.outputs.pr_number }} -var target:sandbox apply -- terraform apply -auto-approve -no-color
        continue-on-error: true
        working-directory: ${{ env.SANDBOX_DIR }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      # Terraform applyのエラーチェック
      - name: Check Terraform apply output
        if: steps.apply.outcome == 'failure'
        run: |
          tfcmt -log-level DEBUG -owner [your-team] -repo [your-repo] -pr ${{ needs.branch_deploy_check.outputs.pr_number }} -var target:sandbox error -- "Terraform apply failed"
          exit 1
        working-directory: ${{ env.SANDBOX_DIR }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

production向けGitHub Action Workflow

name: Command Plan and Apply for Production Terraform

on:
  issue_comment:
    types: [created]

env:
  PROD_DIR: environments/prod

permissions:
  id-token: write
  contents: write
  pull-requests: write
  deployments: write
  statuses: read
  checks: read
  issues: write

jobs:
  # Branch Deployの検証ジョブ
  branch_deploy_check:
    name: branch-deploy-check
    runs-on: ubuntu-latest
    if: ${{ github.event.issue.pull_request && (contains(github.event.comment.body, '.apply') || contains(github.event.comment.body, '.plan')) }}
    outputs:
      continue: ${{ steps.branch-deploy.outputs.continue }}
      noop: ${{ steps.branch-deploy.outputs.noop }}
      sha: ${{ steps.branch-deploy.outputs.sha }}
      pr_number: ${{ steps.get_pr_target_branch.outputs.pr_number }}
      is_apply: ${{ steps.check-command.outputs.is_apply }}
      has_admin_permission: ${{ steps.check-admin-permission.outputs.has_admin_permission }}
    steps:
      # ターゲットブランチの確認
      - name: Get PR details
        id: get_pr_target_branch
        run: |
          PR_NUMBER=${{ github.event.issue.number }}
          PR_INFO=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
            "https://api.github.com/repos/${{ github.repository }}/pulls/${PR_NUMBER}")
          BASE_BRANCH=$(echo "$PR_INFO" | jq -r .base.ref)
          echo "BASE_BRANCH: ${BASE_BRANCH}"
          echo "base_branch=$BASE_BRANCH" >> $GITHUB_OUTPUT
          echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT

      # ターゲットブランチがmainであるか確認
      - name: Check base branch main
        if: steps.get_pr_target_branch.outputs.base_branch != 'main'
        run: |
          echo "❌ ターゲットブランチがmainではないためこのワークフローを停止しました。"
          exit 1

      # コマンドタイプの確認
      - name: Check command type
        id: check-command
        run: |
          if [[ "${{ github.event.comment.body }}" == *".apply"* ]]; then
            echo "is_apply=true" >> $GITHUB_OUTPUT
          else
            echo "is_apply=false" >> $GITHUB_OUTPUT
          fi

      # Branch Deploy Actionの設定
      - name: branch-deploy
        id: branch-deploy
        uses: github/branch-deploy@v10.4.2
        with:
          trigger: '.apply'
          noop_trigger: '.plan'
          stable_branch: main
          allow_non_default_target_branch_deployments: true

      # 管理者権限チェック(applyコマンドの場合のみ)
      - name: Check admin permission
        id: check-admin-permission
        if: steps.branch-deploy.outputs.continue == 'true' && steps.check-command.outputs.is_apply == 'true'
        run: |
          # コマンド実行者の情報を取得
          COMMENTER="${{ github.event.comment.user.login }}"
          
          # リポジトリのコラボレーター情報を取得して権限をチェック
          COLLAB_INFO=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
            "https://api.github.com/repos/[your-team]/[your-repo]/collaborators/$COMMENTER/permission")
          
          # 権限レベルを取得
          PERMISSION=$(echo "$COLLAB_INFO" | jq -r '.permission')
          
          # 管理者権限を持っているかチェック
          if [[ "$PERMISSION" == "admin" ]]; then
            echo "✅ 管理者権限を持つユーザーです!コマンド実行を許可します。"
            echo "has_admin_permission=true" >> $GITHUB_OUTPUT
            exit 0
          else
            echo "❌ 管理者権限を持つユーザーのみがapplyコマンドを実行できます!"
            echo "現在の権限: $PERMISSION"
            echo "has_admin_permission=false" >> $GITHUB_OUTPUT
            exit 1
          fi

  # Production環境のPlan実行ジョブ
  plan_prod:
    name: plan-prod
    runs-on: ubuntu-latest
    needs: branch_deploy_check
    if: ${{ needs.branch_deploy_check.outputs.continue == 'true' && needs.branch_deploy_check.outputs.noop == 'true' && needs.branch_deploy_check.outputs.is_apply == 'false' }}
    steps:
      # ブランチをチェックアウト
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ needs.branch_deploy_check.outputs.sha }}

      # AWS認証情報の設定(Prod)
      - name: Configure aws credentials for prod
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::XXXXXXXXXX:role/[your-assume-role]
          aws-region: ap-northeast-1

      # Terraformのセットアップ
      - name: Setup terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.11.1

      # tfcmtのセットアップ
      - name: Setup tfcmt
        uses: shmokmt/actions-setup-tfcmt@v2

      # github-commentのセットアップ
      - name: Setup github-comment
        uses: shmokmt/actions-setup-github-comment@v2

      # Prod環境のTerraform初期化
      - name: Initialize Prod
        run: terraform init -upgrade
        working-directory: ${{ env.PROD_DIR }}

      # 古いコメントを非表示にする
      - name: Hide old comments
        run: |
          github-comment hide -org [your-team] -repo [your-repo] -pr ${{ needs.branch_deploy_check.outputs.pr_number }} -token ${GITHUB_TOKEN} \
          -condition 'Comment.HasMeta && (Comment.Meta.SHA1 != Commit.SHA1)'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      # Prod環境のTerraform planを実行
      - name: Terraform plan for prod
        id: plan_prod
        run: |
          tfcmt -log-level DEBUG -owner [your-team] -repo [your-repo] -pr ${{ needs.branch_deploy_check.outputs.pr_number }} -var target:production plan -- terraform plan -no-color
        working-directory: ${{ env.PROD_DIR }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  # Production環境へのApply実行ジョブ
  apply_prod:
    name: apply-prod
    runs-on: ubuntu-latest
    needs: branch_deploy_check
    if: ${{ needs.branch_deploy_check.outputs.continue == 'true' && needs.branch_deploy_check.outputs.noop != 'true'&& needs.branch_deploy_check.outputs.has_admin_permission == 'true' && needs.branch_deploy_check.outputs.is_apply == 'true' }}
    steps:
      # ブランチをチェックアウト
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ needs.branch_deploy_check.outputs.sha }}

      # AWS認証情報の設定(Prod)
      - name: Configure aws credentials for prod
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::XXXXXXXXXX:role/[your-assume-role]
          aws-region: ap-northeast-1

      # Terraformのセットアップ
      - name: Setup terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.11.1

      # tfcmtのセットアップ
      - name: Setup tfcmt
        uses: shmokmt/actions-setup-tfcmt@v2

      # github-commentのセットアップ
      - name: Setup github-comment
        uses: shmokmt/actions-setup-github-comment@v2

      # Prod環境のTerraform初期化
      - name: Initialize Prod
        run: terraform init -upgrade
        working-directory: ${{ env.PROD_DIR }}

      # 古いコメントを非表示にする
      - name: Hide old comments
        run: |
          github-comment hide -org [your-team] -repo [your-repo] -pr ${{ needs.branch_deploy_check.outputs.pr_number }} -token ${GITHUB_TOKEN} \
          -condition 'Comment.HasMeta && (Comment.Meta.SHA1 != Commit.SHA1)'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      # Terraform applyを実行(Prod)
      - name: Terraform apply for prod
        id: apply
        run: |
          tfcmt -log-level DEBUG -owner [your-team] -repo [your-repo] -pr ${{ needs.branch_deploy_check.outputs.pr_number }} -var target:production apply -- terraform apply -auto-approve -no-color
        working-directory: ${{ env.PROD_DIR }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 

先人のブログを参考にさせていただいたので載せておきます。 kakakakakku.hatenablog.com

まとめ

今回はデプロイ戦略を変更することでマージ後にエラーとなる課題を解消しました。
導入後の効果としては下記が挙げられます。

  • 手戻り工数の削減:マージ後のデプロイ失敗によるPR作成からのやり直しを根絶
  • 検証品質の向上:sandbox環境でのマージ前検証により、問題の早期発見が可能
  • デプロイ安定性の向上:検証済みコードのみがブランチにマージされる仕組みを確立

また、現在Claude Codeでの網羅的なレビュー体制も整えている段階です。
弊社では積極的に新しいことを試していける環境が整っています。
もし挑戦したいという方がいれば最高の環境なので下記を覗いてみてください! youtrust.jp

career.youtrust.co.jp