Terraformをモノレポ化した話 — tfactionを使ったプロダクト横断IaC運用の実践

こんにちは、P2O(X: @hitsumabushi845)です。昨年11月から estie で SRE をしています。
inside blog では初めての投稿となります。

estie では、社外向け・社内向け問わず数多くのプロダクトが稼働しており、それらのインフラ全てを Terraform で管理しています。
Terraform で管理される数多くのインフラをどのように整理・運用するかは、各組織で様々な方法がとられていることかと思います。

estie では、tfaction を活用することで大量のプロダクトの IaC を纏めたモノレポを作成しました。
本記事ではその内容について紹介します。

背景

estie では、もともとアプリケーションのコードと Terraform HCL による IaC を纏めた「プロダクトとしての」モノレポ構成でリポジトリを作成していました。

これはプロダクトの断面で開発を追いかける場合には有用ですが、プロダクト全体として IaC を管理・運用する際には少し不便です。

プロダクト全体で利用する共通の設定やリソースについては Terraform Module を作成し、それを各プロダクトの Terraform から利用しています。
しかし、全プロダクトに新たに共通の設定を配る際や、モジュールの更新を行った際には、それぞれのリポジトリに対して PR を作成する必要があり、非常に手間がかかってしまいます。

また、terraform plan/apply を行う GitHub Actions のワークフローが各リポジトリ内で組まれています。
Reusable Workflow を活用しメンテナンスコストを抑えていますが、ワークフローに修正や改善が入った場合は Reusable Workflow のバージョンを変更する PR を各リポジトリに作成しなければなりません。

estie では 10 を超えるプロダクトを顧客向けに開発しています。さらに、社内向けツールやミドルウェアとして利用されるプロダクトも併せると、その数は数十に及びます。
ここまで説明した IaC の管理・運用コストは、estie のプロダクトが増えていくほどに増大していきます。

これらの背景から、Terraform モノレポ化プロジェクトが立ち上がりました。

Terraform をどうモノレポに集約するのか

大量のプロダクトの IaC を集約するということは、プロダクト数 × 環境数 からなる大量の tfstate を単一のリポジトリ内で管理するということです。
GitHub Actions だけで CI を組む場合、素直に組むとすぐに path 設定が肥大化したり、.github/workflows 配下に大量の YAML ファイルが誕生してしまいます。これでは可読性やメンテナンス性が損なわれるため、こういったことが起きないような仕組みを検討する必要があります。

以下3パターンを検討し、最終的に tfaction を採用してモノレポを構築することとしました。

1 とにかく GitHub Actions で頑張る

  • 既存の Reusable Workflow 等を活用できるが、ワークフローのメンテナンス性が損なわれるおそれがある

2 Terragrunt を利用する

  • モノレポ向けの機能も提供されているが、効率的な Module 運用などその他のメリットの方が大きく今回の目的からは逸れる部分も多い

3 tfaction を利用する

  • GitHub Actions 向けのアクションの集合であり、モノレポの構築を目的として製作されている
  • Terragrunt との併用もサポートされているため、将来的に併用することも可能
tfaction

tfaction は Terraform モノレポにおける CI/CD を便利にする GitHub Actions のアクションを多数備えたセットです。

suzuki-shunsuke.github.io

複数の apply 対象をもつリポジトリにおいて、PR に含まれる差分から動的に terraform plan/apply の実行対象を計算し、必要なディレクトリに対してのみ plan/apply が実行されます。

terraform validate や tfsec/trivy/tflint/conftest の実行もサポートされており、Terraform の各種操作を実行する場合に必要なものが一通り揃っています。

モノレポの構築

あるプロダクトにおいて、Terraform 関連のコードは以下のディレクトリに含まれています。
プロダクトごとに細かな差異はありますが、基本的な構成としてはおおむねこの構成に即しています。

