🦅

tfactionを導入してみた

2025/01/01に公開
4

tfactionとは

高度なTerraformのCI/CDをGitHub Actionsで簡単に実現できるActionです。
TerraformのCI/CDを組むにあたって欲しい機能が多く搭載されており、OSSのActionを会社のセキュリティポリシーで使えないとかがない限り、個人的にはこれを使用しないという選択肢がない位、非常におすすめなActionです。

詳しくは、開発者であるShunsuke Suzuki氏のブログを参照下さい。
https://zenn.dev/shunsuke_suzuki/articles/tfaction-introduction

導入目標

以下機能を使えること

  • Support Monorepo with GitHub Actions build matrix
    • ワークフロー設定ファイルを各ルートモジュール共通で管理しつつ、変更があったルートモジュールのみCI/CDを実行できる機能
      • tfactionを使用せず、ワークフローの発火条件でパスフィルターを設定する方法もあるが、各ルートモジュール毎にワークフロー設定ファイルが必要で冗長
  • Notify the result of terraform plan and apply with tfcmt
    • プルリクエストのコメントにplanとapplyの結果を通知する機能
  • Apply safely with Terraform Plan File
    • CIで実行されたplanの内容のみをCDで実行されるapplyで適用する機能
      • CI(プルリクエストの作成)とCD(プルリクエストのマージ)がノンストップで行えれば良いが、CIとCDの間にはレビューが挟まるので、基本的に間が開いてしまう。その間に別経路(手動変更など)からリモートが変更されることが偶にあり、作業ブランチの最新コードをそのままapplyしてしまうと、CIで実行されたplanとは違う内容のapplyが適用される危険があるが、この機能はそれを防いでくれる
  • Automatically update related pull requests when the remote state is updated
    • プルリクエストのマージ後、関連する未マージのプルリクエストのCIを自動再実行する機能
      • プルリクエストがパラレルで複数作成されている時、あるプルリクエストがマージされるとtfstateの状態が更新されるので、関連する未マージのプルリクエストで再度CIを実行する必要があるが、それを自動で行ってくれる
  • Create a pull request automatically to handle the problem when apply failed
    • CD(apply)が失敗した時、それをフォローアップするプルリクエストを自動作成する機能
      • CD(apply)が失敗している状態は、コードとtfstateで差分が出ている状態なので、いち早く修正のプルリクエストを再作成する必要あるが、それを自動かつメンションを飛ばして「早く直せ」とケツを叩いてくれるのが良い
  • Manage Terraform Modules
    • 子モジュール上でのリンターや自動フォーマッタを実行する機能
  • Auto Fix .terraform.lock.hcl and Terraform Configuration
    • .terraform.lock.hclの差分のコミット、Terraform fmtの実行/コミットを自動実行する機能
  • Linters
    • プルリクエストのコメントにtflint、trivyなどのリンターの結果を通知する機能
  • Run CI on working directories that depend on a updated local path Module
    • 子モジュールがローカル管理(ルートモジュールと同じレポジトリで管理)の場合、必要な機能

以下ディレクトリ構成で動くこと

├ environments
    ├ dev
        ├ init
            ├ main.tf
            ├ providers.tf
            ├ variables.tf
        ├ main
            ├ backend.tf
            ├ main.tf
            ├ providers.tf
            ├ variables.tf
    ├ stg
        ├ init
        ├ main
    ├ prd
        ├ init
        ├ main
├ modules
    ├ sample
        ├ main.tf
        ├ output.tf
        ├ variables.tf   
  • ルートモジュールと子モジュールは同一レポジトリで管理
  • 環境毎にルートモジュールを分割
  • 環境毎のディレクトリ配下に以下ディレクトリを作成
    • init
      • GitHub Actions、tfactionに必要なリソース、tfstateを保存するS3バケットを管理
      • CI/CDの実行対象からは除外する
    • main
      • 環境毎のリソースを管理
      • CI/CDの実行対象

導入手順

前提

  • Terraformの実行対象はAWSを前提とする
  • Terraform実行ロールの使い方やディレクトリ構成などは、筆者の宗教的な考え

