TerraformのState肥大化を解消!Terramate で実現する マルチテナント SaaS のデータ基盤

この記事は CADDi Tech/Product Advent Calendar 2025 の9日目の記事です。

Data Management チームの森岡です。要らなくなったものをすぐに捨てられるデータ基盤を意識して日々開発しています。

この記事では、プロダクトの成長に伴って直面した Terraform State の肥大化問題を Terramate を活用して解決した実践的な事例を紹介します。

はじめに

キャディでは、製造業AIデータプラットフォームを開発しています。

我々の顧客には大手エンタープライズ企業も多く含まれるため、セキュリティとデータガバナンスは最優先事項です。

その一方で、キャディには、カスタマーサクセスや、エンタープライズソリューションチームが存在し、顧客への価値提供に取り組んでいます。 これらのチームでは、顧客への提供価値を最大化するために、BigQuery 上のデータを活用し、利用状況分析、プロダクト上の顧客データを抽出・加工してプロダクトへ反映、さらにはデータを活用した高度なソリューション提案などを行っています。

このような背景から、「堅牢な分離」と「活用」を両立するため、テナントごとに BigQuery のデータセットを作成し、そのデータセットには特定の許可された社員のみがアクセスできるようにしています。

直面した問題

当初の Terraform 構成は、簡略化すると以下のようになっていました。1 つの環境につき 1 つの tfstate が存在し、その中で全テナントのリソースを一元管理していました。

├── README.md
├── environments
│   ├── prod
│   │   ├── main.tf           # ここで全てのリソースを呼び出し
│   │   └── tenant_data.json  # tenant の一覧
│   ├── stg
│   └── dev
└── modules
    ├── iam                  # 共通リソース定義
    |   └── main.tf
    ├── bigquery
    └── tenant_resource      # tenant ごとのリソース定義
        ├── tenant_iam.tf
        └── tenant_datasets.tf

しかし、テナント数の増加に伴い、以下のような問題が顕在化しました。

  1. パフォーマンスの悪化
    • 管理リソースの増大に伴い、terraform plan/apply の実行時間が著しく増加。
    • これにより、CIの待ち時間による開発生産性の低下。
  2. デプロイ安定性の低下
    • 大量のリソースを一括で更新・参照するため、Google Cloudの API Rate Limit が発生。
    • 特に BigQuery の getTable API 等において秒間リクエスト数制限を超過し、「コードは正しいのにデプロイが失敗する」という事象が多発。
  3. 運用アジリティの欠如
    • State が単一であるためロックの競合が頻発し、複数人による並行開発が難しい。
    • 1テナントの修正であっても全リソースへの参照が発生。

これらの課題を解決するためには、モノリシックな tfstate を分割し、テナントごとに独立した tfstate を管理する構成(Multi-State)への移行が必要でした。 しかし、単にディレクトリを分割するだけでは、テナントの数だけ .tf ファイルの複製管理が必要となり煩雑です。そこで、Terramate の採用を検討しました。

Terramate

Terramate とは

Terramate は、Terraform(および OpenTofu, Terragrunt)のための オーケストレーター兼コードジェネレーター です。主に以下の特徴を持っています。

  1. Stacks(スタック)の概念: Terramate を理解する上で最も重要な概念が 「Stack」 です。 一言で言えば、Stack とは 「Terraform の State を持つ最小のデプロイ単位」 のことを指します。リソースをこの Stack という論理グループ単位で管理することで、tfstate 分離し、独立した操作を可能にします。

  2. コード生成(Code Generation): 共通の HCL 設定を親ディレクトリで定義し、各スタック配下に Terraform コードとして生成・配布できます。

  3. 強力なオーケストレーション: Git の差分検知機能を持っており、変更があったスタックのみに対して plan や apply を実行できます。

公式ページに quick start ガイドがありますので、基本的な使い方はそちらをご参照ください。

Terragrunt との比較

terraform で tfstate 分割と DRY を実現するツールとしては Terragrunt が有名です。今回の選定にあたり、両者を以下のように比較しました。

