🎑

tfaction を段階的に導入した

に公開

こんにちは、tsub です。2023 年 11 月から育休を取得していて今年の 5 月に復職しました。今回が Social PLUS Tech Blog への初投稿となります 😄

この数ヶ月間、徐々に tfaction の導入を進めていました。元々 Terraform の CI/CD は GitHub Actions で自前のパイプラインを組んでいましたが、通常の開発タスクを優先しつつ隙間時間で CI/CD の改善を行っていたため、段階的に移行する形となりました。

この記事では tfaction を導入した背景や、どのように導入を進めていったかについて説明したいと思います。

tfaction とは

tfaction とは、簡単に説明すると Terraform (あるいは Terragrunt, OpenTofu) 向けの GitHub Actions ベースの CI/CD ツールです。

https://suzuki-shunsuke.github.io/tfaction/docs/

公式ドキュメントでは以下のように説明されています。

tfaction is a framework for a Monorepo to build high-level Terraform workflows using GitHub Actions. You don't have to run terraform apply in your laptop, and don't have to reinvent the wheel for Terraform Workflows anymore.

便宜的に CI/CD ツールと書きましたが、実際には GitHub Actions のカスタムアクションの集合体で、terraform plan や terraform apply を実行するカスタムアクションは勿論、terraform validate や tflint などを実行するカスタムアクションなど、Terraform の開発フローで便利な CI/CD のための機能を多く提供しています。

なぜ tfaction を導入したのか

弊社では素の Terraform ではなく Terragrunt を介して使っているのですが、これまで gruntwork-io/terragrunt-action を使いつつ、ワークフローの大半を自前実装していました。

自前実装で 1 つ課題となっていたのが、ローカルモジュール変更時にモジュールの呼び出し元の plan を実行するような仕組みが実現できていませんでした。

ローカルモジュールのみを変更する機会は割とあるため、CI で plan/apply を実行するために、呼び出し元の方で不要な変更を入れるなどの運用でカバーしている状態でしたが、さすがにこの運用を長く続けるのは大変でした。

当時、ローカルモジュール変更を検知して plan/apply を実行する方法を調べていたところ、tfaction が提供している list-targets Action を使うことで解決できそうなことが分かったため、部分的に list-targets Action を導入しました。

https://suzuki-shunsuke.github.io/tfaction/docs/feature/local-path-module

ここで tfaction の利点に気がつきましたが、tfaction はカスタムアクションの集合体であるため、必要な機能のみを段階的に導入することができます。

また、tfaction は PR をマージしたら apply するというフローで設計されており、我々の自前ワークフローも同様のフローで設計していたため、その点でも親和性が高く、全体的に tfaction を導入していこうという方針となりました。

ここで、list-targets Action 以外の部分は自前実装のままでも問題なかったのですが、ローカルモジュールの変更検知以外にもいくつか課題感があったため、Terraform の CI/CD を全体的に改善していくために tfaction の導入を進めていくことを決めました。

段階的な導入の進め方

以下で説明する各ステップはそれぞれ master にマージした変更で、単体で動作するようになっています。

段階的な導入ということなので、それぞれのステップ間で期間が開いているものもありますし、その間も通常のインフラの開発タスクは継続しているため、単体で実行可能な状態を維持しています。

なお、本記事では導入当時の tfaction v1.16.1 を前提としています。記事執筆時点で v1.19.2 が最新バージョンです。

また、tfaction とは関係ないですが、本記事で紹介しているコードではカスタムアクションの呼び出しにコミットハッシュを使わずにタグで指定しているため、安全に利用するためにコミットハッシュへの置き換えも行うことを推奨します 🙏 (参考)

0. tfaction 導入前のワークフロー

まず前提として弊社のインフラリポジトリの構成は Monorepo 環境となっていて 1 つのリポジトリ内に複数のサービスのルートモジュールがあります。

tfaction の導入を始める前は以下のようなワークフローが定義されていました。
(本記事と直接的に関係のない細かい部分は省いています)

.github/workflows/terragrunt_plan_xxxx.yml
name: Terrgrunt Plan xxxx State

on:
  pull_request:
    branches:
    - master
    - staging
    paths:
    - "terragrunt/xxxx/**"

jobs:
  terragrunt-plan:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
      pull-requests: write
    steps:
    - uses: actions/checkout@v4
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v4.0.2
      with:
        role-to-assume: ${{ secrets.TERRAGRUNT_AWS_IAM_ROLE_ARN }}
        aws-region: ${{ vars.AWS_REGION }}
    - name: terragrunt plan
      uses: gruntwork-io/terragrunt-action@v2
      with:
        tf_version: 1.9.8 # renovate: datasource=github-releases depName=hashicorp/terraform packageName=hashicorp/terraform
        tg_version: 0.69.0 # renovate: datasource=github-releases depName=gruntwork-io/terragrunt packageName=gruntwork-io/terragrunt
        tg_dir: terragrunt/xxxx
        tg_command: 'plan -lock=false'
        tg_comment: 1
      env:
        TERRAGRUNT_FORWARD_TF_STDOUT: "true"
        GITHUB_TOKEN: ${{ github.token }}

このワークフローがルートモジュールごとに定義されており、弊社のルートモジュールは全部で 14 個あるため、計 14 ワークフローとなります。

