Github Actionsで複数環境のterraform planを自動化する
はじめに
現場でterraformソースコードのGitlab → GitHub移行にあたって、Github Actionsワークフローを組む機会がありました。
そこで、運用しやすそうなCIを組めたので、共有させていただきます。
前提/方針
- GoogleCloud(以下GCP)インフラをterraform管理している。
- 環境はstg・prodの2つで、別々のGCPプロジェクトに構築されている。
- プルリク作成時・更新時にterraform planを実行したい。
- plan結果は
tfcmt
というツールを利用し、プルリク上に自動でコメントしてもらう。 - 各環境用で別々のGithub Secretsを登録している。
また、ディレクトリ構成は以下のような感じ。
src/common
はstg・prodの共通リソースです。
src/
├ common/
│ ├ moduleA/
│ ├ moduleB/
│ └ ...
├ stg/
│ ├ main.tf
│ └ ...
├ prod/
│ ├ main.tf
└ ...
Actionsの実行時間節約のためにも、以下を実現したかったです。
- src/stgディレクトリに変更があったらstgにplan実行
- src/prodディレクトリに変更があったらprodにplan実行
- src/commonに変更があったらstg・prod両方にplan実行
ワークフロー全体
はじめに、ワークフロー全体は以下の通りです。大きく2段階のジョブに分かれます。
- mainブランチとの変更検出
- 検出した環境にterraform plan(並列実行)
.github/workflows/terraform-plan.yaml
name: terraform-plan
on:
pull_request:
branches:
- main
paths:
- "src/**"
types:
- opened
- reopened
- synchronize
jobs:
# mainブランチと差分があるディレクトリを検出
# common ディレクトリは prod/stg 共通なので、両方を実行対象にする
check_changed_dirs:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: set-matrix
id: set-matrix
run: |
CHANGES=$(git diff --name-only origin/main | grep 'src/' | awk -F'/' '{print $2}' | uniq)
INCLUDE_PROD=false
INCLUDE_STG=false
if echo "$CHANGES" | grep -q 'prod'; then
INCLUDE_PROD=true
fi
if echo "$CHANGES" | grep -q 'stg'; then
INCLUDE_STG=true
fi
if echo "$CHANGES" | grep -q 'common'; then
INCLUDE_PROD=true
INCLUDE_STG=true
fi
MATRIX="{\"include\": ["
if [ "$INCLUDE_PROD" = true ]; then
MATRIX+='{"dir":"src/prod", "credentials_key": "GCP_CREDENTIALS_PROD", "project_id_key": "GCP_PROJECT_ID_PROD"},'
fi
if [ "$INCLUDE_STG" = true ]; then
MATRIX+='{"dir":"src/stg", "credentials_key": "GCP_CREDENTIALS_STG", "project_id_key": "GCP_PROJECT_ID_STG"},'
fi
MATRIX+="]}"
echo "::set-output name=matrix::$MATRIX"
tf-plan:
runs-on: ubuntu-latest
needs: check_changed_dirs
timeout-minutes: 10
strategy:
fail-fast: false # あるジョブが失敗しても並行ジョブはキャンセルさせない
matrix: ${{ fromJSON(needs.check_changed_dirs.outputs.matrix) }}
defaults:
run:
shell: bash
working-directory: ${{ matrix.dir }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Environment
run: echo "Running Terraform scripts in ${{ matrix.dir }}"
- name: auth
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets[matrix.credentials_key] }}
- name: Set up Google Cloud SDK
uses: google-github-actions/setup-gcloud@v2
with:
version: "477.0.0"
project_id: ${{ secrets[matrix.project_id_key] }}
- name: install tfcmt
run: |
sudo curl -fL -o tfcmt.tar.gz https://github.com/suzuki-shunsuke/tfcmt/releases/download/$TFCMT_VERSION/tfcmt_linux_amd64.tar.gz
sudo tar -C /usr/bin -xzf ./tfcmt.tar.gz
env:
TFCMT_VERSION: v4.0.0
- name: setup terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.0
- name: terraform fmt
run: terraform fmt
continue-on-error: true
- name: terraform init
run: terraform init
- name: tfcmt plan
run: tfcmt -var "target:$(echo ${{ matrix.dir }} | sed 's/src\///g')" plan -patch -- terraform plan -no-color -lock=false
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
それぞれ詳しく
1. check_changed_dirs(変更検知ジョブ)
変更差分のあるディレクトリを検出し、後続のtf-planに値を渡します。
主に以下の点を考慮しました。
-
src
以下でmainブランチと差分があるディレクトリを検出し、後続のmatrix戦略(並列実行)のために値を渡す。 - common, prod, stgディレクトリで分岐が発生するので、条件
INCLUDE_STG
,INCLUDE_PROD
フラグにより、どの環境にplanするか決定。 - 参照するGithub Secret名がstg・prodで異なるので、シークレットキー(値ではない)として
credentials_key
,project_id_key
で動的に渡す。(詳細後述)
outputについて少し詳しく解説すると、
条件フラグがINCLUDE_PROD=true
, INCLUDE_STG=true
だった場合、以下のようなoutputになります。
{
"include": [
{
"dir": "src/prod",
"credentials_key": "GCP_CREDENTIALS_PROD",
"project_id_key": "GCP_PROJECT_ID_PROD"
},
{
"dir": "src/stg",
"credentials_key": "GCP_CREDENTIALS_STG",
"project_id_key": "GCP_PROJECT_ID_STG"
}
]
}
後続tf-planステップのfromJSON
により、これがmatrixブロックに展開され、以下のように変換されます。
matrix:
include:
- dir: "src/prod"
credentials_key: "GCP_CREDENTIALS_PROD"
project_id_key: "GCP_PROJECT_ID_PROD"
- dir: "src/stg"
credentials_key: "GCP_CREDENTIALS_STG"
project_id_key: "GCP_PROJECT_ID_STG"
今回マトリックス変数は指定していないので、includeエントリの分だけ、つまり2つのジョブが並列実行されることになります。
ちなみにincludeについてはこちらのドキュメントの例が分かりやすいです。
matrixは複数の変数の組み合わせでジョブを並列実行する機能なので、メインの使い方ではない気がしますが、今回はincludeだけで要件を満たせたので採用しました。
2. tf-planジョブ
デフォルト設定
defaults:
run:
shell: bash
working-directory: ${{ matrix.dir }}
このブロックで、ジョブに用いるデフォルトのシェルや作業ディレクトリを指定します。
terraform planの実行ディレクトリを指定する必要があります。今回はsrc/stg
, src/prod
の2パターンなので、前ステップから受け取ったmatrix.dir
を利用します。
matrixの挙動
変更検知の説明で既に書きましたが、matrix戦略によりincludeエントリの数だけジョブが実行されます。今回は最大2つのジョブ(stg・prod)が並列で走ります。
↓こんな感じ
- どちらかの変更であれば、1つのジョブだけが走ります。
- いずれも変更がない場合(ex: README変更時など)は、ワークフロー冒頭の
on.paths
でsrc/**
以下のみを指定しているので、ワークフロー自体トリガーされません。
Secretの動的参照
Github Secretは環境ごとに参照するキーが異なります。そのためここはちょっと工夫しました。
ここでも変更検知ジョブのoutputを利用します。
本来シークレット参照には${{ secrets.キー名 }}
という指定をしますが、今回はmatrix配列のオブジェクトを指定して、以下の書き方をすることで環境ごとで動的参照できるようにしました。
${{ secrets[matrix.credentials_key] }}
# 展開すると
# stgの場合
${{ secrets.GCP_CREDENTIALS_STG }}
# prodの場合
${{ secrets.GCP_CREDENTIALS_PROD }}
なお、GCPとの認証やセットアップについて詳しくは省略しますが、こちらの記事を参考にさせていただきました。
tfcmt
- name: tfcmt plan
run: tfcmt -var "target:$(echo ${{ matrix.dir }} | sed 's/src\///g')" plan -patch -- terraform plan -no-color -lock=false
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
詳しい使い方は、tfcmtのドキュメントをご覧ください。
- 今回は
-patch
オプションを指定することで、プルリクが更新されてplanが再実行された時に、tfcmtが既存コメントを更新するようにしました。(デフォルトでは既存コメントがHideされて新しいコメントが作られます)
↓結果は以下のようにプルリクに自動コメントされます。
-
Change Result (Click me)
を展開すると詳細な差分が見れます。 -
destroy
やadd-or-update
のようなラベルも自動付与してくれるのも地味に良いです。
まとめ
今回組んだCIでは主に以下がうまくできました。
- 変更検知と適切なplan対象の絞り込み:stgまたはprodディレクトリの変更時はそれぞれの環境のみ、common(共通モジュール)の変更時は全環境にplan実行。
- 環境別でSecretの動的参照
そして、運用面では以下のメリットがあると感じています。
- CI実行時間の節約。
- tfcmtがすごく良い感じにコメントをプルリクにコメントしてくれるので、プルリク作成者とレビュワーの負担減。
また、GithubでのCI/CDを検討するにあたって、こちらの書籍『GitHub CI/CD実践ガイド』がとても参考になったので激推しさせていただきます。
今後のTODO
- GCPとの権限連携にサービスアカウントキーを使っているので、よりセキュアなWorkload Identity連携に移行する。
- Actionsで
set-output
が非推奨なので、Environment Filesを利用する方法にする。
参考
Discussion