特徴 Terramate Terragrunt
アプローチ Orchestrator
コード生成で Terraform コードを出力。Stack 単位で管理・実行する。
Wrapper
実行時に動的に設定を生成・注入する
管理/可読性 〇: テンプレート(generate_hcl)とグローバル変数で管理。テナント追加時は、スクリプトで stack.tm.hcl の作成が必要。tf ファイルが生成されるので可読性がよい。 〇: include による継承機能。テナント追加時は、同様にterragrunt.hcl の作成が必要。柔軟だが可読性は悪い。
実行制御 〇: git 差分検知 (terramate run --changed) やstackのタグ管理が強力 △: run-all で依存関係順に実行。--terragrunt-include-dir で特定 dir に絞った実行は可能
API Rate Limit 対策 〇: 変更がないStackに対するAPIコールはゼロ。後述する自作のretryも強力。 △ : 依存解決やPlan時に多くのAPIコール(Refresh)が発生しやすいが、--terragrunt-include-dir で回避はできる。
学習コスト ◎: 生成ルール(generate_hcl)以外は標準の Terraform の知識で完結する 〇: 独自の HCL 記法や継承ルールの学習が必要だが、そこまで複雑ではない

Terragrunt は素晴らしいツールであり、複雑な依存関係を持つインフラ(例:VPCを作ってからEKSを作り、その上にアプリを載せるなど)には最適です。

しかし、我々のケースは「依存関係は薄くフラットな構造であるが、とにかく数が膨大にある」 という特徴があります。 この場合、動的な解決を行う Terragrunt よりも、静的なコード生成と Git ベースの差分実行を行う Terramate の方が、パフォーマンス・運用コストの両面で有利であると判断しました。

Terramate 導入後の構成

Terramate 導入後のディレクトリ構成は以下のようになりました。すべてを解説すると長くなるため、主要なポイントに絞って説明します。

├── terramate.tm.hcl                     # グローバル設定(全環境共通)
├── scripts
│   └── sync-tenants.sh              # テナント Stack 自動生成スクリプト
├── _imports                         # 共有テンプレート
│   ├── backend.tm.hcl               # Backend & Provider 生成テンプレート(統合版)
│   └── tenant_resources.tm.hcl      # テナントリソース生成テンプレート
├── environments
│   ├── prod
│   │   ├── env_config.tm.hcl        # 環境固有設定
│   │   ├── data
│   │   │   └── tenant_data.json     # 既存ファイル(prod 環境のテナントデータ)
│   │   ├── _platform                # 共有リソースの Stack
│   │   │   ├── imports.tm.hcl       # _platformに適用するテンプレートの定義
│   │   │   ├── stack.tm.hcl
│   │   │   ├── main.tf              # 既存ファイル(生成しない)
│   │   │   ├── local.tf             # 既存ファイル(生成しない)
│   │   │   └── _gen_backend.tf      # 生成ファイル
│   │   └── _tenants                 # テナント Stack 群
│   │       ├── imports.tm.hcl       # _tenantsに適用するテンプレートの定義
│   │       ├── tenant_aaaaa         # テナント個別のStack
│   │       │   ├── stack.tm.hcl
│   │       │   ├── _gen_backend.tf
│   │       │   └── _gen_main.tf
│   │       ├── tenant_{TENANT_ID}
│   │       │   ├── stack.tm.hcl
│   │       │   ├── _gen_backend.tf
│   │       │   └── _gen_main.tf
│   │       └── ...
│   ├── stg
│   └── ...
└── modules
    ├── iam
    ├── bigquery
    └── tenant_resource             # 単一テナント用にリファクタリング
        ├── datasets.tf
        └── iam_tenant.tf

主要な構成要素の解説

1. Stack の構成

この構成では、大きく2種類の Stack があります。

  • _platform Stack: IAM ロールやプロジェクト共通の BigQuery データセットなど、全テナント共通のリソースを管理します。

  • _tenants/{tenant_id} Stack: 各テナント専用のリソース(データセット、IAM バインディングなど)を管理します。テナントごとに独立した tfstate を持ちます。

2. テナント Stack の自動生成

テナント数が増減するたびに手動でディレクトリを作成するのは非効率です。そこで、tenant_data.json を元に Stack を自動生成するスクリプト sync-tenants.sh を作成しました。 このスクリプトは、tenant_data.json を読み込み、存在しないテナント Stack ディレクトリを terramate createterramate generate コマンドで生成します。

tenant_data.json の例:

[
  {
    "tenant_id": "aaaaa",
    "tenant_name": "tenant A"
  },
  {
    "tenant_id": "bbbbb",
    "tenant_name": "tenant B"
  }
]

sync-tenants.sh で実行される terramate create コマンドの例:

