🤖

Terraform/Terragrunt の自動 plan・apply ができるまで ① 〜plan 編〜

2024/12/09に公開

本記事は SimpleForm Advent Calendar 2024 の 8 日目の記事です。

はじめに

こんにちは、シンプルフォームでインフラエンジニアをやっている入江 純 (@jirtosterone) です。

シンプルフォームでは、Terraform/Terragrunt を使ってインフラを IaC 化しています。Terragrunt って何ぞ?と言う方は弊社山岸さん (@yamagishihrd) のブログをご参照ください。

https://zenn.dev/simpleform/articles/20221111-01-terraform-with-terragrunt

IaC 化はできているものの、リソースのデプロイは個人任せになっていました。その状況を改善すべく、自動 apply を実現する物語の第一章です。

自動 apply の前に、まずは自動 plan で差分表示を実現しました。本記事では、その実現までの道のりと苦難をご紹介します。

なお、これら CI/CD は GitHub, GitHub Actions が前提となっています。その他のコードベースは必要に応じて読み替えてください。

対象読者

  • Terraform/Terragrunt でインフラ管理されている方
  • Terragrunt の CI/CD を運用・検討されている方

結論

時間が無い方のために結論から。

  • PR 作成時に修正対象のみ自動 plan を行うようにした。
  • Terraform ファイルのみが更新された時に対応する terragrunt.hcl が分かるように、あらかじめマッピングデータを保存しておくようにした。
  • 依存先のリソースが apply されていない時にエラーが発生するのは仕方ないので、そのエラーは許容するようにした。
  • ただし、定義に誤りが無いにも関わらずエラーが発生するのは望ましくないため、必ず正常終了するようにした。
  • 正常終了はするものの、 plan でエラーが発生していたら分かるようにした。

実現方針

シンプルフォームでは、アプリケーションは GitHub Flow により運用されています。そのため、インフラもそれに従おうと以下を実現しようと考えました。

  1. PR 作成時に自動で plan を行い、開発者が差分を確認できるようにする
  2. main ブランチマージ時に staging 環境に自動 apply してデプロイを行う
  3. リリース時に production 環境に自動 apply してデプロイを行う

第一段階として、自動 plan を GitHub Actions にて実装します。

課題と対策

自動 plan を実装しようとした時、2 つの大きな壁にぶつかりました。その壁 (課題) と対策について示します。

Terraform ファイルのみが修正された時にどの terragrunt.hclplan すれば良いかが分からない

何が問題なのか?

Terragrunt にて IaC を実現している方のほとんどが同じだと思いますが、プロジェクトのディレクトリ構造は以下のようになっています (実際にはもう少し複雑ですが簡略化しています)。

project-root
├── terragrunt.hcl  # 全体で共通するルート設定
├── envs  # 環境ごとの個別パラメータを定義した Terragrunt ファイルを格納
│   ├── prod  # 本番環境 (production)
│   │   ├── ecs
│   │   │   └── app1
│   │   │       └── terragrunt.hcl
│   │   ├── rds
│   │   │   └── terragrunt.hcl
│   │   └── alb
│   │       └── terragrunt.hcl
│   └── stg  # 検証環境 (staging)
│       ├── ecs
│       │   └── app2
│       │       └── terragrunt.hcl
│       └── rds
│           └── terragrunt.hcl
└── modules  # 複数の環境で参照する Terraform ファイルを格納
    ├── ecs
    │   ├── default1
    │   │   ├── main.tf
    │   │   ├── variables.tf
    │   │   └── outputs.tf
    │   └── default2
    │       ├── main.tf
    │       ├── variables.tf
    │       └── outputs.tf
    ├── rds
    │   └── default
    │       ├── main.tf
    │       ├── variables.tf
    │       └── outputs.tf
    └── alb
        └── default
            ├── main.tf
            ├── variables.tf
            └── outputs.tf

envs ディレクトリ配下の prod/ecs/app1/terragrunt.hclstg/ecs/app2/terragrunt.hcl では、以下のように参照しているものとします。

prod/ecs/app1/terragrunt.hcl
terraform {
  source = "${dirname(find_in_parent_folders())}/modules/ecs//default1"
}
stg/ecs/app2/terragrunt.hcl
terraform {
  source = "${dirname(find_in_parent_folders())}/modules/ecs//default2"
}