GitHubレポジトリの作成、クローン

レポジトリのタイプはパブリック or プライベートのどちらでも良いが、プライベートの場合は無料でGitHub Actionsを使える枠(時間)に制限があるので、注意。

GitHub Appの作成、インストール

tfactionのドキュメントとGitHubのドキュメントを参考にtfaction用のGitHub Appを作成し、1で作成したレポジトリにインストールする。

本導入手順では実施しないが、tfactionは各アクション毎に異なる権限を必要とするので、各アクションの前段のStepで権限を指定してToken発行することで、最小権限の原則に従うことも可能。

GitHub AppのAppIDとPrivate Keyをレポジトリシークレットに登録

シークレット名はここではTFACTION_GITHUB_APP_IDTFACTION_GITHUB_APP_PRIVATE_KEYとする。

コードの作成

サンプルコードをベースに、1で作成したレポジトリのローカルにコードを作成する。
アカウントIDやロール名などは、適宜設定する。

https://github.com/falcon-tech/tfaction-sample

設定ファイル解説

tfaction-root.yaml

https://github.com/falcon-tech/tfaction-sample/blob/main/tfaction-root.yaml

  • plan_workflow_name
    • GitHub Actionsのワークフロー設定ファイルで指定するCIのワークフロー名を設定する
  • update_local_path_module_caller
    • 子モジュールがローカル管理の場合はtrue
  • tflint
    • CIでtflintを実行する場合、enabledはtrue
    • 自動修正を実行する場合、fixはtrue
  • trivy
    • CIでtrivyを実行する場合、enabledはtrue
  • working_directory
    • CI/CDの実行対象のディレクトリ(ルートモジュール)のパスを相対パスで設定
  • target
    • working_directoryのalias名を設定
      • ここでは環境名を設定
  • aws_region
    • リージョンを設定
      • tfstate保存用のS3のリージョンを設定しとけば、取り敢えずは良いと思う
      • 設定必須なパラメータだが、ここで設定したリージョンにしかリソースを作成できないなどの制約は無い
  • terraform_plan_config/terraform_apply_config
    • GitHub ActionからAssumeするロールのARNを指定
      • ここではOIDCGitHubIaCRoleを設定

environments/${env}/tfaction.yaml

中身は空({})で良いが、CI/CDの実行対象のルートモジュール配下への配置は必須。
空ファイルだとtfactionがコケるので注意。

https://github.com/falcon-tech/tfaction-sample/blob/main/environments/dev/main/tfaction.yaml

tfaction-root.yamlの設定値をオーバライドしたい時に中身を記載する。
https://suzuki-shunsuke.github.io/tfaction/docs/config/tfaction-yaml

module/sample/tfaction_module.yaml

中身は空({})で良いが、CIの実行対象の子モジュール配下への配置は必須。
空ファイルだとtfactionがコケるので注意。

https://github.com/falcon-tech/tfaction-sample/blob/main/modules/sample/tfaction_module.yaml

aqua.yaml

tfactionで必要なツールはaquaでインストールされるので、そのツールとバージョンをここで指定。

https://github.com/falcon-tech/tfaction-sample/blob/main/aqua.yaml

tfactionの各action毎に必要なツールが異なり、このファイルで明示的な指定が必要なツールもあれば、そうでない(tfcmtとか)ツールがある。

明示的な指定が不要なツールの場合でも、このファイルで明示的な指定をすれば、指定したバージョンで固定できる。

明示的な指定が不要なツール一覧は以下を参照。

https://github.com/suzuki-shunsuke/tfaction/tree/main/install/aqua

trivy.yaml

trivyの設定を記載する。

https://github.com/falcon-tech/tfaction-sample/blob/main/trivy.yaml

ルートモジュール毎、子モジュール毎に設定を分ける場合は、それぞれの配下に配置する。
共通で良い場合は、レポジトリのルートに配置で良い。
ここでは、ルートに配置。

.trivyignore

trivyで無視するAVDを記載する。

