🚀

Github Actionsで複数環境のterraform planを自動化する

2024/07/07に公開2

はじめに

現場で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段階のジョブに分かれます。

  1. mainブランチとの変更検出
  2. 検出した環境に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.pathssrc/**以下のみを指定しているので、ワークフロー自体トリガーされません。

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)を展開すると詳細な差分が見れます。
  • destroyadd-or-updateのようなラベルも自動付与してくれるのも地味に良いです。

まとめ

今回組んだCIでは主に以下がうまくできました。

  • 変更検知と適切なplan対象の絞り込み:stgまたはprodディレクトリの変更時はそれぞれの環境のみ、common(共通モジュール)の変更時は全環境にplan実行。
  • 環境別でSecretの動的参照

そして、運用面では以下のメリットがあると感じています。

  • CI実行時間の節約。
  • tfcmtがすごく良い感じにコメントをプルリクにコメントしてくれるので、プルリク作成者とレビュワーの負担減。

また、GithubでのCI/CDを検討するにあたって、こちらの書籍『GitHub CI/CD実践ガイド』がとても参考になったので激推しさせていただきます。

https://gihyo.jp/book/2024/978-4-297-14173-8

今後のTODO

  • GCPとの権限連携にサービスアカウントキーを使っているので、よりセキュアなWorkload Identity連携に移行する。
  • Actionsでset-outputが非推奨なので、Environment Filesを利用する方法にする。

参考

https://times.hrbrain.co.jp/entry/terraform-plan-github-actions

https://suzuki-shunsuke.github.io/tfcmt/

Discussion