この状況で例えば modules/ecs/default1/main.tf を修正した時、どの terragrunt.hcl に対して plan を実行すれば良いかが分かリません。

また、 prod 配下には alb ディレクトリがありますが、 stg 配下にはありません。この時、やはり modules/alb/default/main.tf を修正した時にどの terragrunt.hcl に対して plan を実行すれば良いかが分かりません。

全ての環境で対象であるのが望ましいですが、開発やコストの都合により必ずしもそれができるとは限りません。これが一つ目の問題です。

全てのディレクトリで plan すれば良いのでは?

この問題解決のため、元々は planapplyrun-all を指定して全ディレクトリに対して実行すれば良いと考えていました。しかし、その場合修正対象とは全く関係ない箇所で意図しない変更が適用されてしまう可能性がありました。

理想としては、 修正対象のみ plan, apply できるようにしてそれ以外には影響のないようにしたかった のです。

どうやって解決したか?

全ての terragrunt.hcl ファイルと Terraform ファイルとのマッピングを作成しました。作成したマッピングデータは DynamoDB に保存して、 plan 時に参照して対応する terragrunt.hcl に対して plan するようにしました。

同時に複数の修正が含まれており、かつ依存関係がある時に、依存している方が必ずエラーになる

何が問題か?

例えば、以下のような定義があったとします。

prod/ecs/app1/terragrunt.hcl
terraform {
  source = "${dirname(find_in_parent_folders())}/modules/ecs//default1"
}
prod/alb/terragrunt.hcl
terraform {
  source = "${dirname(find_in_parent_folders())}/modules/alb//default"
}

# app1 への依存 (参照)
dependency "app1" {
  config_path = "${dirname(find_in_parent_folders())}/envs/prod/ecs/app1"
}

ある PR にて app1 と alb の両方を新規に追加しようとした時、 alb は app1 を参照しているため、 app1 が apply されていないと alb での plan は参照するリソースが存在しないとのエラーになってしまいます。

どうやって解決したか?

PR 作成時に apply するわけにもいかないので、このエラーはどうしようもありません。

依存する terragrunt.hcl (今回の場合だと alb の terragrunt.hcl) に mock を定義する等すればエラーは回避できますが、 terragrunt.hcl に余計な定義は追加したくありませんでしたし、それにより定義漏れに気付けない弊害もあります。

そのため、このエラーについては発生するのを許容して「できれば修正を別 PR に分けてね」というメッセージに留めることとしました。

エラー発生時のPRコメント

GitHub Actions の全体像

かなりサラッと書いてますが、実現にはそれなりの実装を行いました。

本記事では、GitHub Actions の構成とワークフローを記載することとし、terragrunt.hcl ファイルと Terraform ファイルとのマッピングについては別記事にて紹介したいと思います。

GitHub Actions の構成
.github
├── actions
│   ├── terragrunt
│   │   └── plan
│   │       └── action.yml  # 4. terragrunt plan を実行する action
│   └── tf_module_mapper
|        ... (本記事では割愛、別記事で紹介します) ...
└── workflows
    ├── terragrunt-plan-diff-prod.yml  # 1. prod 環境に対する自動 plan
    ├── terragrunt-plan-diff-stg.yml  # 2. stg 環境に対する自動 plan
    └── _plan_terragrunt_diff.yml  # 3. 自動 plan 処理の本体となるワークフロー

1. prod 環境に対する自動 plan

terragrunt-plan-diff-prod.yml
name: "[PROD] Terragrunt plan"
on:
  pull_request:
    paths:
      - "envs/prod/**"
      - "modules/**"

permissions:
  id-token: write
  contents: read
  actions: read
  pull-requests: write

jobs:
  checkout:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

  plan:
    needs:
      - checkout
    uses: ./.github/workflows/_plan_terragrunt_diff.yml
    with:
      env: "prod"

2. stg 環境に対する自動 plan

terragrunt-plan-diff-stg.yml
name: "[STG] Terragrunt plan"
on:
  pull_request:
    paths:
      - "envs/stg/**"
      - "modules/**"

permissions:
  id-token: write
  contents: read
  actions: read
  pull-requests: write

jobs:
  checkout:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

  plan:
    needs:
      - checkout
    uses: ./.github/workflows/_plan_terragrunt_diff.yml
    with:
      env: "stg"