https://github.com/falcon-tech/tfaction-sample/blob/main/.trivyignore

ルートモジュール毎、子モジュール毎に設定を分ける場合は、それぞれの配下に配置する。
共通で良い場合は、レポジトリのルートに配置で良い。
ここでは、ルートに配置。

environments/.tflint.hcl、module/.tflint.hcl

tflintの設定を記載する。

https://github.com/falcon-tech/tfaction-sample/blob/main/environments/.tflint.hcl

https://github.com/falcon-tech/tfaction-sample/blob/main/modules/.tflint.hcl

ルートモジュール毎、子モジュール毎に設定を分ける場合は、それぞれの配下に配置する。
共通で良い場合は、レポジトリのルートに配置で良い。
ここでは、environmentsとmodules配下に配置。

.github/workflows/ci.yaml

name

tfaction-root.yamlで設定したplan_workflow_nameの値と合わせる。

name: CI
on

mainブランチへのプルリクエストで発火。

on:
  pull_request:
    branches: main
concurrency

ワークフローの多重起動の抑制。

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
permissions

最小権限の原則に従う場合は、ジョブ毎に設定する必要があるが、ここではワークフロー共通で設定。

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

ワークフロー共通の環境変数を設定。
TRIVY用の設定ファイルはルートモジュールと子モジュールで共通なので、ここで設定。

env: 
  TRIVY_CONFIG: ${{ github.workspace }}/trivy.yaml
  TRIVY_IGNOREFILE: ${{ github.workspace }}/.trivyignore
job(Set up)

セットアップジョブ。

Checkoutで作業ブランチの最新のコードを取得。
Install toolsでaqua.yamlに明示したツールをインストール。
Get changed working directoryで変更のあったルートディレクトリ、子モジュールを取得。

jobs:
  setup:
    name: Set up
    runs-on: ubuntu-latest
    timeout-minutes: 5
    defaults:
      run:
        shell: bash
    outputs:
      targets: ${{ steps.list-targets.outputs.targets }}
      modules: ${{ steps.list-targets.outputs.modules }}
    steps:
    - name: Checkout
      uses: actions/checkout@v4
 
    - name: Install tools
      uses: aquaproj/[email protected]
      with:
        aqua_version: v2.40.0      
    
    - name: Get changed working directory
      uses: suzuki-shunsuke/tfaction/[email protected]
      id: list-targets
job(Test-module)

子モジュールのCIジョブ。

変更があった子モジュール毎にパラレルで実行。

どの子モジュールにも変更がない場合は、ifでスキップする。

envでtflintの子モジュール用の設定ファイルのパスを指定。

