🐳

Apply Before MergeなTerraformのCI/CDをGitHub Actionsで作ってみた

に公開

目次

はじめに

Terraform を使ったインフラの CI/CD パイプラインを構築する際、さまざまな実装パターンがあると思います。
これまで Terraform の CI/CD を構築する際は、main ブランチにマージしてから terraform apply を自動実行する「Apply After Merge」なパターンで実装することが多かったのですが、今回は別のアプローチとして「Apply Before Merge」なパターンを試してみることにしました。

Apply Before Merge では、main ブランチへのマージ前に実際に terraform apply を実行し、適用が成功したことを確認してからマージを行います。本記事では、簡易的ですがこの Apply Before Merge パターンを使った Terraform の CI/CD パイプラインの実装について紹介します。

CI/CDの流れ

今回実装する CI/CD では、トランクベースでのブランチ戦略を取っているため以下のような開発フローを想定しています。

  1. main ブランチから feature ブランチを作成し、PR を作成
  2. PR が作成されると自動的に terraform plan が実行され、結果が PR にコメントされる
  3. 開発者が stg 環境に対して terraform apply を実行
  4. レビュアーが PR をレビューし approve
  5. 開発者が本番環境に対して terraform apply を実行
  6. prd 環境への apply が成功したら PR をマージ
    1. apply が失敗した場合は、修正して(再度レビュー依頼して)再度 apply を実行する(これを繰り返す)

マーメイドで書くとこんな感じ

Apply After Merge の場合は main ブランチへのマージで apply エラーになると修正 PR を作る必要がありますが、Apply Before Merge はterraform applyが通る状態のコードを main ブランチにマージする流れになります。

前提

今回実装したコードでは以下の環境を想定しています。

  • ブランチ戦略としてトランクベース(main ブランチ一本での運用)を採用
  • Terraform 構成として、環境別(stg/prd)に tfvars と backend を切り替えるパターン
  • 小規模な開発チームでの開発を想定

ディレクトリ構成

ディレクトリ構成はざっくり以下のようになっています。

├── .github
│   ├── actions
│   │   └── setup-aqua
│   └── workflows
│       ├── apply.yml   # terraform applyのワークフロー
│       ├── lint.yml    # actionlintやghalintのワークフロー
│       ├── pinact.yml  # pinactのワークフロー
│       └── plan.yml    # terraform planのワークフロー
├── .gitignore
├── README.md
├── aqua.yml   # aqua設定ファイル
├── terraform
│   └── backend-config-pattern
│       └── aws-sample
│          ├── .terraform.lock.hcl
│          ├── README.md
│          ├── backend.tf
│          ├── oidc.tf
│          ├── prd.tfbackend
│          ├── prd.tfvars
│          ├── providers.tf
│          ├── security-group.tf
│          ├── stg.tfbackend
│          ├── stg.tfvars
│          ├── terraform.sh
│          ├── variables.tf
│          └── vpc.tf
├── tfcmt.yml   # tfcmt設定ファイル
├── tflint.hcl  # tflint設定ファイル
└── trivy.yml   # trivy設定ファイル

CIの実装

まずは CI の実装です。
CI の実装では、テストの実行とterraform planの実行を行いますが、テストの実装については割愛し、terraform planの実装についてポイントを紹介します。

  • matrix を使って stg 環境と prd 環境に対して並列で plan を実行
  • tfcmtで plan 結果を PR にコメント
  • plan ファイルを Artifacts として保存
    • 環境名と PR 番号をファイル名に含め、apply のワークフローで利用します

tfcmtは Terraform の CI/CD では必須のツールなのでぜひ利用しましょう。
また、後続のワークフローで apply を行う際に、意図しない変更の適用を防ぐために plan ファイルを Artifacts に保存しています。

name: terraform-plan

on:
  pull_request:
    branches: [main]
    types: [synchronize, opened]
    paths:
      - 'terraform/**'
      - '.github/workflows/plan.yml'
      - '.github/workflows/apply.yml'

concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref}}
  cancel-in-progress: true

defaults:
  run:
    shell: bash
    working-directory: terraform/backend-config-pattern/aws-sample