ワークフローが分かれていた理由は、変更したディレクトリごとに実行する CI を最小限に抑えたいため、on.pull_request.paths の部分で terragrunt/xxxx/** のようにルートモジュールごとのディレクトリ変更を検知していました。

1. list-targets Action の導入

まずは前述のローカルモジュールの変更を検知するために list-targets Action を導入しました。
また、14 個のワークフローの保守性も課題となっていたため、list-targets Action の導入に伴い、matrix ジョブの導入も同時に行なうことで 14 個のワークフローを 1 つに統合しました。

.github/workflows/terragrunt_plan.yml
name: Terragrunt Plan

on:
  pull_request:
    branches:
    - master
    - staging
    paths:
    - "terragrunt/**"
  # デフォルトブランチ上でのキャッシュ作成用。terragrunt plan は実行しない
  push:
    branches:
    - master
    paths:
    - "terragrunt/**"

jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      targets: ${{ steps.list-targets.outputs.targets }}
    permissions:
      contents: read
      pull-requests: write # tfaction/list-targets で必要
    steps:
    - uses: actions/checkout@v4
    # tfaction の内部で使う aqua でインストールされたツールのキャッシュから復元
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 # renovate: datasource=github-releases depName=suzuki-shunsuke/tfaction packageName=suzuki-shunsuke/tfaction
      with:
        path: ~/.local/share/aquaproj-aqua
        # 自前で aqua.yaml を管理していないので tfaction_version をキャッシュキーに使用する
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    # tfaction の内部で使うaquaをインストール
    - uses: aquaproj/aqua-installer@v3.1.2
      with:
        aqua_version: v2.51.1 # renovate: datasource=github-releases depName=aquaproj/aqua packageName=aquaproj/aqua
    # tfaction の update_local_path_module_caller 機能で必要
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-release@v2.1.0
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.69.0 # renovate: datasource=github-releases depName=gruntwork-io/terragrunt packageName=gruntwork-io/terragrunt
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    # tfaction の update_local_path_module_caller 機能で必要
    - uses: hashicorp/setup-terraform@v3.1.2
      with:
        terraform_version: "1.9.8" # renovate: datasource=github-releases depName=hashicorp/terraform packageName=hashicorp/terraform
        terraform_wrapper: false # ラッパースクリプトと tfaction を併用すると意図しない挙動になるため
    # 変更のあった作業ディレクトリを取得する Action
    - uses: suzuki-shunsuke/tfaction/list-targets@v1.16.1
      id: list-targets
      with:
        github_token: ${{ github.token }}

  terragrunt-plan:
    name: "terragrunt-plan for ${{ matrix.target.target }}"
    runs-on: ubuntu-latest
    needs: setup
    # setup で取得した変更のある作業ディレクトリが空の場合は実行しない
    if: join(fromJSON(needs.setup.outputs.targets), '') != ''
    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.setup.outputs.targets) }}
    permissions:
      id-token: write
      contents: read
      pull-requests: write
    steps:
    - uses: actions/checkout@v4
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v4.0.2
      with:
        role-to-assume: ${{ secrets.TERRAGRUNT_AWS_IAM_ROLE_ARN }}
        aws-region: ${{ vars.AWS_REGION }}
    - name: terragrunt plan
      uses: gruntwork-io/terragrunt-action@v2
      with:
        tf_version: 1.9.8 # renovate: datasource=github-releases depName=hashicorp/terraform packageName=hashicorp/terraform
        tg_version: 0.69.0 # renovate: datasource=github-releases depName=gruntwork-io/terragrunt packageName=gruntwork-io/terragrunt
        tg_dir: terragrunt/${{ matrix.target.target }}
        tg_command: 'plan -lock=false'
        tg_comment: 1
      env:
        TERRAGRUNT_FORWARD_TF_STDOUT: "true"
        GITHUB_TOKEN: ${{ github.token }} # PRコメントに使われるトークン

また、tfaction の利用に必要な設定ファイルの追加も合わせて実施しています。

tfaction-root.yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/tfaction/refs/tags/v1.16.1/schema/tfaction-root.json

plan_workflow_name: Terragrunt Plan

# terragrunt/modules のみ変更時にモジュールの呼び出し元を CI を実行対象に含める
# See: https://suzuki-shunsuke.github.io/tfaction/docs/feature/local-path-module
update_local_path_module_caller:
  enabled: true

target_groups:
- working_directory: terragrunt/xxxx
  target: xxxx
# ...
terragrunt/xxxx/tfaction.yaml
{}

ワークフローの実装がガラッと変わったようにも見えますが、本質的には terragrunt plan を実行する前準備として、実行対象を検出する仕組みが増えただけです。
list-targets Action によって変更したローカルモジュールやルートモジュールを検出し、PR 上で変更したディレクトリのみ terragrunt plan を実行するような仕組みに変わりました。

terragrunt plan を実行する部分には変わらず gruntwork-io/terragrunt-action Action を使っています。

また、今回は list-targets Action のローカルモジュール変更検知が必要なだけでしたので、tfaction-root.yaml の設定も必要最小限に抑えていますし、tfaction が前提としている aqua パッケージマネージャの導入も行いませんでした。
tfaction 内部で使っている aqua のために CI 上では aqua CLI をインストールしていますが、ローカル環境などには aqua を導入していませんし、aqua.yaml も追加していません。

2. plan Action の導入

次のステップとして、tfaction の plan Action を導入しました。

plan Action の導入理由は gruntwork-io/terragrunt-action Action の PR コメント機能にサマリ表示がなく、1 つ 1 つ詳細を見るのが大変だったため、tfaction に組み込まれている tfcmt による PR コメントを使いたかったからです。
(ちなみに元々 tfcmt は使っていましたが Terragrunt 導入時に公式 Action に寄せるために一度廃止されていました)


No changes であることを確認するのにも都度トグルを開いて中身を確認する必要があった

https://suzuki-shunsuke.github.io/tfaction/docs/feature/tfcmt

.github/workflows/terragrunt_plan.yml
@@ -43,7 +43,7 @@
       uses: jaxxstorm/action-install-gh-release@v2.1.0
       with:
         repo: gruntwork-io/terragrunt
-        tag: v0.69.0 # renovate: datasource=github-releases depName=gruntwork-io/terragrunt packageName=gruntwork-io/terragrunt
+        tag: v0.81.0 # renovate: datasource=github-releases depName=gruntwork-io/terragrunt packageName=gruntwork-io/terragrunt
         cache: enable
         rename-to: terragrunt
         chmod: 0755
@@ -51,7 +51,7 @@
     # tfaction の update_local_path_module_caller 機能で必要
     - uses: hashicorp/setup-terraform@v3.1.2
       with:
-        terraform_version: "1.9.8" # renovate: datasource=github-releases depName=hashicorp/terraform packageName=hashicorp/terraform
+        terraform_version: "1.12.2" # renovate: datasource=github-releases depName=hashicorp/terraform packageName=hashicorp/terraform
         terraform_wrapper: false # ラッパースクリプトと tfaction を併用すると意図しない挙動になるため
     # 変更のあった作業ディレクトリを取得する Action
     - uses: suzuki-shunsuke/tfaction/list-targets@v1.16.1
@@ -64,15 +64,19 @@
     runs-on: ubuntu-latest
     needs: setup
     # setup で取得した変更のある作業ディレクトリが空の場合は実行しない
-    if: join(fromJSON(needs.setup.outputs.targets), '') != ''
+    if: github.event_name == 'pull_request' && join(fromJSON(needs.setup.outputs.targets), '') != ''
     strategy:
       fail-fast: false
       matrix:
         target: ${{ fromJSON(needs.setup.outputs.targets) }}
     permissions:
       id-token: write
-      contents: read
+      contents: write
       pull-requests: write
+      issues: write
+    env:
+      TFACTION_TARGET: ${{ matrix.target.target }}
+      TFACTION_JOB_TYPE: terraform
     steps:
     - uses: actions/checkout@v4
     - name: Configure AWS credentials
@@ -80,14 +84,36 @@
       with:
         role-to-assume: ${{ secrets.TERRAGRUNT_AWS_IAM_ROLE_ARN }}
         aws-region: ${{ vars.AWS_REGION }}
-    - name: terragrunt plan
-      uses: gruntwork-io/terragrunt-action@v2
+    # tfaction の内部で使う aqua でインストールされたツールのキャッシュから復元
+    - uses: actions/cache@v4
+      env:
+        tfaction_version: v1.16.1 # renovate: datasource=github-releases depName=suzuki-shunsuke/tfaction packageName=suzuki-shunsuke/tfaction
       with:
-        tf_version: 1.9.8 # renovate: datasource=github-releases depName=hashicorp/terraform packageName=hashicorp/terraform
-        tg_version: 0.69.0 # renovate: datasource=github-releases depName=gruntwork-io/terragrunt packageName=gruntwork-io/terragrunt
-        tg_dir: terragrunt/${{ matrix.target.target }}
-        tg_command: 'plan -lock=false'
-        tg_comment: 1
+        path: ~/.local/share/aquaproj-aqua
+        # 自前で aqua.yaml を管理していないので tfaction_version をキャッシュキーに使用する
+        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
+        restore-keys: |
+          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
+    # tfaction の内部で使うaquaをインストール
+    - uses: aquaproj/aqua-installer@v3.1.2
+      with:
+        aqua_version: v2.51.1 # renovate: datasource=github-releases depName=aquaproj/aqua packageName=aquaproj/aqua
+    - name: Install terragrunt
+      uses: jaxxstorm/action-install-gh-release@v2.1.0
+      with:
+        repo: gruntwork-io/terragrunt
+        tag: v0.81.0 # renovate: datasource=github-releases depName=gruntwork-io/terragrunt packageName=gruntwork-io/terragrunt
+        cache: enable
+        rename-to: terragrunt
+        chmod: 0755
+        extension-matching: disable
+    - uses: hashicorp/setup-terraform@v3.1.2
+      with:
+        terraform_version: "1.12.2" # renovate: datasource=github-releases depName=hashicorp/terraform packageName=hashicorp/terraform
+        terraform_wrapper: false # ラッパースクリプトと tfaction を併用すると意図しない挙動になるため
+    - uses: suzuki-shunsuke/tfaction/setup@v1.16.1
       env:
-        TERRAGRUNT_FORWARD_TF_STDOUT: "true"
-        GITHUB_TOKEN: ${{ github.token }} # PRコメントに使われるトークン
+        TG_TF_FORWARD_STDOUT: "true"
+    - uses: suzuki-shunsuke/tfaction/plan@v1.16.1
+      env:
+        TG_TF_FORWARD_STDOUT: "true"
tfaction-root.yaml
@@ -7,6 +7,8 @@
 update_local_path_module_caller:
   enabled: true
 
+terraform_command: terragrunt
+
 target_groups:
 - working_directory: terragrunt/xxxx
   target: xxxx
ソースコード全体
.github/workflows/terragrunt_plan.yml
name: Terragrunt Plan

on:
  pull_request:
    branches:
    - master
    - staging
    paths:
    - "terragrunt/**"
  # デフォルトブランチ上でのキャッシュ作成用。terragrunt plan は実行しない
  push:
    branches:
    - master
    paths:
    - "terragrunt/**"

jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      targets: ${{ steps.list-targets.outputs.targets }}
    permissions:
      contents: read
      pull-requests: write # tfaction/list-targets で必要
    steps:
    - uses: actions/checkout@v4
    # tfaction の内部で使う aqua でインストールされたツールのキャッシュから復元
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 # renovate: datasource=github-releases depName=suzuki-shunsuke/tfaction packageName=suzuki-shunsuke/tfaction
      with:
        path: ~/.local/share/aquaproj-aqua
        # 自前で aqua.yaml を管理していないので tfaction_version をキャッシュキーに使用する
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    # tfaction の内部で使うaquaをインストール
    - uses: aquaproj/aqua-installer@v3.1.2
      with:
        aqua_version: v2.51.1 # renovate: datasource=github-releases depName=aquaproj/aqua packageName=aquaproj/aqua
    # tfaction の update_local_path_module_caller 機能で必要
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-release@v2.1.0
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.81.0 # renovate: datasource=github-releases depName=gruntwork-io/terragrunt packageName=gruntwork-io/terragrunt
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    # tfaction の update_local_path_module_caller 機能で必要
    - uses: hashicorp/setup-terraform@v3.1.2
      with:
        terraform_version: "1.12.2" # renovate: datasource=github-releases depName=hashicorp/terraform packageName=hashicorp/terraform
        terraform_wrapper: false # ラッパースクリプトと tfaction を併用すると意図しない挙動になるため
    # 変更のあった作業ディレクトリを取得する Action
    - uses: suzuki-shunsuke/tfaction/list-targets@v1.16.1
      id: list-targets
      with:
        github_token: ${{ github.token }}

  terragrunt-plan:
    name: "terragrunt-plan for ${{ matrix.target.target }}"
    runs-on: ubuntu-latest
    needs: setup
    # setup で取得した変更のある作業ディレクトリが空の場合は実行しない
    if: github.event_name == 'pull_request' && join(fromJSON(needs.setup.outputs.targets), '') != ''
    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.setup.outputs.targets) }}
    permissions:
      id-token: write
      contents: write
      pull-requests: write
      issues: write
    env:
      TFACTION_TARGET: ${{ matrix.target.target }}
      TFACTION_JOB_TYPE: terraform
    steps:
    - uses: actions/checkout@v4
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v4.0.2
      with:
        role-to-assume: ${{ secrets.TERRAGRUNT_AWS_IAM_ROLE_ARN }}
        aws-region: ${{ vars.AWS_REGION }}
    # tfaction の内部で使う aqua でインストールされたツールのキャッシュから復元
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 # renovate: datasource=github-releases depName=suzuki-shunsuke/tfaction packageName=suzuki-shunsuke/tfaction
      with:
        path: ~/.local/share/aquaproj-aqua
        # 自前で aqua.yaml を管理していないので tfaction_version をキャッシュキーに使用する
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    # tfaction の内部で使うaquaをインストール
    - uses: aquaproj/aqua-installer@v3.1.2
      with:
        aqua_version: v2.51.1 # renovate: datasource=github-releases depName=aquaproj/aqua packageName=aquaproj/aqua
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-release@v2.1.0
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.81.0 # renovate: datasource=github-releases depName=gruntwork-io/terragrunt packageName=gruntwork-io/terragrunt
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    - uses: hashicorp/setup-terraform@v3.1.2
      with:
        terraform_version: "1.12.2" # renovate: datasource=github-releases depName=hashicorp/terraform packageName=hashicorp/terraform
        terraform_wrapper: false # ラッパースクリプトと tfaction を併用すると意図しない挙動になるため
    - uses: suzuki-shunsuke/tfaction/setup@v1.16.1
      env:
        TG_TF_FORWARD_STDOUT: "true"
    - uses: suzuki-shunsuke/tfaction/plan@v1.16.1
      env:
        TG_TF_FORWARD_STDOUT: "true"
tfaction-root.yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/tfaction/refs/tags/v1.16.1/schema/tfaction-root.json

plan_workflow_name: Terragrunt Plan

# terragrunt/modules のみ変更時にモジュールの呼び出し元を CI を実行対象に含める
# See: https://suzuki-shunsuke.github.io/tfaction/docs/feature/local-path-module
update_local_path_module_caller:
  enabled: true

terraform_command: terragrunt

target_groups:
- working_directory: terragrunt/xxxx
  target: xxxx
# ...

弊社のインフラリポジトリでは Argo CD の CI も実行しているため、tfcmt の PR コメントを一部変更しています。また、ラベルの自動付与は現時点では不要なため無効化しています。

tfcmt.yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/tfcmt/v4.14.1/json-schema/tfcmt.json

terraform:
  plan:
    disable_label: true
templates:
  # default: plan_title: "## {{if eq .ExitCode 1}}:x: {{end}}Plan Result{{if .Vars.target}} ({{.Vars.target}}){{end}}"
  plan_title: "## {{if eq .ExitCode 1}}:x: {{end}}terragrunt plan result{{if .Vars.target}} ({{.Vars.target}}){{end}}"

tfcmt による PR コメントで変更内容のサマリが表示されるようになり、非常に見やすくなりました。

また、合わせて PR ラベルとして変更があったルートモジュールが記載されるようになり、Monorepo 開発において変更箇所が分かりやすくなりました。

plan Action と同時に setup Action も導入したことにより、.terraform.lock.hcl の自動更新も行なってくれるようになりました。

3. Terragrunt のディレクトリ構成変更

次のステップは tfaction と直接的に関係ないのですが、既存の Terragrunt のディレクトリ構成が tfaction の利用に適しておらず、複数の環境の plan を実行できない状態だったため[1]、Terragrunt のディレクトリ構成を変更しました。

元々 Terragrunt 導入時に以下のようなディレクトリ構成となっていました。

- terragrunt
    |- xxxx (サービス/ルートモジュール ごとのディレクトリ)
        |- terragrunt.hcl
        |- *.tf (Terraform ソースコード)

環境ごとにディレクトリが分かれていないため、staging/production の切り替えをどうやっているかというと、Terragrunt の run_cmd function を使ってシェルスクリプトを実行し、環境変数やハードコードされた値などで実行対象の環境を決めていました。

そのため、tfaction の設定でも target_groups は環境ごとのディレクトリを記述せずに以下のようになっていました。

tfaction-root.yaml
# ...

target_groups:
- working_directory: terragrunt/xxxx
  target: xxxx
# ...

そして、CI 上では PREFIX という環境変数を用いて PR のベースブランチに応じて環境の切り替えを行なっていました。

.github/workflows/terragrunt_plan.yml
# ...
    - uses: suzuki-shunsuke/tfaction/plan@v1.16.1
      env:
        PREFIX: ${{ github.base_ref == 'master' && 'production' || github.base_ref == 'staging' && 'staging' }}

この設計でも tfaction の動作は問題ないのですが、1 つの PR 上で複数の環境の plan を実行しようとすると、tfaction の target_groups が同一のままだと plan Action でのアーティファクトアップロード部分 (plan 結果をファイルとして出力している) で名前の競合がおき、以下のようなエラーが出てしまいました。

Error: Failed to CreateArtifact: Received non-retryable error: Failed request: (409) Conflict: an artifact with this name already exists on the workflow run

そのため、Terragrunt のディレクトリ構成を以下のように変更しました。こちらのディレクトリ構成の方が Terraform/Terragrunt において割と一般的ではないかと思います。

- terragrunt
    |- envs
    |   |- production
    |       |- xxxx (サービス/ルートモジュール ごとのディレクトリ)
    |           |- terragrunt.hcl (Terragrunt ワーキングディレクトリ)
    |   |- staging
    |       |- xxxx
    |           |- terragrunt.hcl (Terragrunt ワーキングディレクトリ)
    |   |- review
    |       |- xxxx
    |           |- terragrunt.hcl (Terragrunt ワーキングディレクトリ)
    |- xxxx (サービス/ルートモジュール ごとのディレクトリ)
        |- terragrunt.hcl (互換性維持のため一旦残す)
        |- *.tf (Terraform ソースコード)

一度にディレクトリ構成を完全に切り替えると開発チーム内で混乱が起きそうだと思い、一応従来のディレクトリ構成でもローカルから plan などが実行できるように terragrunt.hcl を残してあります。
ただし CI 上では新しいディレクトリ構成を前提として動作するようにしました。

tfaction の設定も環境ごとに target_groups の定義を分けるようにしました。

tfaction-root.yaml
# ...

target_groups:
- working_directory: terragrunt/envs/production/xxxx
  target: production/xxxx
- working_directory: terragrunt/envs/staging/xxxx
  target: staging/xxxx
- working_directory: terragrunt/envs/review/xxxx
  target: review/xxx
# ...

なお、GitHub Actions ワークフローの実装例は本筋から外れるため省略します。

4. test, test-module Action の導入

次のステップとして、tfaction の test, test-module Action を導入しました。

tfaction の test Aciton や test-module Action では以下のことが行えます。

  • terraform validate によるエラー検出 (test Action のみ)
  • terraform fmt によるコードの自動修正
  • tflint --fix によるコードの自動修正
  • terraform-docs によるモジュールのドキュメント生成
  • その他、tfsec や trivy の実行 (今回は使っていない)

これまで、terraform fmt や tflint は自前のワークフローや reviewdog などを使って実装していましたが、tfaction に統合することで、管理するワークフローの数を減らせる点や、CI の実行時間などでいくつかメリットがありました。

test Acton の導入は基本的に plan のワークフローに以下を追加するだけです。弊社の場合は前述の通り aqua を使っていないため、tflint のインストールも行なっています。

.github/workflows/terragrunt_plan.yml
@@ -119,6 +120,10 @@ jobs:
+     - uses: terraform-linters/setup-tflint@v4.1.1
+       with:
+         tflint_version: v0.50.3 # renovate: datasource=github-releases depName=terraform-linters/tflint packageName=terraform-linters/tflint
@@ -152,6 +157,41 @@ jobs:
+     # terraform validate や tflint などを実行する
+     - uses: suzuki-shunsuke/tfaction/test@v1.16.1
+       env:
+         TFLINT_CONFIG_FILE: "${{ github.workspace }}/.tflint.hcl"
+         TG_TF_FORWARD_STDOUT: "true"

また、tfaction の設定にも追加が必要です。

tfaction-root.yaml
+ tflint:
+   enabled: true
+   fix: true
+
+ # デフォルト true のため、必要になるまで一旦無効化
+ trivy:
+   enabled: false

test-module Action の導入は plan のワークフローに別のジョブを追加しています。

.github/workflows/terragrunt_plan.yml
# ...
  # terragrunt/modules などの共用モジュールを terraform fmt, tflint などでテストする
  test-module:
    name: "test-module for ${{ matrix.module }}"
    runs-on: ubuntu-latest
    needs: setup
    # キャッシュ作成時 (on.push) の時はスキップ
    # setup で取得した変更のある作業ディレクトリが空の場合は実行しない
    if: github.event_name == 'pull_request' && join(fromJSON(needs.setup.outputs.modules), '') != ''
    strategy:
      fail-fast: false
      matrix:
        module: ${{ fromJSON(needs.setup.outputs.modules) }}
    permissions:
      contents: write
      pull-requests: write
      issues: write
    env:
      TFACTION_TARGET: ${{ matrix.module }}
      TFACTION_JOB_TYPE: terraform
    steps:
    - uses: actions/checkout@v4
    # tfaction の内部で使う aqua でインストールされたツールのキャッシュから復元
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 # renovate: datasource=github-releases depName=suzuki-shunsuke/tfaction packageName=suzuki-shunsuke/tfaction
      with:
        path: ~/.local/share/aquaproj-aqua
        # 自前で aqua.yaml を管理していないので tfaction_version をキャッシュキーに使用する
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    # tfaction の内部で使うaquaをインストール
    - uses: aquaproj/aqua-installer@v4.0.2
      with:
        aqua_version: v2.51.1 # renovate: datasource=github-releases depName=aquaproj/aqua packageName=aquaproj/aqua
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-release@v2.1.0
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.81.0 # renovate: datasource=github-releases depName=gruntwork-io/terragrunt packageName=gruntwork-io/terragrunt
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    - uses: hashicorp/setup-terraform@v3.1.2
      with:
        terraform_version: "1.12.2" # renovate: datasource=github-releases depName=hashicorp/terraform packageName=hashicorp/terraform
        terraform_wrapper: false # ラッパースクリプトと tfaction を併用すると意図しない挙動になるため
    - uses: terraform-linters/setup-tflint@v4.1.1
      with:
        tflint_version: v0.50.3 # renovate: datasource=github-releases depName=terraform-linters/tflint packageName=terraform-linters/tflint
    - uses: suzuki-shunsuke/tfaction/install@v1.16.1
    - uses: ./.github/actions/test-module
      env:
        TFLINT_CONFIG_FILE: "${{ github.workspace }}/.tflint.hcl"

test-module Action でテストする対象は Terraform のルートモジュール以外ということになりますが、弊社の場合は terragrunt/modules/ ディレクトリ配下にモジュールを定義しており、これらのローカルモジュールは素の Terraform で書かれているため terragrunt.hcl を配置しておらず、test-module Action では terraform コマンドを実行する必要があります。

tfaction の設定でトップレベルに terraform_command: terragrunt を指定していると、test-module Action で terragrunt コマンドが使われるようになってしまい、terragrunt.hcl が存在しないため実行エラーとなってしまいます。

そのため、terraform_command: terragrunttarget_groups 配下のみ適用するように変更を加える必要がありました。

tfaction-root.yaml
- terraform_command: terragrunt
+ # global で terraform_command を指定すると test-module action 内の terragrunt fmt 実行時、
+ # terragrunt/modules/** に terragrunt.hcl がなくエラーになるため、target_groups 内で個別に指定する
+ #
+ # terraform_command: terragrunt

  target_groups:
  - working_directory: terragrunt/envs/production/xxxx
    target: production/xxxx
+   terraform_command: terragrunt
  - working_directory: terragrunt/envs/staging/xxxx
    target: staging/xxxx
+   terraform_command: terragrunt
  - working_directory: terragrunt/envs/review/xxxx
    target: review/xxx
+   terraform_command: terragrunt
# ...

また、test-module Action 内では terraform-docs を使用してドキュメントの自動生成・更新を行なってくれるのですが、弊社では元々 terraform-docs を使っていなかったため、この時点で terraform-docs の導入まで一緒にやってしまうと認知負荷が増えてしまうため、一時的に terraform-docs は実行しないようにしています。

tfaction 側には terraform-docs を無効化するオプションは現在提供されていません。

https://github.com/suzuki-shunsuke/tfaction/issues/2757

そのため、test-module Action をローカルの Composite Action としてコピーし、terraform-docs 実行部分だけ一部コメントアウトするような形を取っています。

.github/actions/test-module/action.yml
+ MIT License
+ 
+ Copyright (c) 2022 Shunsuke Suzuki
+ 
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+ 
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+ 
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
+
+ # terraform-docs を共用モジュール上で実行しないため
+ #
+ # tfaction/test-module の terraform-docs が実行されると差分が多すぎるため一時的に止める
+ # tfaction には共用モジュール用の terraform-docs を止める機能が実装されていないため、tfaction/test-module を自前で管理して terraform-docs を実行しないようにする
+ # See: https://github.com/suzuki-shunsuke/tfaction/issues/2757
+ # Refs: https://github.com/suzuki-shunsuke/tfaction/blob/v1.16.1/test-module/action.yaml
+
  name: Test Module
  description: Test Module
  inputs:
@@ -56,10 +63,11 @@ runs:
        shell: bash
        working-directory: ${{ env.TFACTION_TARGET }}

-     - uses: suzuki-shunsuke/tfaction/terraform-docs@v1.16.1
-       with:
-         github_token: ${{ inputs.github_token }}
-         working_directory: ${{ env.TFACTION_TARGET }}
+     # terraform-docs の実行を一旦止める
+     # - uses: suzuki-shunsuke/tfaction/terraform-docs@v1.16.1
+     #   with:
+     #     github_token: ${{ inputs.github_token }}
+     #     working_directory: ${{ env.TFACTION_TARGET }}

terraform-docs が実行されること自体は問題ないため、今後この Composite Action 廃止して通常通り test-module Action を使う予定です。

5. GitHub Actions トークンを GitHub App トークンへ移行

次のステップとして、GitHub Actions の GITHUB_TOKEN (ここでは便宜上 GitHub Actions トークンと表現します) から GitHub App トークンへ移行しました。
移行した主な理由としては、Renovate の Terraform Provider アップデートの PR でオートマージを行うためです。

tfaction には Renovate の PR 上で terraform plan の差分があると CI を失敗扱いにしてくれる機能があり、これを使うことで Terraform Provider などのアップデートを安全にオートマージすることができます。

https://suzuki-shunsuke.github.io/tfaction/docs/feature/renovate

この機能を使って Renovate の Terraform Provider アップデートの PR をオートマージにしたいのですが、ここで問題になるのが、GitHub Actions トークンと GitHub Action の仕様です。

GitHub Actions トークンを用いてコミットした場合は、そのコミットで次のワークフローがトリガーされないため、GitHub App トークンを使うことでワークフローがトリガーされるようにしました。

GitHub App トークンの生成には actions/create-github-app-token Action を使っています。一般的な使い方だと思いますので、詳しい実装は割愛します。

6. apply Action の導入

最後のステップとして tfaction の apply Action を導入しました。
ここまで来れば tfaction を導入したと言って良いでしょう 😁

tfaction では plan 時に terraform plan の -out=tfplan.binary オプションを使って plan 結果をファイル出力し、それを apply 時に参照してくれます。(異なるワークフロー間ですので GitHub Actions のアーティファクトを経由します)

これによって、plan の結果として表示された差分だけを適用することができ、安全に terraform apply の CI/CD を組むことができます。

https://suzuki-shunsuke.github.io/tfaction/docs/feature/plan-file

また、tfplan.binary を使って apply を行うと、古い tfplan.binary で apply した時に以下のエラーが出ます。

  ╷
  │ Error: Saved plan is stale
  │ 
  │ The given plan file can no longer be applied because the state was changed
  │ by another operation after the plan was created.
  ╵

このようなケースを考慮して tfaction では自動的に同じルートモジュールに変更がある PR に対してデフォルトブランチを取り込んでくれるような機能があります。

https://suzuki-shunsuke.github.io/tfaction/docs/feature/auto-update-related-prs

自前でワークフローを実装する場合はこういった考慮があって難しいため、tfaction を使うことで安全なワークフローを比較的組みやすいと思います。

さて、apply Action の導入ですが、基本的には plan と同様の実装で、違う点としては env.TFACTION_IS_APPLY: "true" が必要になるのと、gruntwork-io/terragrunt-action Action を suzuki-shunsuke/tfaction/apply Action に置き換えるくらいでした。
あとは GITHUB_TOKEN に actions: read の権限が必要になります。

# ...
    env:
      TFACTION_IS_APPLY: "true"
      TFACTION_TARGET: ${{ matrix.target.target }}
      TFACTION_JOB_TYPE: terraform
    steps:
    # tfaction 及び github-comment で使うための GITHUB_TOKEN を生成
    - uses: actions/create-github-app-token@v2.0.2
      id: tfaction-app-token
      with:
        app-id: ${{ vars.INFRA_TFACTION_GITHUB_APP_ID }}
        private-key: ${{ secrets.INFRA_TFACTION_GITHUB_APP_PRIVATE_KEY }}
        permission-actions: read # Download plan files
        permission-issues: write # Update drift issues
        permission-pull-requests: write # Post comments / Create a pull request
        permission-contents: write # Update related pull requests / Push commits
# ...
    - name: terragrunt apply
      id: apply
      uses: suzuki-shunsuke/tfaction/apply@v1.16.1
      with:
        github_token: ${{ steps.tfaction-app-token.outputs.token }}
      env:
        TG_TF_FORWARD_STDOUT: "true"
        TG_LOG_LEVEL: warn # PR コメントに INFO ログが書かれていると邪魔になるため 
# ...

ただ、弊社では apply 実行後に Slack へ通知していたため、その運用を維持するために以下の改修も行なっています。もともと terragrunt apply の標準出力で No changes かどうかを判断して通知メッセージの内容を書き分けていましたが、tfaction の apply Action では標準出力が outputs 経由で取れないため、tfplan.binary の中から .applyable を参照して No changes かどうかを判断するようにしています。

.github/workflows/terragrunt_plan.yml
# ...
    - name: create success message
      id: slack_success_message
      working-directory: ${{ matrix.target.working_directory }}
      env:
        TFPLAN_FILE_PATH: tfplan.binary
      run: |
        ls -al ./ # to debug
        TERRAGRUNT_CHANGES=$(terragrunt show -json "$TFPLAN_FILE_PATH" | jq '.applyable')

        if [ "$TERRAGRUNT_CHANGES" = 'false' ]; then
          message=$(cat <<EOS
        *${{ github.actor }}* による *${{ github.ref_name }}* Mergeによって *${{ matrix.target.target }}* への terragrunt apply による差分はありませんでした
        EOS
          )
        else
          message=$(cat <<EOS
        *${{ github.actor }}* による *${{ github.ref_name }}* Mergeによって *${{ matrix.target.target }}* への terragrunt apply が実行されました。
        正常に完了しました
        <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|Workflow URL>
        EOS
          )
        fi

        {
          echo 'message<<EOS'
          echo "$message"
          echo EOS
        } >> "$GITHUB_OUTPUT"
# ...

最終的な plan, apply ワークフローの実装を載せておきます。

ソースコード全体
.github/workflows/terragrunt_plan.yml
name: Terragrunt Plan

on:
  pull_request:
    branches:
    - master
    - production
    paths:
    - "terragrunt/**"
  # デフォルトブランチ上でのキャッシュ作成用。terragrunt plan は実行しない
  push:
    branches:
    - master
    paths:
    - "terragrunt/**"

jobs:
  setup:
    uses: ./.github/workflows/terragrunt_setup.yml
    secrets: inherit
    with:
      # キャッシュ作成用の on.push は考慮する必要がないため実装しない
      target_envs: |-
        ${{
          github.base_ref == 'master' && 'production staging review' ||
          github.base_ref == 'production' && 'production' ||
        }}

  terragrunt-plan:
    name: "terragrunt-plan for ${{ matrix.target.target }}"
    runs-on: ubuntu-latest
    needs: setup
    # キャッシュ作成時 (on.push) の時はスキップ
    # setup で取得した変更のある作業ディレクトリが空の場合は実行しない
    if: github.event_name == 'pull_request' && join(fromJSON(needs.setup.outputs.targets), '') != ''
    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.setup.outputs.targets) }}
    permissions:
      id-token: write
      contents: read
    env:
      TFACTION_TARGET: ${{ matrix.target.target }}
      TFACTION_JOB_TYPE: terraform
    steps:
    # tfaction 及び github-comment で使うための GITHUB_TOKEN を生成
    - uses: actions/create-github-app-token@v2.0.2
      id: tfaction-app-token
      with:
        app-id: ${{ vars.INFRA_TFACTION_GITHUB_APP_ID }}
        private-key: ${{ secrets.INFRA_TFACTION_GITHUB_APP_PRIVATE_KEY }}
        permission-issues: write # Create labels
        permission-pull-requests: write # Post comments and set labels
        permission-contents: write # Push commits
    - id: export-target
      name: Export target service and env from matrix.target.target
      env:
        target: ${{ matrix.target.target }}
      run: |
        # $target: production/account
        # $env: production
        # $service: account
        env=$(echo "$target" | cut -d '/' -f 1)
        service=$(echo "$target" | cut -d '/' -f 2)

        echo "env: $env"
        echo "service: $service"

        echo "env=$env" >> "$GITHUB_OUTPUT"
        echo "service=$service" >> "$GITHUB_OUTPUT"
    - uses: actions/checkout@v4
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v4.0.2
      with:
        role-to-assume: ${{ secrets.TERRAGRUNT_AWS_IAM_ROLE_ARN }}
        aws-region: ${{ vars.AWS_REGION }}
    # tfaction の内部で使う aqua でインストールされたツールのキャッシュから復元
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 # renovate: datasource=github-releases depName=suzuki-shunsuke/tfaction packageName=suzuki-shunsuke/tfaction
      with:
        path: ~/.local/share/aquaproj-aqua
        # 自前で aqua.yaml を管理していないので tfaction_version をキャッシュキーに使用する
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    # tfaction の内部で使うaquaをインストール
    - uses: aquaproj/aqua-installer@v4.0.2
      with:
        aqua_version: v2.51.1 # renovate: datasource=github-releases depName=aquaproj/aqua packageName=aquaproj/aqua
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-release@v2.1.0
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.81.0 # renovate: datasource=github-releases depName=gruntwork-io/terragrunt packageName=gruntwork-io/terragrunt
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    - uses: hashicorp/setup-terraform@v3.1.2
      with:
        terraform_version: "1.12.2" # renovate: datasource=github-releases depName=hashicorp/terraform packageName=hashicorp/terraform
        terraform_wrapper: false # ラッパースクリプトと tfaction を併用すると意図しない挙動になるため
    - uses: terraform-linters/setup-tflint@v4.1.1
      with:
        tflint_version: v0.50.3 # renovate: datasource=github-releases depName=terraform-linters/tflint packageName=terraform-linters/tflint
    - uses: shmokmt/actions-setup-github-comment@v2.1.1
      with:
        version: v6.3.2 # renovate: datasource=github-releases depName=suzuki-shunsuke/github-comment packageName=suzuki-shunsuke/github-comment
    - name: Hide old PR comments
      run: github-comment exec -k hide -- github-comment hide -k tfcmt
      env:
        GITHUB_TOKEN: ${{ steps.tfaction-app-token.outputs.token }}
        GH_COMMENT_VAR_tfaction_target: ${{ env.TFACTION_TARGET }}
    # terragrunt init などを実行する
    - uses: suzuki-shunsuke/tfaction/setup@v1.16.1
      with:
        github_token: ${{ steps.tfaction-app-token.outputs.token }}
      env:
        TG_TF_FORWARD_STDOUT: "true"
    # terraform validate や tflint などを実行する
    - uses: suzuki-shunsuke/tfaction/test@v1.16.1
      with:
        github_token: ${{ steps.tfaction-app-token.outputs.token }}
      env:
        GITHUB_TOKEN: ${{ steps.tfaction-app-token.outputs.token }} # For github-comment
        TFLINT_CONFIG_FILE: "${{ github.workspace }}/.tflint.hcl"
        TG_TF_FORWARD_STDOUT: "true"
    - uses: suzuki-shunsuke/tfaction/plan@v1.16.1
      with:
        github_token: ${{ steps.tfaction-app-token.outputs.token }}
      env:
        GITHUB_TOKEN: ${{ steps.tfaction-app-token.outputs.token }} # For github-comment and tfcmt
        TG_TF_FORWARD_STDOUT: "true"

  # terragrunt/modules などの共用モジュールを terraform fmt, tflint などでテストする
  test-module:
    name: "test-module for ${{ matrix.module }}"
    runs-on: ubuntu-latest
    needs: setup
    # キャッシュ作成時 (on.push) の時はスキップ
    # setup で取得した変更のある作業ディレクトリが空の場合は実行しない
    if: github.event_name == 'pull_request' && join(fromJSON(needs.setup.outputs.modules), '') != ''
    strategy:
      fail-fast: false
      matrix:
        module: ${{ fromJSON(needs.setup.outputs.modules) }}
    permissions:
      id-token: write
      contents: read
    env:
      TFACTION_TARGET: ${{ matrix.module }}
      TFACTION_JOB_TYPE: terraform
    steps:
    # tfaction 及び github-comment で使うための GITHUB_TOKEN を生成
    - uses: actions/create-github-app-token@v2.0.2
      id: tfaction-app-token
      with:
        app-id: ${{ vars.INFRA_TFACTION_GITHUB_APP_ID }}
        private-key: ${{ secrets.INFRA_TFACTION_GITHUB_APP_PRIVATE_KEY }}
        permission-pull-requests: write # Post comments
        permission-contents: write # Push commits
    - uses: actions/checkout@v4
    # tfaction の内部で使う aqua でインストールされたツールのキャッシュから復元
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 # renovate: datasource=github-releases depName=suzuki-shunsuke/tfaction packageName=suzuki-shunsuke/tfaction
      with:
        path: ~/.local/share/aquaproj-aqua
        # 自前で aqua.yaml を管理していないので tfaction_version をキャッシュキーに使用する
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    # tfaction の内部で使うaquaをインストール
    - uses: aquaproj/aqua-installer@v4.0.2
      with:
        aqua_version: v2.51.1 # renovate: datasource=github-releases depName=aquaproj/aqua packageName=aquaproj/aqua
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-release@v2.1.0
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.81.0 # renovate: datasource=github-releases depName=gruntwork-io/terragrunt packageName=gruntwork-io/terragrunt
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    - uses: hashicorp/setup-terraform@v3.1.2
      with:
        terraform_version: "1.12.2" # renovate: datasource=github-releases depName=hashicorp/terraform packageName=hashicorp/terraform
        terraform_wrapper: false # ラッパースクリプトと tfaction を併用すると意図しない挙動になるため
    - uses: terraform-linters/setup-tflint@v4.1.1
      with:
        tflint_version: v0.50.3 # renovate: datasource=github-releases depName=terraform-linters/tflint packageName=terraform-linters/tflint
    # tfaction 内部で使う aqua のパッケージをインストール
    - uses: suzuki-shunsuke/tfaction/install@v1.16.1
    - uses: ./.github/actions/test-module
      with:
        github_token: ${{ steps.tfaction-app-token.outputs.token }}
      env:
        TFLINT_CONFIG_FILE: "${{ github.workspace }}/.tflint.hcl"

  # ブランチ保護ルールのステータスチェック必須に登録するためのジョブ
  # status_check.yml ワークフローのジョブ名と同一にする必要がある
  terragrunt-plan-status-check:
    runs-on: ubuntu-latest
    needs: [terragrunt-plan, test-module]
    if: failure()
    steps:
    - run: exit 1
.github/workflows/terragrunt_apply.yml
name: Terragrunt Apply

on:
  push:
    branches:
    - master
    - staging
    paths:
    - "terragrunt/**"

jobs:
  setup:
    uses: ./.github/workflows/terragrunt_setup.yml
    secrets: inherit
    with:
      target_envs: |-
        ${{
          github.ref_name == 'master' && 'production' ||
          github.ref_name == 'staging' && 'staging'
        }}

  terragrunt-apply:
    name: "terragrunt-apply for ${{ matrix.target.target }}"
    runs-on: ubuntu-latest
    needs: setup
    # setup で取得した変更のある作業ディレクトリが空の場合は実行しない
    if: join(fromJSON(needs.setup.outputs.targets), '') != ''
    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.setup.outputs.targets) }}
    permissions:
      id-token: write
      contents: read
    env:
      TFACTION_IS_APPLY: "true"
      TFACTION_TARGET: ${{ matrix.target.target }}
      TFACTION_JOB_TYPE: terraform
    steps:
    # tfaction 及び github-comment で使うための GITHUB_TOKEN を生成
    - uses: actions/create-github-app-token@v2.0.2
      id: tfaction-app-token
      with:
        app-id: ${{ vars.INFRA_TFACTION_GITHUB_APP_ID }}
        private-key: ${{ secrets.INFRA_TFACTION_GITHUB_APP_PRIVATE_KEY }}
        permission-actions: read # Download plan files
        permission-issues: write # Update drift issues
        permission-pull-requests: write # Post comments / Create a pull request
        permission-contents: write # Update related pull requests / Push commits
    - id: export-target
      name: Export target service and env from matrix.target.target
      env:
        target: ${{ matrix.target.target }}
      run: |
        # $target: production/account
        # $env: production
        # $service: account
        env=$(echo "$target" | cut -d '/' -f 1)
        service=$(echo "$target" | cut -d '/' -f 2)

        echo "env: $env"
        echo "service: $service"

        echo "env=$env" >> "$GITHUB_OUTPUT"
        echo "service=$service" >> "$GITHUB_OUTPUT"
    - uses: actions/checkout@v4
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v4.0.2
      with:
        role-to-assume: ${{ secrets.TERRAGRUNT_AWS_IAM_ROLE_ARN }}
        aws-region: ${{ vars.AWS_REGION }}
    # tfaction の内部で使う aqua でインストールされたツールのキャッシュから復元
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 # renovate: datasource=github-releases depName=suzuki-shunsuke/tfaction packageName=suzuki-shunsuke/tfaction
      with:
        path: ~/.local/share/aquaproj-aqua
        # 自前で aqua.yaml を管理していないので tfaction_version をキャッシュキーに使用する
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    # tfaction の内部で使うaquaをインストール
    - uses: aquaproj/aqua-installer@v4.0.2
      with:
        aqua_version: v2.51.1 # renovate: datasource=github-releases depName=aquaproj/aqua packageName=aquaproj/aqua
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-release@v2.1.0
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.81.0 # renovate: datasource=github-releases depName=gruntwork-io/terragrunt packageName=gruntwork-io/terragrunt
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    - uses: hashicorp/setup-terraform@v3.1.2
      with:
        terraform_version: "1.12.2" # renovate: datasource=github-releases depName=hashicorp/terraform packageName=hashicorp/terraform
        terraform_wrapper: false # ラッパースクリプトと tfaction を併用すると意図しない挙動になるため
    # terragrunt init などを実行する
    - uses: suzuki-shunsuke/tfaction/setup@v1.16.1
      with:
        github_token: ${{ steps.tfaction-app-token.outputs.token }}
      env:
        TG_TF_FORWARD_STDOUT: "true"
    - name: terragrunt apply
      id: apply
      uses: suzuki-shunsuke/tfaction/apply@v1.16.1
      with:
        github_token: ${{ steps.tfaction-app-token.outputs.token }}
      env:
        TG_TF_FORWARD_STDOUT: "true"
        TG_LOG_LEVEL: warn # PR コメントに INFO ログが書かれていると邪魔になるため
    - name: create failure message
      if: failure()
      id: slack_failure_message
      run: |
        message=$(cat <<EOS
        *${{ github.actor }}* による *${{ github.ref_name }}* へのMergeによって *${{ matrix.target.target }}* への terragrunt applyが実行されました。
        :warning: Terragrunt Applyが失敗してエラーになりました :warning:
        <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|Workflow URL>
        EOS
        )

        {
          echo 'message<<EOS'
          echo "$message"
          echo EOS
        } >> "$GITHUB_OUTPUT"
    - name: create success message
      id: slack_success_message
      working-directory: ${{ matrix.target.working_directory }}
      env:
        TFPLAN_FILE_PATH: tfplan.binary
      run: |
        ls -al ./ # to debug
        TERRAGRUNT_CHANGES=$(terragrunt show -json "$TFPLAN_FILE_PATH" | jq '.applyable')

        if [ "$TERRAGRUNT_CHANGES" = 'false' ]; then
          message=$(cat <<EOS
        *${{ github.actor }}* による *${{ github.ref_name }}* Mergeによって *${{ matrix.target.target }}* への terragrunt apply による差分はありませんでした
        EOS
          )
        else
          message=$(cat <<EOS
        *${{ github.actor }}* による *${{ github.ref_name }}* Mergeによって *${{ matrix.target.target }}* への terragrunt apply が実行されました。
        正常に完了しました
        <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|Workflow URL>
        EOS
          )
        fi

        {
          echo 'message<<EOS'
          echo "$message"
          echo EOS
        } >> "$GITHUB_OUTPUT"
    - name: Send Slack notification
      if: always()
      uses: ./.github/actions/slack_custom_notification
      with:
        # 成功か失敗かどちらしかないので、片方は空白扱いで出力されるためこの書き方で問題ない
        message: ${{ steps.slack_success_message.outputs.message }}${{ steps.slack_failure_message.outputs.message }}
        slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL_DEV_TEAM_INFRA }}
.github/workflows/terragrunt_setup.yml
name: Terragrunt Setup

on:
  workflow_call:
    inputs:
      target_envs:
        required: true
        type: string
        description: 'plan/apply 対象の env。スペース区切りで複数指定可能'
    outputs:
      targets:
        description: '変更があった Terragrunt ルートモジュール'
        value: ${{ jobs.setup.outputs.targets }}
      modules:
        description: '変更があった Terragrunt 共有モジュール'
        value: ${{ jobs.setup.outputs.modules }}

jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      targets: ${{ steps.filtered-targets.outputs.targets }}
      modules: ${{ steps.list-targets.outputs.modules }}
    permissions:
      contents: read
      pull-requests: write # tfaction/list-targets で必要
    steps:
    - uses: actions/checkout@v4
    # tfaction の内部で使う aqua でインストールされたツールのキャッシュから復元
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 # renovate: datasource=github-releases depName=suzuki-shunsuke/tfaction packageName=suzuki-shunsuke/tfaction
      with:
        path: ~/.local/share/aquaproj-aqua
        # 自前で aqua.yaml を管理していないので tfaction_version をキャッシュキーに使用する
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    # tfaction の内部で使うaquaをインストール
    - uses: aquaproj/aqua-installer@v4.0.2
      with:
        aqua_version: v2.51.1 # renovate: datasource=github-releases depName=aquaproj/aqua packageName=aquaproj/aqua
    # tfaction の update_local_path_module_caller 機能で必要
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-release@v2.1.0
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.81.0 # renovate: datasource=github-releases depName=gruntwork-io/terragrunt packageName=gruntwork-io/terragrunt
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    # tfaction の update_local_path_module_caller 機能で必要
    - uses: hashicorp/setup-terraform@v3.1.2
      with:
        terraform_version: "1.12.2" # renovate: datasource=github-releases depName=hashicorp/terraform packageName=hashicorp/terraform
        terraform_wrapper: false # ラッパースクリプトと tfaction を併用すると意図しない挙動になるため
    # 変更のあった作業ディレクトリを取得する Action
    - uses: suzuki-shunsuke/tfaction/list-targets@v1.16.1
      id: list-targets
      with:
        github_token: ${{ github.token }}
    # 新しい Terragrunt のディレクトリ構成 (terragrunt/envs/{env}/) の場合は対象の env 以外スキップする
    - name: Exclude all but the target env
      id: filtered-targets
      env:
        targets: ${{ steps.list-targets.outputs.targets }}
        target_envs: ${{ inputs.target_envs }}
      run: |
        outputs='[]'

        for target_env in ${target_envs}; do
          # $targets: `[{ "workind_directory": "terragrunt/envs/production/account" }, { "workind_directory": "terragrunt/envs/staging/account" }]`
          # $filtered_targets: `[{ "workind_directory": "terragrunt/envs/production/account" }]`
          filtered_targets=$(echo "$targets" | jq -c "map(select(
            (.working_directory | startswith(\"terragrunt/envs/$target_env\"))
          ))")

          echo "filtered_targets: $filtered_targets"

          # 複数の環境の配列を結合する
          outputs=$(jq -n -c --argjson a "$outputs" --argjson b "$filtered_targets" '$a + $b')
        done

        echo "outputs: $outputs"
        echo "targets=$outputs" >> "$GITHUB_OUTPUT"
tfaction-root.yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/tfaction/refs/tags/v1.16.1/schema/tfaction-root.json

# .github/workflows/terragrunt_plan.yml の workflow 名
plan_workflow_name: Terragrunt Plan

# terragrunt/modules のみ変更時にモジュールの呼び出し元を CI を実行対象に含める
# See: https://suzuki-shunsuke.github.io/tfaction/docs/feature/local-path-module
update_local_path_module_caller:
  enabled: true

# global で terraform_command を指定すると test-module action 内の terragrunt fmt 実行時、
# terragrunt/modules/** に terragrunt.hcl がなくエラーになるため、target_groups 内で個別に指定する
#
# terraform_command: terragrunt

tflint:
  enabled: true
  fix: true

# デフォルト true のため、必要になるまで一旦無効化
trivy:
  enabled: false

providers_lock_opts: -platform=linux_amd64 -platform=linux_arm64 -platform=darwin_arm64

target_groups:
target_groups:
- working_directory: terragrunt/envs/production/xxxx
  target: production/xxxx
  terraform_command: terragrunt
- working_directory: terragrunt/envs/staging/xxxx
  target: staging/xxxx
  terraform_command: terragrunt
- working_directory: terragrunt/envs/review/xxxx
  target: review/xxx
  terraform_command: terragrunt
# ...

今後の方針

今回導入した Action 以外にも、tfaction には様々な機能が提供されています。

  • ドリフト検出
  • apply 失敗時のフォローアップ PR の自動作成
  • ワーキングディレクトリ、モジュールなどの scaffold
  • Conftest サポート
  • tfsec, trivy の実行
  • terraform-docs の実行

今回紹介したように、tfaction は段階的・部分的な導入ができるため、これらすべての機能を使う必要はありませんが、社内の需要に応じて導入を進めていければと思います。

特にドリフト検出は Terraform の CI/CD を組む上でのリスクを減らせるため、積極的に導入したいと考えています。

まとめ

今回社内の課題を解決するのに tfaction がピッタリハマりました。

Terraform の CI/CD でお困りの方は部分的にでも tfaction を導入してみると、課題に対して必要最小限の実装・労力で済むため、非常におすすめです。

tfaction の良いところは小さく導入できるところだと思っています。他の Terraform CI/CD ツールだと割と組み込むのが大掛かりになったり、既存の開発フローを大きく変える必要があると思います。

脚注
  1. ベースブランチが master の PR で production と review 環境への plan を実行したいという目的がありました。 ↩︎

Social PLUS Tech Blog

Discussion