はじめに

Terraformはインフラをコードで管理するデファクトスタンダードのツールですが、管理対象リソースが数百、数千規模に増えるとplanapplyの実行時間が急激に悪化し、stateファイルの肥大化やチーム間のコンフリクトなど、小規模環境では顕在化しなかった問題が次々と表面化します。本記事では、Terraformを大規模環境で運用する際に直面するパフォーマンス上の課題と、その解決策を体系的に整理します。AWS・GCP・Azureいずれのクラウドでも共通する考え方を中心に、具体的なHCLサンプルとともに解説します。

大規模運用で起こりがちな問題

Terraformのパフォーマンス劣化は主に以下の要因から生じます。

  • 単一stateファイルへのリソース集約:数百リソースを1つのstateで管理すると、plan実行時に全リソースのリフレッシュ処理が走り、数分〜数十分かかるようになります。
  • モジュールの過度なネスト:モジュールを何層にも重ねると、依存関係グラフの解決コストが増大します。
  • プロバイダAPIのレートリミット:AWSやGCPのAPIは呼び出し回数に制限があり、並列度を上げすぎるとスロットリングでエラーになります。
  • 不要なdata sourceの多用dataブロックは毎回APIを叩くため、大量に使うとplan時間に直結します。

これらを踏まえ、次章以降で具体的な対策を見ていきます。

1. stateファイルの分割戦略

最も効果が大きいのがstateの分割です。1つの巨大なstateにすべてのリソースを詰め込むのではなく、機能単位・環境単位・チーム単位でstateを分けることで、planの対象リソース数を減らし、実行時間を大幅に短縮できます。

分割の考え方

  • 環境ごと(dev / staging / prod)
  • レイヤーごと(ネットワーク基盤 / データベース / アプリケーション)
  • チームの責任範囲ごと(プラットフォームチーム / プロダクトチーム)
# backend.tf (network/ ディレクトリ)
terraform {
  backend "s3" {
    bucket         = "okpy-tfstate"
    key            = "network/terraform.tfstate"
    region         = "ap-northeast-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}
# backend.tf (app/ ディレクトリ)
terraform {
  backend "s3" {
    bucket         = "okpy-tfstate"
    key            = "app/terraform.tfstate"
    region         = "ap-northeast-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

state間で値を共有する場合はterraform_remote_stateデータソースを使います。

data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "okpy-tfstate"
    key    = "network/terraform.tfstate"
    region = "ap-northeast-1"
  }
}

resource "aws_instance" "app" {
  subnet_id = data.terraform_remote_state.network.outputs.private_subnet_id
  # ...
}

state分割のトレードオフとして、リソース間の暗黙的な依存関係が見えにくくなる点には注意が必要です。分割単位は「変更頻度」と「所有者」が揃う境界を選ぶのがコツです。

2. モジュール設計のベストプラクティス

モジュールは再利用性を高める一方で、設計を誤るとplan時間の増大やコードの見通しの悪化を招きます。

フラットなモジュール構成を心がける

過度なネスト(モジュールの中でモジュールを呼び、さらにその中で…という構造)は依存関係グラフを複雑にします。ルートモジュールから1〜2階層程度に抑えるのが目安です。

module "vpc" {
  source = "./modules/vpc"

  cidr_block = "10.0.0.0/16"
  azs        = ["ap-northeast-1a", "ap-northeast-1c"]
}

module "eks_cluster" {
  source = "./modules/eks"

  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnet_ids
}

for_eachを活用してリソース定義を集約

同種のリソースを大量に作る場合、countよりfor_eachの方が安全です。countはインデックスに依存するため、リストの途中要素を削除すると他のリソースが再作成される危険があります。

variable "buckets" {
  type = map(object({
    versioning = bool
  }))
}

resource "aws_s3_bucket" "this" {
  for_each = var.buckets
  bucket   = "okpy-${each.key}"
}

resource "aws_s3_bucket_versioning" "this" {
  for_each = { for k, v in var.buckets : k => v if v.versioning }
  bucket   = aws_s3_bucket.this[each.key].id

  versioning_configuration {
    status = "Enabled"
  }
}

モジュールのバージョン固定

Terraform Registryやgitリポジトリからモジュールを読み込む場合は、バージョンを必ず固定します。固定しないとCI環境ごとに異なるバージョンが取得され、意図しない差分が発生します。

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.8.1"
  # ...
}

3. plan/applyの並列度とタイムアウト調整

Terraformはデフォルトで最大10個のリソースを並列に処理します(-parallelism)。大規模環境ではこの値がボトルネックにも、逆にAPIスロットリングの原因にもなり得ます。

# 並列度を上げてplan/apply時間を短縮(APIレートリミットに注意)
terraform apply -parallelism=30

# プロバイダ側のレートリミットが厳しい場合は逆に下げる
terraform apply -parallelism=5

プロバイダブロックでリトライ設定を明示的に行うことで、スロットリングによる一時的なエラーを吸収できます。

provider "aws" {
  region = "ap-northeast-1"

  retry_mode  = "adaptive"
  max_retries = 25
}

GCPやAzureのプロバイダにも同様のリトライ・タイムアウト設定があります。GCPプロバイダではrequest_timeout、Azureプロバイダでは各リソースブロック内のtimeoutsブロックで調整可能です。

resource "azurerm_kubernetes_cluster" "this" {
  # ...
  timeouts {
    create = "60m"
    update = "60m"
    delete = "30m"
  }
}

4. -targetと部分applyの使い所

大規模state全体のplanに時間がかかる場合、緊急対応として-targetオプションで特定リソースのみを対象にできます。ただし、これは恒常的な運用手段ではなく、あくまで一時的な回避策として使うべきです。依存関係グラフの一部だけを評価するため、他リソースとの整合性が崩れるリスクがあります。

terraform apply -target=module.app.aws_ecs_service.main

恒常的な解決策は前述のstate分割です。-targetを日常的に使わざるを得ない状況は、state分割の見直しシグナルと捉えましょう。

5. dataソースの削減とキャッシュ戦略

dataブロックはリフレッシュのたびにAPI呼び出しが発生します。同じ情報を何度も参照する場合は、値をローカル変数やoutputに一度だけ格納し、再利用する設計に変えるとplan時間を削減できます。

# 悪い例: 複数箇所で同じdata sourceを呼び出す
data "aws_ami" "latest" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-gp2"]
  }
}