# terramate create でディレクトリと stack.tm.hcl を作成
# --id にテナントID、--name にテナント名を設定
terramate create "$stack_dir" \
  --id "${tenant_id}" \
  --name "${tenant_name}" \
  --description "Resources for tenant: ${tenant_name}" \
  --after "../../_platform" \
  --tags "tenant,${ENV},tenant-${tenant_id}"

生成される stack.tm.hcl の例:

stack {
  id          = "aaaaa"
  name        = "tenant A"
  description = "Resources for tenant: tenant A"
  tags        = ["prod", "tenant", "tenant-aaaaa"]
  after       = ["../../_platform"]
}

これにより、新規テナントの追加は tenant_data.json への追加と非常にシンプルなスクリプトの実行だけで完結します。

3. コード生成の仕組み

コード生成は Terramate のコア機能のひとつです。_imports/ 配下のテンプレートファイルを、各 Stack の imports.tm.hcl で読み込むことで、必要な Terraform コードを自動生成します。

例えば、environments/prod/_tenants/imports.tm.hcl は以下のようになっています。

environments/prod/_tenants/imports.tm.hcl

import {
  source = "../../../_imports/backend.tm.hcl"
}

import {
  source = "../../../_imports/tenant_resources.tm.hcl"
}

これにより、このディレクトリ配下の全 Stack に backend.tm.hcl と tenant_resources.tm.hcl の 2 つのテンプレートを適用させています

テンプレートファイルである _imports/backend.tm.hclでは以下のようにバックエンド設定を定義しています。

_imports/backend.tm.hcl

# Backend 設定生成テンプレート
generate_hcl "_gen_backend.tf" {
  content {
    terraform {
      # Terraform バージョン
      required_version = global.terraform.version # このあたりの変数は terramate.tm.hcl や env_config.tm.hcl で定義

      backend "gcs" {
        # 環境固有のバケット
        bucket = global.terraform.backend.gcs.bucket

        # Stack ID ベースのパスで State を分離
        prefix = "stacks/${terramate.stack.id}"
      }

      required_providers {
        google = {
          source  = global.terraform.providers.google.source
          version = global.terraform.providers.google.version
        }
      }
    }

    provider "google" {
      project  = global.project.id
    }
  }
}

このテンプレートにより、各 Stack に 以下のような_gen_backend.tf が生成され、Stack ごとに異なる GCS パスで tfstate が保存されます。

environments/prod/_tenants/tenant_aaaaa/_gen_backend.tf

// TERRAMATE: GENERATED AUTOMATICALLY DO NOT EDIT

terraform {
  required_version = "x.x.x"
  backend "gcs" {
    bucket = "hoge"
    prefix = "stacks/aaaaa"
  }
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "x.x.x"
    }
  }
}
provider "google" {
  project  = "hoge"
}

同様に、テンプレートファイル _imports/tenant_resources.tm.hcl では、テナントリソースの Terraform コードを生成します。

_imports/tenant_resources.tm.hcl

# テナント Stack 専用の設定
# 各テナント Stack から個別に import される

generate_hcl "_gen_main.tf" {
  content {

    # テナントローカル変数の生成
    locals {
      tenant_id   = terramate.stack.id  # stack.tm.hcl で設定された id が入る
      tenant_name = terramate.stack.name
    }

    # Platform Stack の outputs を参照
    data "terraform_remote_state" "platform" {
      backend = "gcs"

      config = {
        bucket = global.terraform.backend.gcs.bucket
        prefix = "stacks/${global.platform.stack_id}" # Platform Stack の ID を使用
      }
    }

    # テナントリソースモジュールの呼び出し
    module "tenant_resource" {
      source = "path/to/modules/tenant_resource"

      # プロジェクト情報
      project_id = global.project.id

      # テナント情報 (locals から取得)
      tenant_id   = local.tenant_id
      tenant_name = local.tenant_name

      # Platform Stack からの出力を参照
      data_access_type_tag_values = data.terraform_remote_state.platform.outputs.data_access_type_tag_values
    }
  }
}

environments/prod/_tenants/tenant_aaaaa/_gen_main.tf

// TERRAMATE: GENERATED AUTOMATICALLY DO NOT EDIT