<product-repository-root>
├ infra/
│ ├ envs/
│ │ ├ prd/
│ │ │ ├ main.tf        # modules/ 全体を呼び出す
│ │ │ ├ variables.tf
│ │ │ ├ outputs.tf
│ │ │ └ versions.tf
│ │ └ stg/
│ └ modules/           # プロダクト固有のモジュール
│   ├ moduleA/         # サブモジュール A
│   ├ moduleB/         # サブモジュール B
│   ├ main.tf          # サブモジュール等を統合した main モジュール
│   ├ variables.tf
│   └ versions.tf
├ frontend/            # フロントエンドのコード
└ backend/             # バックエンドのコード

モノレポ化においては、複数のプロダクトに存在するこれらのコードを1つのリポジトリに集約します。
tfaction のサンプルリポジトリを参考にしつつ、以下のようにモノレポを構成しました。

tfaction のサンプルリポジトリはこちらです。

github.com

<terraform-monorepo-root>
├ .github/workflows/
│ ├ terraform-plan.yaml                     # PR 上で動作する plan workflow
│ ├ terraform-apply.yaml                    # PR 上で動作する apply workflow
│ ├ tag-based-terraform-apply.yaml          # tag 作成をトリガーに実行される apply workflow(後述)
│ ├ workflow-dispatch-terraform-apply.yaml  # workflow_dispatch で実行される apply workflow(後述)
│ ├ wc-plan-single-target.yaml              # tag/workflow_dispatch で実行する際に使用する sub workflow
│ └ wc-apply-single-target.yaml             # tag/workflow_dispatch で実行する際に使用する sub workflow
├ modules/                                  # 全プロダクトから利用できる共通モジュール
│ ├ aws/network/
│ ├ ...
│ └ xxx/yyy/
├ terraform/
│ ├ <product-1>/
│ │ ├ envs/
│ │ │ ├ prd/
│ │ │ │ ├ tfaction.yaml # <product-1>/envs/prd 配下で利用する tfaction の設定値(AssumeRole ARN など)
│ │ │ │ ├ aqua.yaml     # このディレクトリ配下で利用するツールの指定(共通設定のままで良ければ特に変更する必要なし)
│ │ │ │ ├ main.tf       # 実際の terraform コード
│ │ │ │ ├ variables.tf  # 同上
│ │ │ │ ├ outputs.tf    # 同上
│ │ │ │ └ versions.tf   # 同上
│ │ │ └ stg/
│ │ └ modules/             # プロダクト固有のモジュール
│ ├ <product-2>/
│ │ └ ...
│ └ <product-x>/
├ aqua.yaml             # リポジトリ全体で共通利用するツール群の定義
└ tfaction-root.yaml    # tfaction 全体の設定値

基本的には、terraform/ ディレクトリ配下に各プロダクトのインフラコードをそのまま移設し、そこへ aqua.yamltfaction.yaml といった tfaction の構成に必要なファイルを配置した形となります。
tfaction では、tfaction.yaml が配置されているディレクトリが実行対象となります。

tfaction/aqua 設定ファイルの作成

ディレクトリ構成としては上記で紹介した通りで、ここからは tfaction と aqua の設定にまつわるファイルの内容について紹介します。

tfaction-root.yaml

tfaction 全体の設定を行うファイルです。通常リポジトリルートに配置されます。

# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/tfaction/refs/heads/latest/schema/tfaction-root.json
plan_workflow_name: Terraform Plan

terraform_command: terraform

# ローカルパスモジュールが更新された時に、それを呼び出しているディレクトリも実行対象とする
update_local_path_module_caller:
  enabled: true

# renovate による PR では terraform の実行をしない
skip_terraform_by_renovate: true

aqua:
  update_checksum:
    # Update aqua-checksums.json in `setup` action
    enabled: true # default is false
    skip_push: false # default is false
    prune: true # default is false

tflint:
  enabled: true

target_groups:
  - working_directory: terraform/<product-A>/envs
    target: product-A
  - working_directory: terraform/<product-B>/envs
    target: product-B