# 良い例: ルートで一度だけ解決し、モジュールにはoutputとして渡す
module "app" {
  source = "./modules/app"
  ami_id = data.aws_ami.latest.id
}

6. state操作コマンドの活用

大規模stateの運用では、terraform stateサブコマンドの理解が欠かせません。

# リソースの一覧を確認(全体planより高速)
terraform state list

# 特定リソースだけの情報を取得
terraform state show aws_instance.app

# リソースをstate間で移動(分割時に有用)
terraform state mv aws_instance.app module.app.aws_instance.main

# 誤ってインポートされたリソースを削除(実体は削除しない)
terraform state rm aws_instance.orphan

terraform importstate mvは、既存のリソースをTerraform管理下に移す際やstateを再編成する際に頻出します。大規模移行ではterraform plan -generate-config-out(Terraform 1.5以降)を使うと、既存リソースからHCLコードを自動生成でき、手作業を大幅に削減できます。

7. CI/CDでの実行時間短縮

CIパイプラインでは、変更のあったディレクトリのみを対象にplan/applyを実行する仕組みが有効です。GitHub Actionsを例にすると、pathsフィルタやworking-directoryのマトリクス化で実現できます。

jobs:
  terraform:
    strategy:
      matrix:
        stack: [network, database, app]
    steps:

      - uses: actions/checkout@v4
      - name: Terraform Plan
        working-directory: ./environments/${{ matrix.stack }}
        run: terraform plan -out=tfplan

加えて、プロバイダプラグインのダウンロードをCIキャッシュに含めることで、terraform initの時間を短縮できます。

      - uses: actions/cache@v4
        with:
          path: |
            .terraform
            .terraform.lock.hcl
          key: terraform-${{ hashFiles('**/.terraform.lock.hcl') }}

クラウドプロバイダごとの留意点

  • AWS:APIレートリミットはサービスごとに異なり、特にIAMやRoute53は制限が厳しめです。aws_iam_roleなどを大量にfor_eachで生成する場合は並列度を抑えるか、リトライ設定を強めに設定します。
  • GCP:プロジェクト単位でAPIクォータが管理されるため、複数プロジェクトにまたがる大規模構成ではstateをプロジェクト単位で分割するのが自然です。
  • Azure:リソースグループ単位での操作が基本となるため、stateもリソースグループ境界に合わせて分割すると、権限管理(RBAC)とも整合しやすくなります。

いずれのクラウドでも共通するのは、「APIの制限単位」と「state・モジュールの分割単位」を一致させることが、パフォーマンスと運用性の両立につながるという点です。

注意点・アンチパターン

  • 巨大な単一stateの放置:後から分割しようとするとstate mvの作業量が膨大になります。プロジェクト初期から分割方針を決めておくのが得策です。
  • countの乱用によるリソース再作成事故:リストの順序変更で無関係なリソースが削除・再作成されることがあります。for_eachをデフォルトの選択肢にしましょう。
  • ロックファイルの不整合.terraform.lock.hclをコミットし忘れると、CIとローカルでプロバイダバージョンがずれ、予期しない差分が発生します。
  • リモートバックエンドのロック未設定:DynamoDBやCloud Storageのロック機構を設定しないと、複数人が同時にapplyしてstateが破損するリスクがあります。
  • secretsの平文管理:state自体に機密情報が平文で残ることがあるため、暗号化(encrypt = true)とアクセス制御を必ず設定してください。

FAQ

Q1. planの実行時間が長すぎます。まず何を確認すべきですか? A. まずterraform state listでリソース数を確認してください。数百を超えている場合はstate分割が最優先の対策です。次にdataブロックの数と、不要なリフレッシュが発生していないかを確認します。-refresh=falseで一時的に切り分けるのも診断に有効です。

Q2. state分割後、リソース間の依存関係はどう管理すればよいですか? A. terraform_remote_stateデータソースでoutputを参照するのが基本パターンです。ただし依存が複雑になりすぎる場合は、Terraform CloudやSpacelift、Atlantisなどのオーケストレーションツールでstack間の実行順序を管理する方法も検討してください。

Q3. -targetを使わずに大規模applyの影響範囲を絞る方法はありますか? A. state分割に加えて、terraform planの差分を確認してからapplyするワークフローを徹底することが基本です。またTerraform 1.6以降の-excludeオプションを使うと、特定リソースを除外した形でplan/applyを実行でき、-targetより安全に部分適用ができます。

まとめ

大規模なTerraform運用におけるパフォーマンス最適化の本質は、「stateとモジュールの境界を、変更頻度・所有者・APIレートリミットの単位に合わせて設計する」ことに尽きます。for_eachの活用やモジュールのバージョン固定といった基本的なプラクティスを徹底しつつ、CI/CDパイプラインでの並列実行や差分検出を組み合わせることで、リソース数が増えても実用的な実行時間を維持できます。まずは現在のstateリソース数とplan実行時間を計測し、ボトルネックを特定するところから始めてみてください。