locals {
  tenant_id   = "aaaaa"
  tenant_name = "tenant A"
}
data "terraform_remote_state" "platform" {
  backend = "gcs"
  config = {
    bucket = "hoge"
    prefix = "stacks/platform-prod"
  }
}
module "tenant_resource" {
  source                      = "path/to/modules/tenant_resource"
  project_id                  = "hoge"
  tenant_id                   = local.tenant_id
  tenant_name                 = local.tenant_name
  data_access_type_tag_values = data.terraform_remote_state.platform.outputs.data_access_type_tag_values
}

このように、Terramate のコード生成機能を活用することで、テナントごとに独立した Stack を簡単に管理できるようになっています

4. 運用フロー

Terramate を使った terraform コマンドの実行例は以下の通りです。

# 全 Stack での実行 (10個の Stack を並列実行)
terramate run --parallel 10 -- terraform plan

# 変更された Stack のみ plan (Git ベース)
terramate run --changed --parallel 10 -- terraform plan

# タグを使った制御(":" で AND "," で OR)
terramate run --tags prod:tenant_aaaaa -- terraform plan

# あるいは、生成された terraform コードを直接操作することも可能
cd environments/prod/_tenants/tenant_aaaaa
terraform plan

Github Actions との連携も非常に簡単で、公式ページ をなぞればすぐに構築できます。

キャディでは、日次で対象 tenant の変化を検知して、① tenant_data.json の更新 ②sync-tenants.sh の実行、③ terramate run --changed で terraform plan および apply を実行するワークフローを構築しています。

その他の工夫

API Rate Limit 対策

Terramate により Stack ごとに独立した tfstate を持つことで、少数のテナントへの変更における API Rate Limit の問題は大幅に軽減されました。 特に --changed フラグによる差分実行では、変更がない Stack は API コールが発生しないため、日常的な運用では問題が起きなくなりました。

しかし、全テナントに対して大規模な変更を加える場合(例:共通モジュールのバージョンアップや、セキュリティポリシーの一斉適用など)、短時間に大量の API リクエストが発生し、依然として API Rate Limit に抵触するリスクがあります。

そこで、Terramate の Script 機能を活用し、一時的な API エラーに対して自動的にリトライする仕組みを導入しました。 Terramate Script は、各 Stack で実行するコマンドを HCL で定義できる機能で、通常の terraform コマンドの代わりに独自のスクリプトを実行できます。

以下は、terraform apply を最大3回リトライする Script の例です。

terramate.tm.hcl に記載

script "retriable_apply" {
  description = "Run terraform apply with automatic retries for transient errors"
  job {
    commands = [
      [
        "bash", "-c",
        <<-BASH
        for i in {1..3}; do
          if terraform apply -auto-approve -no-color; then
            exit 0
          fi
          if [ $i -lt 3 ]; then
            echo "Attempt $i failed, retrying in 10 seconds..." >&2
            sleep 10
          fi
        done
        echo "Terraform apply failed after 3 attempts" >&2
        exit 1
        BASH
      ]
    ]
  }
}

使い方も非常に簡単で、terramate run --parallel 10 -- terraform apply コマンドの代わりに terramate script run --parallel 10 retriable_apply を実行するだけです。

導入効果

Terramate 導入とtfstate分割による効果は劇的でした。

  • CICD時間の短縮: 以前までは、単一 State 構成での terraform plan/apply が 60 分以上かかることも珍しくありませんでしたが、Terramate 導入後は、数テナントの変更であれば数分以内に完了するようになりました。

  • 安定性の向上: retry により、API Rate Limit による問題も解消されました。

運用も基本は、CI/CD パイプラインで自動化されており、terramateを意識することなく進められています。 また、たまに手動介入するときも、特定テナントの Stack に移動して通常の Terraform コマンドを実行するだけで済むため、追加の学習コストもほとんど発生していません。

おわりに

私が Terramate で最も気に入っている点は、Terramate の責務と Terraform の責務が明確に分離されており、非常に疎結合であることです。

ざっくりいえば、Terramate の責務は以下の2点のみです。

  1. コード生成: DRY を実現するための tf ファイル生成
  2. オーケストレーション: terramate run による実行対象 Stack の選定とコマンド発行

実際の tfstate 操作や API 通信といったコア処理は、標準の Terraform に完全に委ねられています。なので、問題発生時における切り分けも容易であり、Terraform の豊富なドキュメントやコミュニティリソースを活用できる点が非常に助かっています。

terramate は比較的新しいツールということもあり、実践的な資料がまだまだ少ないです。この記事が同様の課題に直面している方々の参考になれば幸いです。