jobs:
  terraform-plan:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read # required to check out the repository
      pull-requests: write # required to post PR comments
      actions: write # required for plan persistence
      id-token: write # required for workload-identity-federation
    strategy:
      fail-fast: false
      matrix:
        env: [stg, prd]
    environment: ${{ matrix.env }}
    steps:
      - name: Checkout
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false
      - name: Setup aqua
        uses: ./.github/actions/setup-aqua
        with:
          aqua_version: v2.45.0
          aqua_opts: -l
      - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
        with:
          role-to-assume: ${{ secrets.AWS_ASSUME_ROLE }}
          role-session-name: gha-${{ matrix.env }}
          aws-region: ap-northeast-1
      - name: terraform init
        run: terraform init -backend-config=${{ matrix.env }}.tfbackend
      - name: terraform fmt
        run: terraform fmt -check -diff -recursive
      - name: terraform validate
        run: terraform validate -no-color
      - name: tflint
        uses: reviewdog/action-tflint@f17a66a19220804dfa5ba4912e1a9fe7c530fe0a # v1.24.0
        continue-on-error: true
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          working_directory: terraform/backend-config-pattern/aws-sample
          reporter: github-pr-review
          fail_level: 'any'
          filter_mode: 'nofilter'
          tflint_init: true
          tflint_config: ${{ github.workspace }}/tflint.hcl
      - name: trivy
        continue-on-error: true
        uses: suzuki-shunsuke/trivy-config-action@6c7c845cbf76e5745c4d772719de7a34453ae81d
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          github_comment: 'true'
          config_path: ${{ github.workspace }}/trivy.yml
      - name: terraform plan
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: tfcmt -var "target:${{ matrix.env }}" --pr ${{ github.event.pull_request.number }}  plan -patch -- terraform plan -no-color -var-file=${{ matrix.env }}.tfvars -out=${{ matrix.env }}-${{ github.event.pull_request.number }}.tfplan
      - name: save plan file to artifacts
        uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
        with:
          name: ${{ matrix.env }}-${{ github.event.pull_request.number }}.tfplan
          path: terraform/backend-config-pattern/aws-sample/${{ matrix.env }}-${{ github.event.pull_request.number }}.tfplan
          overwrite: true

CDの実装

次に CD 部分の実装です。
CD ではterraform applyを実行します。
ポイントは以下となります。

  • apply 対象の環境ごとにラベル(stg-applyまたはprd-apply)を付与することで apply がトリガーされる
  • prd 環境への apply には、PR が承認(approve)されていることを条件としている
  • prd 環境への apply がエラーになった場合は、approved なレビューを stale にして再度 approve を必要とする
  • plan のワークフローで保存した plan ファイルを利用することで、意図しない変更が apply されるのを防止する

環境ごとに環境名-applyというラベルによって実行をトリガーする設計になっています。
prd 環境に自由に apply ができると困るため、PR が approve されている状態でないとラベルを貼ってもトリガーされないようにしています。
apply の処理の流れは基本的に同じですが、トリガー条件などが異なるため stg と prd で別のジョブとして実装しています。

name: terraform-apply

on:
  pull_request:
    branches: [main]
    types: [labeled]
    paths:
      - 'terraform/**'
      - '.github/workflows/plan.yml'
      - '.github/workflows/apply.yml'

concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref}}
  cancel-in-progress: true

defaults:
  run:
    shell: bash
    working-directory: terraform/backend-config-pattern/aws-sample