Checkoutで作業ブランチの最新のコードを取得。
Install toolsでaqua.yamlに明示したツールをインストール。
Generate tokenでGitHub Appからトークンを取得。
Test(module)で、子モジュールに対するtrivy、tflint、自動フォーマッタ、terraform-docsによるREADMEの作成などを実行。

  test-module:
    name: Test module (${{ matrix.target }})
    needs: setup    
    if: join(fromJSON(needs.setup.outputs.modules), '') != ''    
    runs-on: ubuntu-latest
    timeout-minutes: 5
    defaults:
      run:
        shell: bash
    env:
      TFACTION_TARGET: ${{ matrix.target }}
      TFLINT_CONFIG_FILE: ${{ github.workspace }}/modules/.tflint.hcl
    strategy:
      fail-fast: true
      matrix:
        target: ${{ fromJSON(needs.setup.outputs.modules) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install tools
        uses: aquaproj/[email protected]
        with:
          aqua_version: v2.40.0

      - name: Generate token
        id: generate_token
        uses: tibdex/[email protected]
        with:
          app_id: ${{ secrets.TFACTION_GITHUB_APP_ID }}
          private_key: ${{ secrets.TFACTION_GITHUB_APP_PRIVATE_KEY }}

      - name: Test(module)
        uses: suzuki-shunsuke/tfaction/[email protected]
        with:
          github_token: ${{ steps.generate_token.outputs.token }}
job(Plan)

ルートモジュールのCIジョブ。

変更があったルートモジュール毎にパラレルで実行。

どのルートモジュールにも変更がない場合は、ifでスキップする。
ルートモジュールで呼び出している子モジュールに変更があった場合は、ルートモジュールの変更として認識される(update_local_path_module_callerがtrueの場合)。

envでtflintのルートモジュール用の設定ファイルのパスを指定。

Checkoutで作業ブランチの最新のコードを取得。
Install toolsでaqua.yamlに明示したツールをインストール。
Generate tokenでGitHub Appからトークンを取得。
Set upでTerraform init等々の初期設定を実行。
Testで、ルートモジュールに対するtrivy、tflint、自動フォーマッタなどを実行。
Planで、planの実行、プルリクエストのコメントへ結果の通知、GitHub Actionsのアーティファクトにplan結果のバイナリーの保存(applyで使用)などを実行。

  plan:
    name: Plan (${{ matrix.target.target }})
    needs: setup    
    if: join(fromJSON(needs.setup.outputs.targets), '') != ''    
    runs-on: ubuntu-latest
    timeout-minutes: 5
    defaults:
      run:
        shell: bash
    env:
      TFACTION_TARGET: ${{ matrix.target.target }}
      TFACTION_WORKING_DIR: ${{ matrix.target.working_directory }}
      TFACTION_JOB_TYPE: ${{ matrix.target.job_type }}
      TFLINT_CONFIG_FILE: ${{ github.workspace }}/environments/.tflint.hcl
    strategy:
      fail-fast: true
      matrix:
        target: ${{ fromJSON(needs.setup.outputs.targets) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
  
      - name: Install tools
        uses: aquaproj/[email protected]
        with:
          aqua_version: v2.40.0

      - name: Generate token
        id: generate_token
        uses: tibdex/[email protected]
        with:
          app_id: ${{ secrets.TFACTION_GITHUB_APP_ID }}
          private_key: ${{ secrets.TFACTION_GITHUB_APP_PRIVATE_KEY }}

      - name: Set up
        uses: suzuki-shunsuke/tfaction/[email protected]
        with:
          github_token: ${{ steps.generate_token.outputs.token }}
        env:
          GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}

      - name: Test
        uses: suzuki-shunsuke/tfaction/[email protected]
        with:
          github_token: ${{ steps.generate_token.outputs.token }}
        env:
          GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}

      - name: Plan
        uses: suzuki-shunsuke/tfaction/[email protected]
        with:
          github_token: ${{ steps.generate_token.outputs.token }}
        env:
          GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}

.github/workflows/cd.yaml

name

好きな名前で良い。

name: CD
on

mainブランチへのプルリクエストのクローズで発火。

on:
  pull_request:
    branches: main
    types: closed
concurrency

ワークフローの多重起動の抑制。

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
permissions

最小権限の原則に従う場合は、ジョブ毎に設定する必要があるが、ここではワークフロー共通で設定。

permissions:
  id-token: write
  contents: read
  pull-requests: write
  actions: read
job(Set up)

セットアップジョブ。

ワークフロー自体はプルリクエストのクローズで発火するので、マージによるクローズ以外は、ifでスキップする。

Checkoutで作業ブランチの最新のコードを取得。
Install toolsでaqua.yamlに明示したツールをインストール。
Get changed working directoryで変更のあったルートディレクトリ、子モジュールを取得。

jobs:
  setup:
    name: Set up
    if: ${{ github.event.pull_request.merged }} == true
    runs-on: ubuntu-latest
    timeout-minutes: 5
    defaults:
      run:
        shell: bash
    outputs:
      targets: ${{ steps.list-targets.outputs.targets }}
    steps:
    - name: Checkout
      uses: actions/checkout@v4
 
    - name: Install tools
      uses: aquaproj/[email protected]
      with:
        aqua_version: v2.40.0      
    
    - name: Get changed working directory
      uses: suzuki-shunsuke/tfaction/[email protected]
      id: list-targets
job(apply)

ルートモジュールのCDジョブ。

変更があったルートモジュール毎にパラレルで実行。

どのルートモジュールにも変更がない場合は、ifでスキップする。
ルートモジュールで呼び出している子モジュールに変更があった場合は、ルートモジュールの変更として認識される(update_local_path_module_callerがtrueの場合)。

Checkoutで作業ブランチの最新のコードを取得。
Install toolsでaqua.yamlに明示したツールをインストール。
Generate tokenでGitHub Appからトークンを取得。
Set upでTerraform init等々の初期設定を実行。
Applyで、applyの実行、プルリクエストのコメントへ結果の通知を実行。
Follow up PRで、関連するプルリクエストのCIの再実行をトリガー。

  apply:
    name: Apply (${{ matrix.target.target }})
    needs: setup    
    if: join(fromJSON(needs.setup.outputs.targets), '') != ''    
    runs-on: ubuntu-latest
    timeout-minutes: 5
    defaults:
      run:
        shell: bash
    env:
      TFACTION_TARGET: ${{ matrix.target.target }}
      TFACTION_WORKING_DIR: ${{ matrix.target.working_directory }}
      TFACTION_JOB_TYPE: ${{ matrix.target.job_type }}
      TFACTION_IS_APPLY: true
    strategy:
      fail-fast: true
      matrix:
        target: ${{ fromJSON(needs.setup.outputs.targets) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
  
      - name: Install tools
        uses: aquaproj/[email protected]
        with:
          aqua_version: v2.40.0

      - name: Generate token
        id: generate_token
        uses: tibdex/[email protected]
        with:
          app_id: ${{ secrets.TFACTION_GITHUB_APP_ID }}
          private_key: ${{ secrets.TFACTION_GITHUB_APP_PRIVATE_KEY }}

      - name: Set up
        uses: suzuki-shunsuke/tfaction/[email protected]
        with:
          github_token: ${{ steps.generate_token.outputs.token }}
        env:
          GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}

      - name: Apply
        uses: suzuki-shunsuke/tfaction/[email protected]
        with:
          github_token: ${{ steps.generate_token.outputs.token }}

      - name: Follow up PR
        uses: suzuki-shunsuke/tfaction/[email protected]
        if: failure()
        with:
          github_token: ${{ steps.generate_token.outputs.token }}

GitHub Actions、tfactionに必要なリソース、tfstateを保存するS3バケットを作成

各環境のルートモジュール配下のinitのmain.tfで定義されている以下リソースを作成する(ローカルでapplyを実行する)。

  • tfstate保存用のS3
  • OIDC用のIAM IDプロバイダ
  • OIDC用のIAM ロール
  • Terraform実行ロール

initのtfstateはローカルで管理する。

https://github.com/falcon-tech/tfaction-sample/blob/main/environments/dev/init/main.tf

各環境が同一AWSアカウントの場合は「tfstate保存用のS3」以外は、環境共通(どこかの環境で一つ作成したら、他の環境ではコメントアウト)とする。

OIDC用のIAMロールとTerraform実行用のロールを分けている理由は、ローカル経由とCI/CD経由のplan/applyで使うIAMロールを揃えたいから。

OIDC用のIAMロールの権限は、Terraform実行用ロールへのAssumeRoleとtfstate保存用のS3に対するGetとPutのみを許可する。

tfstate保存用のS3にtfstateをPushする

v1.14.0時点では、tfstate保存用のS3が空だとtfactionがコケるようなので、各環境のルートモジュール配下のmainのmain.tfで定義されているサンプルのS3を作成(ローカルでapplyを実行する)し、tfstate保存用のS3にtfstateをPushする。

https://github.com/falcon-tech/tfaction-sample/blob/main/environments/dev/main/main.tf

S3名が重複する場合は、local.systemを適当な値に変更する。

plan/applyで差分が出るように、コードに変更を加える

例えば、サンプルのS3のタグ(Flag)のtrue/falseを変更するなど。

作業ブランチを作成し、ローカルの変更をコミット&Pushする

作業ブランチ → mainブランチのプルリクエストを作成する

CIが発火し、plan結果がプルリクエストにコメントされる。

trivyのスキャンに引っかかった場合は、検知したAVDがプルリクエストにコメントされる(CIは失敗扱い)。

tflintのスキャンに引っかかった場合は、修正のコミットが自動で行われる。

フォーマッタによる差分が発生した場合は、修正のコミットが自動で行われる。

プルリクエストをマージする

CDが発火し、apply結果がプルリクエストにコメントされる。

CD(apply)が失敗した場合は、フォーローアップのプルリクエストが自動で作成される。

おまけ

コードの変更無しでCI/CDを発火させる方法

tfactionはコードに変更があったルートモジュール、子モジュールを検出し、CI/CDの対象としている為、コードに変更がないプルリクエストではCI/CDが発火しない(ワークフローは発火するが、セットアップ以降のジョブがスキップされる)が、ラベルにtarget:${target}を付与したプルリクエストを作成すると、コードの変更無しでCI/CDを発火させることが可能。

ラベルの${target}は、tfaction-root.yamlで設定したworking_directoryのalias名(target)を設定する。

手順の流れは以下の通り。

作業ブランチを作成
↓
空コミット&Push
↓
target:${target}のラベルを付与したプルリクエストを作成

Discussion

Shunsuke SuzukiShunsuke Suzuki

tfaction の紹介記事ありがとうございます!

tfactionの各action毎に必要なツールが異なり、このファイルで明示的な指定が必要なツールもあれば、そうでない(tfcmtとか)ツールもあるので、2025/1月時点だとトライ&エラーで探るしかない。
明示的な指定が不要なツールの場合でも、このファイルで明示的な指定をすれば、指定したバージョンで固定してくれる(と思っている)。

こちらについて補足させてください。
明示的なインストールが不要なものはこちらで管理されています。
https://github.com/suzuki-shunsuke/tfaction/tree/main/install/aqua

この install という action が setup action などで内部的に呼ばれて一部のツールがインストールされています。
これが導入されたのは v1.12.0 なので古いバージョンを使っている場合は全て明示的にインストールする必要があります。

https://github.com/suzuki-shunsuke/tfaction/releases/tag/v1.12.0

明示的な指定が不要なツールの場合でも、このファイルで明示的な指定をすれば、指定したバージョンで固定してくれる(と思っている)。

これは現状 YES です。
install action の aqua.yaml は AQUA_GLOBAL_CONFIG という aqua の設定に追加されますが、
これは AQUA_CONFIG やファイルのパスを探索して発見された aqua.yaml よりも優先順位が下なので、リポジトリに aqua.yaml を置いて明示的に管理すればそちらが優先されます。

ハヤブサハヤブサ

コメントありがとうございます!
補足頂いた箇所、記事に反映させて貰いましたm

Shunsuke SuzukiShunsuke Suzuki

フリーアカウントの場合は、パブリックで作成すること(GitHub Actionsがプライベートだと使えないので)。

正確に言うと、実行時間に制限があるだけで使えるかと思います。

https://github.co.jp/pricing.html

tfactionは各アクション毎に異なるGitHub権限を必要とするので、最小権限の原則に従う場合は、各アクション毎にGitHub Appを作成する必要があるが、ここでは必要な権限を全て付与した単一のGitHubAppを作成する。

最小権限に従う場合でも app を使って token を発行する際に権限を指定できるので action 毎に token を発行してあげればよいかと思います。

tfaction-example でも実際に用途ごとに発行しています。
まぁここまで細かく分けるかはお任せしますが。

https://github.com/suzuki-shunsuke/tfaction-example/blob/12dbca456d7d6c11a09dc47177389b2aac56c720/.github/workflows/apply.yaml#L51-L66
https://github.com/suzuki-shunsuke/tfaction-example/blob/12dbca456d7d6c11a09dc47177389b2aac56c720/.github/workflows/apply.yaml#L133-L149

ハヤブサハヤブサ

コメントありがとうございます!
補足頂いた箇所、記事に反映させて貰いましたm