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 では、トランクベースでのブランチ戦略を取っているため以下のような開発フローを想定しています。
- main ブランチから feature ブランチを作成し、PR を作成
- PR が作成されると自動的に terraform plan が実行され、結果が PR にコメントされる
- 開発者が stg 環境に対して terraform apply を実行
- レビュアーが PR をレビューし approve
- 開発者が本番環境に対して terraform apply を実行
- prd 環境への apply が成功したら PR をマージ
- 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 されるのを防止する
- 別ワークフローで保存したアーティファクトを利用するためにdawidd6/action-download-artifactを使っています
環境ごとに環境名-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 のパターンを導入するほうが良いと感じました。
何か質問や改善点があれば、コメントでお知らせください。
Discussion