主要な設定としては以下の設定を行っています。

  • update_local_path_module_caller.enabled
    • modules/ に配置している共通モジュールを更新した際、それを呼び出しているディレクトリもまた terraform plan/apply の実行対象とするため
  • skip_terraform_by_renovate
    • renovate が aqua 周りのバージョンを更新した場合、全てのディレクトリが実行対象となってしまい、大量のジョブが実行されてしまうため、それを抑制する設定を入れています。
  • tflint.enabled
    • tflint を有効にしています。
    • なお、tfsec.enabledtrivy.enabledでそれぞれ同様に有効化できます。
  • target_groups
    • tfaction の実行対象を定義するとともに、プロダクト単位でグループ化しています。
    • プロダクト単位で指定していることで、まだ plan や apply を自動実行したくない作業中のプロダクトのディレクトリはここに記載しないことで実行を抑制できます。
tfaction.yaml

各ディレクトリに配置される、Terraform 実行対象ディレクトリ個別の設定ファイルです。

# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/tfaction/refs/heads/latest/schema/tfaction.json
aws_region: ap-northeast-1
terraform_plan_config:
  aws_assume_role_arn: arn:aws:iam::<Account Number>:role/terraform-plan-role
terraform_apply_config:
  aws_assume_role_arn: arn:aws:iam::<Account Number>:role/terraform-apply-role

ここでは、以下の設定を行っています。

  • terraform_plan_config.aws_assume_role_arn
    • terraform plan 時に利用される IAM Role で、環境ごとに異なる Role を指定しています。
  • terraform_apply_config.aws_assume_role_arn
    • terraform apply 時に利用される IAM Role で、環境ごとに異なる Role を指定しています。

基本的には plan/apply の実行に使う IAM Role や、Google Cloud の場合は Workload Identity provider や Service Account の設定を行うことが中心となるかと思います。

aqua.yaml(リポジトリルート)

tfaction では、利用するツールのバージョン管理に aqua を利用しています。そのため、CI から利用される全てのツールや CLI は aqua を通じてインストールされます。

リポジトリルートに配置する aqua.yaml は次のように設定しています。tfaction から利用されるツールや、terraform や tflint 等のツールを定義しています。

ここで定義されたバージョンが基本的には利用されますが、ディレクトリ単位で個別に異なるバージョンを指定することも可能です。

---
# aqua - Declarative CLI Version Manager
# https://aquaproj.github.io/
registries:
  - type: standard
    ref: v4.46.0 # renovate: depName=aquaproj/aqua-registry
packages:
  - name: suzuki-shunsuke/github-comment@v6.4.1
  - name: suzuki-shunsuke/ci-info@v2.4.2
  - name: int128/ghcp@v1.15.1
  - name: suzuki-shunsuke/tfcmt@v4.14.13
  - name: reviewdog/reviewdog@v0.21.0
  - name: hashicorp/terraform-config-inspect
    version: d2d12f9a63bbb1fe34e81ec84c9cb11d8514c17
  - name: hashicorp/terraform@v1.14.3
  - name: terraform-linters/tflint@v0.60.0
  - name: aquasecurity/trivy@v0.68.2
aqua.yaml(各実行対象ディレクトリ)

各実行対象ディレクトリ配下に配置される aqua.yaml です。リポジトリルートで定義されたバージョンを利用する場合は、個別のツール指定は不要で、以下のような内容となります。

---
# aqua - Declarative CLI Version Manager
# https://aquaproj.github.io/
checksum:
  enabled: true
  require_checksum: true
registries:
  - type: standard
    ref: v4.46.0 # renovate: depName=aquaproj/aqua-registry

「このディレクトリ配下では異なる Terraform バージョンを利用する」といった場合は、以下のように設定を行います。ここで指定されていないツールに関しては、引き続きルートの設定が利用されます。

---
# aqua - Declarative CLI Version Manager
# https://aquaproj.github.io/
checksum:
  enabled: true
  require_checksum: true
registries:
  - type: standard
    ref: v4.46.0 # renovate: depName=aquaproj/aqua-registry
packages:
  - name: hashicorp/terraform@1.11.4

GitHub Actions ワークフローの作成