jobs:
  stg-terraform-apply:
    if: ${{ contains(github.event.pull_request.labels.*.name, 'stg-apply') }}
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: write # required to merge PRs
      pull-requests: write # required to post PR comments
      actions: read # required to download artifacts
      id-token: write # required for workload-identity-federation
      repository-projects: read # gh pr edit を実行するために必要
    environment: stg
    env:
      ENV: stg
    steps:
      - name: Checkout
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false
      - name: Setup aqua
        uses: ./.github/actions/setup-aqua
        with:
          aqua_version: v2.45.0
          aqua_opts: -l
      - name: aqua install
        run: aqua install
      - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
        with:
          role-to-assume: ${{ secrets.AWS_ASSUME_ROLE }}
          role-session-name: gha-${{ env.ENV }}
          aws-region: ap-northeast-1
      - name: terraform init
        run: terraform init -backend-config=${{ env.ENV }}.tfbackend
      - name: download plan file from artifact
        id: download-artifact
        uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295 # v9
        with:
          github_token: ${{secrets.GITHUB_TOKEN}}
          workflow: 'plan.yml'
          workflow_conclusion: success
          pr: ${{github.event.pull_request.number}}
          name: ${{ env.ENV }}-${{ github.event.pull_request.number }}.tfplan
          path: terraform/backend-config-pattern/aws-sample/
      - name: terraform apply
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: tfcmt -var "target:${{ env.ENV }}" --pr ${{ github.event.pull_request.number }} apply -- terraform apply --auto-approve -no-color -var-file=${{ env.ENV }}.tfvars ${{ env.ENV }}-${{ github.event.pull_request.number }}.tfplan
      - name: remove labels
        if: always()
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh pr edit ${{github.event.pull_request.number}} --remove-label ${{ env.ENV }}-apply || true

  check-pr-approved:
    if: ${{ contains(github.event.pull_request.labels.*.name, 'prd-apply') }}
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      contents: write # required to merge PRs
      pull-requests: write # required to post PR comments
      repository-projects: read # gh pr edit を実行するために必要
    env:
      ENV: prd
    outputs:
      is_pr_approved: ${{ steps.check-pr-approved.outputs.is_pr_approved }}
    steps:
      - name: Checkout
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false
      - name: check pr is approved
        id: check-pr-approved
        run: |
          set -ex
          is_pr_approved=$(gh api /repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews | jq -r '.[].state' | grep -q "APPROVED" && echo "true" || echo "false")
          echo "is_pr_approved=$is_pr_approved" >> "$GITHUB_OUTPUT"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: check result
        run: echo "${{ steps.check-pr-approved.outputs.is_pr_approved }}"
      - name: remove labels
        if: ${{ steps.check-pr-approved.outputs.is_pr_approved == 'false' }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh pr edit ${{github.event.pull_request.number}} --remove-label ${{ env.ENV }}-apply || true
  prd-terraform-apply:
    needs: check-pr-approved
    if: |
      contains(github.event.pull_request.labels.*.name, 'prd-apply') &&
      needs.check-pr-approved.outputs.is_pr_approved == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: write # required to merge PRs
      pull-requests: write # required to post PR comments
      actions: read # required to download artifacts
      id-token: write # required for workload-identity-federation
      repository-projects: read # gh pr edit を実行するために必要
    environment: prd
    env:
      ENV: prd
    steps:
      - name: Checkout
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false
      - name: Setup aqua
        uses: ./.github/actions/setup-aqua
        with:
          aqua_version: v2.45.0
          aqua_opts: -l
      - name: aqua install
        run: aqua install
      - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
        with:
          role-to-assume: ${{ secrets.AWS_ASSUME_ROLE }}
          role-session-name: gha-${{ env.ENV }}
          aws-region: ap-northeast-1
      - name: terraform init
        run: terraform init -backend-config=${{ env.ENV }}.tfbackend
      - name: download plan file from artifact
        id: download-artifact
        uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295 # v9
        with:
          github_token: ${{secrets.GITHUB_TOKEN}}
          workflow: 'plan.yml'
          workflow_conclusion: success
          pr: ${{github.event.pull_request.number}}
          name: ${{ env.ENV }}-${{ github.event.pull_request.number }}.tfplan
          path: terraform/backend-config-pattern/aws-sample/
      - name: terraform apply
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: tfcmt -var "target:${{ env.ENV }}" --pr ${{ github.event.pull_request.number }} apply -- terraform apply --auto-approve -no-color -var-file=${{ env.ENV }}.tfvars ${{ env.ENV }}-${{ github.event.pull_request.number }}.tfplan
      - name: remove labels
        if: always()
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh pr edit ${{github.event.pull_request.number}} --remove-label ${{ env.ENV }}-apply || true\
      - name: dismiss reviews
        if: failure()
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh api /repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews | jq -r '.[] | select(.state == "APPROVED") | .id' | xargs -I {} gh api -X PUT /repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews/{}/dismissals -f "message=This PR's review has been dismissed because of apply error."

おわりに

本記事では、Terraform の CI/CD パイプラインに「Apply Before Merge」パターンの実装を紹介しました。
このパターンでは、PR マージ前に terraform apply を実行する流れとなるため開発規模や体制によってはよりスピーディーに開発できると思います。
ただし、Apply After Merge のパターンと比べるとワークフローが複雑になってしまうため、まずはシンプルに Apply After Merge のパターンを導入するほうが良いと感じました。
何か質問や改善点があれば、コメントでお知らせください。

GitHubで編集を提案

Discussion