3. 自動 plan 処理の本体となるワークフロー

_plan_terragrunt_diff.yml
name: Plan terragrunt with diff files (Reuseable workflow)

on:
  workflow_call:
    inputs:
      env:
        required: true
        type: string

env:
  TF_VERSION: "1.x.x"
  TG_VERSION: "0.xx.xx"

permissions:
  id-token: write
  contents: read
  actions: read
  pull-requests: write

jobs:
  # plan 前の準備
  prepare:
    runs-on: ubuntu-latest
    outputs:
      aws_role_arn: ${{ steps.set_values.outputs.aws_role_arn }}
      diff_envs: ${{ steps.align_diffs.outputs.diff_envs }}
      diff_modules: ${{ steps.get_hcl_by_modules.outputs.found_paths }}
    steps:
      # AWS 環境の情報をセットする
      - id: set_values
        run: |
          declare -A account_ids
          account_ids=(
            ["prod"]="123456789012"
            ["stg"]="112233445566"
          )
          declare -A account_names
          account_names=(
            ["prod"]="product-prod"
            ["stg"]="product-stg"
          )
          echo "envs_dir=${{ inputs.env }}" >> "$GITHUB_OUTPUT"
          echo "aws_role_arn=arn:aws:iam::${account_ids['${{ inputs.env }}']}:role/terraform-plan-${{ inputs.env }}-role" >> "$GITHUB_OUTPUT"
          echo "dynamodb_table=simplecheck-${{ inputs.env }}-infra-tfmappings" >> "$GITHUB_OUTPUT"
        shell: bash

      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # main ブランチと差分のあるファイルのみを取得する
      - id: diff_files
        uses: dorny/paths-filter@v3
        with:
          base: "main"
          ref: ${{ github.ref }}
          list-files: "shell"
          filters: |
            env:
              - "envs/${{ steps.set_values.outputs.envs_dir }}/**/terragrunt.hcl"
            module:
              - "modules/**/*.tf"

      # 後続の処理で扱いやすいように出力を整形する
      - id: align_diffs
        run: |
          diff_envs=$(echo "${{ steps.diff_files.outputs.env_files }}" | sed "s/\/terragrunt.hcl//g" | jq -R 'split(" ")' | jq -c)
          diff_modules=$(echo "${{ steps.diff_files.outputs.module_files }}" | jq -R 'split(" ")' | jq -c)
          echo ${diff_envs}
          echo ${diff_modules}
          echo "diff_envs=${diff_envs}" >> "$GITHUB_OUTPUT"
          echo "diff_modules=${diff_modules}" >> "$GITHUB_OUTPUT"

      # modules 配下の修正に対して対応する terragrunt.hcl のパスを取得する
      - id: get_hcl_by_modules
        uses: ./.github/actions/tf_module_mapper/query  # この action については別記事にて紹介します
        with:
          aws_role_arn: ${{ steps.set_values.outputs.aws_role_arn }}
          module_paths: ${{ steps.diff_files.outputs.module_files }}
          dynamodb_table_name: ${{ steps.set_values.outputs.dynamodb_table }}

  # envs 配下のファイルに修正があった場合の plan
  plan_terragrunt_envs:
    needs:
      - prepare
    if: ${{ needs.prepare.outputs.diff_envs != '' && toJson(fromJson(needs.prepare.outputs.diff_envs)) != '[]' }}  # 差分があった場合にのみ実行
    outputs:
      exit_code: ${{ steps.plan.outputs.exit_code }}
      output_message: ${{ steps.plan.outputs.output_message }}
    strategy:
      fail-fast: false  # 各ジョブでエラーが発生しても続行する
      matrix:
        tg_dir: ${{ fromJson(needs.prepare.outputs.diff_envs) }}  # 修正対象を並列に plan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - id: plan
        uses: ./.github/actions/terragrunt/plan
        env:
          TF_VERSION: ${{ env.TF_VERSION }}
          TG_VERSION: ${{ env.TG_VERSION }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          aws_role_arn: ${{ needs.prepare.outputs.aws_role_arn }}
          tg_dir: ${{ matrix.tg_dir }}

  # modules 配下のファイルに修正があった場合の plan
  plan_terragrunt_modules:
    needs:
      - prepare
    if: ${{ needs.prepare.outputs.diff_modules != '' && toJson(fromJson(needs.prepare.outputs.diff_modules)) != '[]' }}
    outputs:
      exit_code: ${{ steps.plan.outputs.exit_code }}
      output_message: ${{ steps.plan.outputs.output_message }}
    strategy:
      fail-fast: false
      matrix:
        tg_dir: ${{ fromJson(needs.prepare.outputs.diff_modules) }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - id: plan
        uses: ./.github/actions/terragrunt/plan
        env:
          TF_VERSION: ${{ env.TF_VERSION }}
          TG_VERSION: ${{ env.TG_VERSION }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          aws_role_arn: ${{ needs.prepare.outputs.aws_role_arn }}
          tg_dir: ${{ matrix.tg_dir }}

4. terragrunt plan を実行する action

terragrunt/plan/action.yml
name: Plan terragrunt

inputs:
  aws_role_arn:
    required: true
    type: string
  tg_dir:
    required: true
    type: string

outputs:
  exit_code:
    value: ${{ steps.plan.outputs.tg_action_exit_code }}
  output_message:
    value: ${{ steps.plan.outputs.tg_action_output }}

permissions:
  id-token: write
  contents: read
  actions: read
  pull-requests: write

runs:
  using: "composite"
  steps:
    # plan 前にフォーマットチェック
    - uses: gruntwork-io/terragrunt-action@v2
      with:
        tf_version: ${{ env.TF_VERSION }}
        tg_version: ${{ env.TG_VERSION }}
        tg_dir: ${{ inputs.tg_dir }}
        tg_command: "hclfmt --terragrunt-check --terragrunt-diff"

    - uses: aws-actions/configure-aws-credentials@v4
      with:
        aws-region: ap-northeast-1
        role-to-assume: ${{ inputs.aws_role_arn }}

    # gruntwork の actions を利用して plan を実行
    - id: plan
      uses: gruntwork-io/terragrunt-action@v2
      env:
        GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}
      with:
        tf_version: ${{ env.TF_VERSION }}
        tg_version: ${{ env.TG_VERSION }}
        tg_dir: ${{ inputs.tg_dir }}
        tg_command: "plan -lock=false --terragrunt-forward-tf-stdout"
        # -lock=false ... plan 時にロックを取得しない
        # --terragrunt-forward-tf-stdout ... Terragrunt のログプレフィックスを付けない
      continue-on-error: true  # エラーが発生しても続行する

    # plan 結果を PR コメントとして投稿する
    - uses: actions/github-script@v7
      with:
        github-token: ${{ env.GITHUB_TOKEN }}
        script: |
          const result = (${{ steps.plan.outputs.tg_action_exit_code }} === 0) ? 'success 🎉' : 'failure 😢';
          const notes = (${{ steps.plan.outputs.tg_action_exit_code }} === 0) ? '' : `
          > [!NOTE]
          > コードに不備がなくても以下ケースで失敗する場合があります
          > - \`dependency\` に定義されているリソースが \`apply\` されていない場合 → リソースごとの PR 分割をご検討ください
          \n
          `;

          let planLog = '${{ steps.plan.outputs.tg_action_output }}';
          planLog = planLog.replace(/%0A/g, '\n');

          const output = `\n### Terragrunt Plan Report\n
          #### Plan target: \`${{ inputs.tg_dir }}\`\n
          #### Plan result: \`${result}\`\n
          #### Exit code: \`${{ steps.plan.outputs.tg_action_exit_code }}\`\n
          <details><summary>Plan ログ</summary>\n
          \`\`\`terraform
          ${planLog}
          \`\`\`\n
          </details>\n
          ${notes}
          *Pushed by: @${{ github.actor }}*`;

          github.rest.issues.createComment({
            issue_number: context.issue.number,
            owner: context.repo.owner,
            repo: context.repo.repo,
            body: output
          })

実行結果

plan 結果は以下のように PR 上にコメントとして投稿されます。

plan が正常に実行できた場合

success

plan でエラーが発生した場合

failure

さいごに

Terragrunt の CI/CD をやっている、またはやろうとしている方の参考になれば幸いです。また、他の方法で実現されている方は是非コメントにて教えていただけますと幸いです!

SimpleForm Tech Blog

Discussion