ここからは、実際に plan/apply を行うワークフローを紹介します。

実際に使用しているワークフローファイルから削除・マスクしている部分もありますが、概ねこの内容で動いています。

plan ワークフロー
name: Terraform Plan

on:
  pull_request:
    branches:
      - main

# lock file 等の複数更新による多重実行を抑制
concurrency:
  group: "${{ github.workflow }}-${{ github.ref }}"
  cancel-in-progress: true

jobs:
  # 変更のあった Terraform ディレクトリを取得する
  fetch-targets:
    uses: ./.github/workflows/wc-fetch-targets.yaml
    secrets:
      app_id: ${{ secrets.APP_ID }}
      app_private_key: ${{ secrets.TOKEN_GENERATOR_APP_PRIVATE_KEY }}

  plan:
    name: "Terraform plan (${{ matrix.target.target }})"
    needs: fetch-targets
    # 変更のある作業ディレクトリがない場合は実行しない
    if: join(fromJSON(needs.fetch-targets.outputs.targets), '') != ''

    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.fetch-targets.outputs.targets) }}

    env:
      TFACTION_TARGET: ${{ matrix.target.target }}
      TFACTION_WORKING_DIR: ${{ matrix.target.working_directory }}
      TFACTION_JOB_TYPE: terraform

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

    steps:
      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1

      - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
        id: create_token
        with:
          app-id: ${{ secrets.APP_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}
          repositories: |
            ${{ github.event.repository.name }}

      - uses: aquaproj/aqua-installer@11dd79b4e498d471a9385aa9fb7f62bb5f52a73c # v4.0.4
        with:
          aqua_version: v2.56.0
        env:
          AQUA_GITHUB_TOKEN: ${{ steps.create_token.outputs.token }}

      # terraform init などの準備を行う Action
      - uses: suzuki-shunsuke/tfaction/setup@4562d910bbacecf384c8aeda25332284fcf05f38 # v1.20.1
        with:
          github_token: ${{ steps.create_token.outputs.token }}

      - uses: suzuki-shunsuke/tfaction/test@4562d910bbacecf384c8aeda25332284fcf05f38 # v1.20.1
        with:
          github_token: ${{ steps.create_token.outputs.token }}

      # terraform plan を実行する Action
      - uses: suzuki-shunsuke/tfaction/plan@4562d910bbacecf384c8aeda25332284fcf05f38 # v1.20.1
        with:
          github_token: ${{ steps.create_token.outputs.token }}

基本的には tfaction-example を参考に実装していますが、特色としては、以下の通りです。

  • concurrency
    • tfaction では、plan 時に .terraform.lock.hcl ファイルや aqua-checksums.json に更新があった場合、自動的に更新をコミットします。現状このコミットは実行ディレクトリ単位で行われるため、複数のディレクトリで更新が発生した場合複数の push イベントが発生し、plan ワークフローが多重実行されてしまうおそれがあります。
    • そのため、concurrencyを設定して多重実行を抑制しています。
  • jobs.fetch-targets
  • jobs.plan
    • 実際に terraform plan を行うジョブ
    • 変更が多い場合デフォルトの GITHUB_TOKEN だとレートリミットに到達する場合があるので、GitHub App からトークンを作成・利用しています。
apply ワークフロー
name: Terraform Apply

on:
  push:
    branches:
      - main
env:
  TFACTION_IS_APPLY: "true"
jobs:
  # 変更のあった作業ディレクトリを取得する
  fetch-targets:
    uses: ./.github/workflows/wc-fetch-targets.yaml
    secrets:
      app_id: ${{ secrets.APP_ID }}
      app_private_key: ${{ secrets.APP_PRIVATE_KEY }}

  apply:
    name: "terraform apply for (${{ matrix.target.target }})"
    runs-on: ${{ matrix.target.runs_on }}
    needs: fetch-targets
    # 変更のある作業ディレクトリがない場合は実行しない
    if: join(fromJSON(needs.fetch-targets.outputs.targets), '') != ''
    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.fetch-targets.outputs.targets) }}

    env:
      TFACTION_TARGET: ${{ matrix.target.target }}
      TFACTION_WORKING_DIR: ${{matrix.target.working_directory}}
      TFACTION_JOB_TYPE: terraform

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

    steps:
      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1

      - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
        id: create_token
        with:
          app-id: ${{ secrets.APP_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}
          repositories: |
            ${{ github.event.repository.name }}

      - uses: aquaproj/aqua-installer@11dd79b4e498d471a9385aa9fb7f62bb5f52a73c # v4.0.4
        with:
          aqua_version: v2.56.0
        env:
          AQUA_GITHUB_TOKEN: ${{ steps.create_token.outputs.token }}

      - name: Setup for remote terraform module
        run: git config --global url."https://${{ steps.create_token.outputs.token }}@github.com/estie-inc".insteadOf https://github.com/estie-inc

      # terraform init などの準備を行う Action
      - uses: suzuki-shunsuke/tfaction/setup@4562d910bbacecf384c8aeda25332284fcf05f38 # v1.20.1
        with:
          github_token: ${{ steps.create_token.outputs.token }}

      # terraform apply を実行する Action
      - uses: suzuki-shunsuke/tfaction/apply@4562d910bbacecf384c8aeda25332284fcf05f38 # v1.20.1
        with:
          github_token: ${{ steps.create_token.outputs.token }}

こちらも plan と同様で、最後に使用する Action が tfaction/apply になっています。

tfaction を利用して便利だった点

tfaction を活用して Terraform モノレポを構成したことで、大きく感じたメリットは以下の通りです。

GitHub Actions ワークフローのメンテナンスコストが下がった

今回、tfaction を使ってモノレポを構成したことで全てのプロダクトのインフラが単一のワークフローファイルを元に plan/apply されるようになりました。これによって、ワークフローのメンテナンスコストが大幅に下がりました。

先述の通り、これまでも Reusable Workflow を活用してメンテナンスコストを抑えてはいたものの、Reusable Workflow のバージョン更新 PR の作成等、泥臭い作業が発生することは避けられませんでした。

モノレポ化を通して、全ての terraform plan/apply は単一の terraform-plan.yaml, terraform-apply.yaml によって実行されるため、ワークフローの更新もこれらのファイルを変更するだけで完結します。

さらに、tfaction では PR に含まれる差分をベースに plan/apply 実行対象のディレクトリを自動的に決定するため、ワークフロー自体のボリュームも抑えられています。

新規プロダクトのインフラ追加がしやすくなった

新たにプロダクトが立ち上がった際の Terraform コードの作成もしやすくなりました。
基本的な設定は tfaction-root.yamlにあり、CI/CD は既に作成されているため、Terraform のコード追加のみに集中できるようになりました。

tfaction を利用していることに伴う作業としては、対象のディレクトリに tfaction.yamlaqua.yaml を追加し、tfaction-root.yamltarget_groups に追記するだけです。
先述の通り、tfaction.yamlは IAM Role の設定を書くだけでよく、aqua.yamlに関しても共通設定と同じバージョンを利用する場合は特別な設定は必要ありません。

プロダクト/環境単位での個別設定も行える

先述の通り、各ディレクトリ配下に配置されるtfaction.yamlaqua.yamlを通して、プロダクトや環境単位の設定を行うことも簡単です。

自力でモノレポを構成する場合、これらの共通設定と個別設定の管理や取り回しに苦労することが懸念されましたが、tfaction を採用したメリットがここにも現れたかなと思います。

tfaction を利用する上で苦労した点

ワークフローの起点が Pull Request である必要がある

tfaction は Pull Request を全てのイベントの起点としています。たとえば、PR が作成された時に plan を実行し、マージされたときに apply を実行するといった形です。

これは Git Flow のようなブランチモデルを採用し、ブランチと環境が 1:1 対応している場合は非常にうまく機能します。
つまり、develop ブランチが開発環境に、release ブランチがステージング環境に、main ブランチが本番環境に対応しているといったケースです。

一方で、GitHub Flow を採用している場合などではタグベースの apply を行いたかったり、workflow_dispatch による apply を行いたいケースでは tfaction をそのまま活用することができません。 estie では GitHub Flow を採用しており、タグベースの apply が必要となってしまうため、tfaction をそのまま活用することができません。

しかし、tfaction の一番のメリットは「PR 差分をもとに実行対象を動的に決定してくれる」点にあると考えています。

Git Tag や workflow_dispatch をトリガーに terraform apply を実行するといったケースでは、そもそもコマンドの実行対象となるディレクトリが明確になっています。

そのため、これらの場合は tfaction を活用せずとも素朴に terraform を実行するワークフローを組めばよいのです。

それが上記の workflow-dispatch-terraform-apply.yamltag-based-terraform-apply.yaml です。クラウドプラットフォームへの認証情報などは tfaction.yaml に記載しているので、これらの設定値の取得部分のみ tfaction の仕組みを利用して、実際の terraform コマンド実行部分のみ独自で作成しています。

ドキュメントが簡素で、細かな仕様を把握するには実装を読む必要があった(過去形)

まずはじめに、今現在では NotebookLM や DeepWiki が公式に提供されているため、かなり軽減されたと思います。ありがとう AI!

deepwiki.com

…とはいえ、当時(昨年夏ごろ)はまだこれらがなかったため、tfaction-root.yaml の設定値が各 Action の動作にどう影響するのかなどの細かな仕様については、実装を一つ一つ追いかけて確認していました。

大変ではあるものの、実装自体は主に YAML, TypeScript, Shell Script で構成されており、やっていることも比較的シンプルなため難易度はそこまで高くありません。ただ、複数の Action が相互に作用して動作するため、それらの流れを認識するのに少し脳のリソースを割く必要があったかなと思います。

Merge Queue に対応していない

tfaction は 2026 年 1 月現在、Merge Queue の利用に対応していません。Merge Queue を活用して、正常に apply されたもののみがマージされるように構成したいところです。

Merge Queue が利用できない原因については、plan と apply を行うブランチ名が同一であることが大きな理由となっているので、余裕があるタイミングで自分でも修正できるか試してみようと思っています。

今後の展望

ひとまず tfaction を用いて Terraform のモノレポを構築できました。既にすべてのプロダクトがこの運用に乗っています。

今後も引き続き便利なモノレポを目指して改善を続けていくつもりで、現時点では以下の展望を持っています。

  • 共通モジュールの拡充
    • モノレポ化によって共通モジュールへの切り出しがしやすくなったため、プロダクト間で共通するリソース等の共通モジュールへの切り出しを進めていきたいと考えています。
  • renovate/dependabot による Terraform provider のバージョン管理
    • 既に GitHub Actions のアクション管理は dependabot、aqua 管理ツールのバージョン管理は renovate を使っていますが、Terraform provider のバージョン管理がまだ整備できていないため対応していきたいです。
  • tfaction/create-scaffold-pr を使った新規プロダクトインフラ構築
    • tfaction にはテンプレートディレクトリを使った Scaffold の機能があります。
    • Scaffold working directory | tfaction
    • これを活用して、新規プロダクトのインフラの構築を簡素化することで、プロダクトの立ち上げスピードをより速めていきたいです。

さいごに

本記事では、tfaction を用いた Terraform モノレポの構成について、実際の設定ファイルやワークフローファイルを例に示しながら紹介しました。

tfaction は非常に便利ですが、その具体の利用方法についてはまだ情報が少ない部分もあります。
この記事が今後 tfaction を検討、あるいは導入する方々の助けとなれば幸いです。

本記事でも紹介したように、estie では多くのプロダクトを開発・運用しています!SRE ももちろんのこと、様々な領域でエンジニア職を募集しておりますので、ご興味があればぜひ一度カジュアル面談にお越しください!

hrmos.co

SRE 職種はこちら。

hrmos.co

© 2019- estie, inc.