Article

私がTerraformで1万行のコードを書いて後悔した理由

高田晃太郎
私がTerraformで1万行のコードを書いて後悔した理由

HashiCorpのState of Cloud Strategy 2023では、企業の76%がマルチクラウドを採用していると報告され、クラウドインフラは確実に複雑化しています[1]。IaC(Infrastructure as Code)はその複雑性に対する有力な対抗策ですが、コードが肥大化すると別の罠が口を開けます。公開事例やコミュニティの報告、一般的な現場の声を丹念に読み解くと、Terraformを単一リポジトリのまま環境・プロダクト・チームを抱き込んだ結果、変更の衝突と認知負荷が指数関数的に増大し、最後は「誰も安全に触れない1万行のモノリス」に至りがちだという傾向が見えてきます。これはIaCアンチパターンの典型です。

本稿では、中規模プロダクトでしばしば報告される「Terraformが1万行に肥大化して後悔した」典型パターンを素材に、その構造的な失敗要因を技術的に分解します。単なる反省では終えません。設計の分割、環境差分の表現、テストとCI/CD、ポリシーによるガードレールまでを、動くコードとともに実務に落とし込みます。対象はCTOやエンジニアリングマネージャ、SREリード層です。判断と投資の優先順位がそのまま運用コストとリスクに跳ね返る層だからこそ、根拠と再現性にこだわります。

なぜ「1万行のTerraform」は生まれるのか

コードが肥大化する直接のきっかけは拡張の速度です。プロダクトの成長に応じて環境が増え、リージョンやアカウントが増え、機能ごとにセキュリティ境界も増えます。時間がない現場では、既存のmain.tfに追記し、環境差分を条件分岐で切り替え、リソース名をローカル変数で強引に整形します。すると境界が曖昧なまま依存が絡み合い、planの実行時間は伸び、レビューは全体を追えず、作業は心理的に防御的になります。これはTerraformベストプラクティスから外れた運用であり、将来の変更コストを確実に膨らませます。

典型的なアンチパターンは、環境差分を条件分岐でねじ込むやり方です。以下のようなコードは一見DRYですが、diffの解釈と影響範囲の推論を難しくします。

# anti-pattern: 条件分岐とcountで環境差分を抱き込む
locals {
  is_prod = var.env == "prod"
  name    = "${var.app}-${var.env}"
}

resource "aws_autoscaling_group" "web" {
  name                      = local.name
  desired_capacity          = local.is_prod ? 6 : 2
  max_size                  = local.is_prod ? 10 : 3
  min_size                  = 2
  # 他にも十数の属性が環境分岐で増殖
}

resource "aws_security_group_rule" "db_from_web" {
  count                    = local.is_prod ? 1 : 0
  type                     = "ingress"
  from_port                = 5432
  to_port                  = 5432
  protocol                 = "tcp"
  security_group_id        = aws_security_group.db.id
  source_security_group_id = aws_security_group.web.id
}

これが数十リソース、数環境分に広がると、レビュー時に「prodだけ挙動が違う」地雷が埋まります。バックエンドのstate分割も曖昧だと、さらに被害は拡大します。単一stateに多環境を積む設計は、並行変更やロールバックの安全性を損ないます。ここでのstateとは、Terraformが管理するリソースと構成の対応関係を保持するメタデータのことです。

# anti-pattern: すべて同じkeyに書き込む危険なbackend
terraform {
  backend "s3" {
    bucket  = "company-terraform-state"
    region  = "ap-northeast-1"
    key     = "platform/monolith.tfstate" # 全環境が同居
    encrypt = true
  }
}

stateは衝突ドメインです。環境・コンポーネント・チーム境界をまたがって同一stateに収容すると、ロック、差分、アクセス権限、いずれも破綻します[2]。

後悔をほどく設計――境界、差分、依存

再設計の第一歩は、依存が少なく変更頻度が独立したまとまりを単位として境界を切ることです。一般にネットワーク、コンピュート、データ、プラットフォーム共通基盤などのドメインで分け、各ドメインは明示的な入出力だけを公開します。環境差分は条件分岐で埋め込まず、データとして外から与えるのが原則です[5]。これはTerraformベストプラクティスの中心的な考え方で、可観測性と変更容易性を高めます。

モジュール境界は、入力と出力の契約で強くします。使う側はバージョン固定で呼び出し、破壊的変更をコントロールします。

# modules/network/variables.tf
variable "name" { type = string }
variable "cidr" { type = string }
variable "public_subnet_cidrs" { type = list(string) }

# modules/network/outputs.tf
output "vpc_id" { value = aws_vpc.this.id }
output "public_subnet_ids" { value = aws_subnet.public[*].id }
# envs/prod/network/main.tf
module "network" {
  source               = "git::https://example.com/infra.git//modules/network?ref=v1.4.2"
  name                 = "app-prod"
  cidr                 = "10.10.0.0/16"
  public_subnet_cidrs  = ["10.10.1.0/24", "10.10.2.0/24"]
}

terraform {
  backend "s3" {
    bucket  = "company-tfstate"
    region  = "ap-northeast-1"
    key     = "network/prod.tfstate" # 境界と環境で分割
    encrypt = true
  }
}

環境差分はHCLやYAML/JSONのパラメータに落とし込み、適用時に注入します。Terragrunt(Terraformのラッパーで差分・依存・バックエンドの共通化を支援するツール)を使う選択肢もあります[3]。依存、差分、バックエンド、再利用の型が揃います。

# envs/prod/terragrunt.hcl
locals {
  env  = "prod"
  vars = {
    app_name      = "myapp"
    desired_count = 6
  }
}
terraform {
  source = "git::https://example.com/infra.git//stacks/app?ref=v2.0.0"
}
inputs = local.vars
remote_state {
  backend = "s3"
  config = {
    bucket  = "company-tfstate"
    key     = "app/${local.env}.tfstate"
    region  = "ap-northeast-1"
    encrypt = true
  }
}

依存は明示的に外部化し、出力を介してつなぎます。Terraformネイティブでもremote_stateを用いた明示は効果的です[4]。

# envs/prod/app/main.tf
data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "company-tfstate"
    key    = "network/prod.tfstate"
    region = "ap-northeast-1"
  }
}

module "app" {
  source            = "git::https://example.com/infra.git//modules/app?ref=v3.2.1"
  name              = "app-prod"
  subnet_ids        = data.terraform_remote_state.network.outputs.public_subnet_ids
  vpc_id            = data.terraform_remote_state.network.outputs.vpc_id
  desired_capacity  = 6
}

ここまでで、状態、差分、依存の三点が分離されます。変更単位は小さくなり、planは理解可能なサイズに縮み、レビューの焦点も合います。結果として、IaCの変更は「安全に速く」回るようになります。

品質をコードで担保する――Lint、テスト、CI/CD

Terraformは宣言的ですが、品質は自動で上がりません。静的解析(tflintやtfsecのようなLint/セキュリティスキャナ)とテスト、そしてCI/CDでの一貫した実行環境を用意して、人手の推論を減らすことが重要です。モジュール単位のテストはTerratest(GoでTerraformを起動して検証するフレームワーク)で最小構成を起動して検証します[6]。

// test/app_module_test.go
package test

import (
  "testing"
  "time"

  "github.com/gruntwork-io/terratest/modules/random"
  "github.com/gruntwork-io/terratest/modules/terraform"
  "github.com/stretchr/testify/require"
)

func TestAppModule_Minimal(t *testing.T) {
  t.Parallel()
  unique := random.UniqueId()
  tf := terraform.Options{
    TerraformDir: "../../modules/app/examples/minimal",
    Vars: map[string]interface{}{
      "name": "ci-" + unique,
      "desired_capacity": 2,
    },
  }
  defer terraform.Destroy(t, &tf)
  terraform.InitAndApply(t, &tf)
  outs := terraform.OutputAll(t, &tf)
  require.NotEmpty(t, outs["service_arn"])
  // 簡易ヘルスチェックなどを入れるなら待機
  time.Sleep(2 * time.Second)
}

CIではplanの出力を保存し、ポリシーチェックやコストの検査に回します。GitHub Actionsのようなホステッド環境でも、ツールのバージョンを固定して再現性を確保します。これにより、レビューは設計判断に集中でき、パイプラインは逸脱検出を担います。

# .github/workflows/terraform.yaml
name: terraform
on:
  pull_request:
    paths: ["envs/**"]
  push:
    branches: ["main"]

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: 1.9.5 }
      - name: Init
        run: terraform -chdir=envs/prod/app init -input=false
      - name: Plan
        run: terraform -chdir=envs/prod/app plan -input=false -out=plan.tfplan
      - name: Show JSON
        run: terraform -chdir=envs/prod/app show -json plan.tfplan > plan.json
      - name: Upload plan
        uses: actions/upload-artifact@v4
        with: { name: plan, path: envs/prod/app/plan.json }

ポリシーはConftestやOPA(Open Policy Agent)でコード化できます。たとえば、インターネット向けのセキュリティグループを禁止するルールをplan JSONに対して適用します。

# policy/sg.rego
package terraform.analysis
import future.keywords.in

violation[msg] {
  some i
  input.resource_changes[i].type == "aws_security_group_rule"
  rc := input.resource_changes[i]
  rc.change.after.cidr_blocks[_] == "0.0.0.0/0"
  rc.change.after.type == "ingress"
  msg := sprintf("ingress 0.0.0.0/0 is not allowed: %v", [rc.address])
}

CDK for Terraform(プログラミング言語でTerraform構成を生成するツール)のような抽象化レイヤを併用する場合も、抽象の境界と生成物の検査を分けて考えます。コード生成側は型安全と組み合わせの爆発抑止、下側は生成HCLの検査とplanのポリシーチェックです。

# cdk/main.py
from constructs import Construct
from cdktf import App, TerraformStack, TerraformOutput
from imports.aws.provider import AwsProvider
from imports.aws.ecs_service import EcsService

class AppStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str):
        super().__init__(scope, ns)
        AwsProvider(self, "aws", region="ap-northeast-1")
        svc = EcsService(self, "svc", name="cdktf-app", desired_count=2)
        TerraformOutput(self, "service_name", value=svc.name)

app = App()
AppStack(app, "app")
app.synth()

ここまでの仕組みが回ると、レビューは「設計の変更」そのものに集中でき、CIは「逸脱の検出」を担います。人間の注意力を、差分の心理戦から解放します。

コストとROI――後悔を投資計画に変える

モノリス化したTerraformの隠れコストは見えづらいですが、見積もることは可能です。例えば、変更あたりのplan実行時間が15分、レビュー待ちを含むリードタイムが3日、週あたりの同時変更が4件、関与するメンバーが5人だと仮定します。1週間の「待ち時間×人件費」はすぐに数十万円規模になります。境界分割でstateサイズが縮小し、planが3分に、レビュー変更範囲が1/3になれば、同じ人員でもスループットは上がる可能性が高い。加えて、障害時のロールバックと原因切り分けのスピードは、事業リスクの削減に直結します。これはCTO視点での投資判断に耐える定量化の第一歩です。

一般的な導入例でも、state分割とTerratestの整備、ポリシーチェックのCI化までを数スプリント相当の投資で進めると、四半期あたりのIaC関連の待ち時間が目に見えて圧縮されたという報告が見られます。重要なのは、最初にメトリクスを計測しておくことです。planの実行時間、1PRあたりの変更行数、stateファイルサイズ、モジュール単位のリソース数、ロールバック所要時間などを継続的に追うと、投資効果が可視化され、チームの合意形成が進みます。

最後に、アクセス権と責任の分割をコードと運用で一致させます。state単位でのIAM権限、ディレクトリごとのCODEOWNERS、ブランチ戦略、そして緊急時の手順書まで、コードと実務の整合をとることで、運用の摩擦はさらに減ります。Terraformそのものは優れたツールですが、後悔の根はツールではなく、設計と運用の整合性にあります。

state設計の現実解

理想論ではなく、現実の落とし所も共有します。アカウント×環境×ドメインでstateを分け、それ以上の細分化は「並行変更の頻度」と「依存の強さ」で決めます。過剰分割はオーケストレーションのコストを生みますが、Terragruntやスクリプトで吸収できる範囲なら分割のメリットが勝ちます。逆に、依存が強固な塊は無理に割らず、出力契約の整備を先に行います。これはIaCのガバナンスと変更管理のバランスを取る現実解です。

ドリフト対策と継続的検証

本番で手当てをしてしまう「一回だけの例外」がドリフト(コードと実環境の乖離)の芽です。定期的なterraform planの自動実行と通知、重要リソースの設定スナップショット検査、コストのドリフト検知など、継続的な検証をパイプラインに組み込みます。Terratestには破壊コストが伴うため、最小構成のフレンドリーな環境を用意し、夜間に並行して走らせる運用が現実的です。

まとめ――「1万行」を越えていくために

Terraformの1万行モノリスは、勢いと善意の副作用として生まれます。環境差分を条件分岐で抱え込み、stateを共有し、依存を暗黙にした結果、変更は怖い行為になります。けれど、境界を切り、差分をデータ化し、テストとポリシーで守り、CI/CDで一貫性を担保すれば、同じチームでも別の未来が開きます。今日からできる最初の一歩は、計測と可視化です。plan時間、stateサイズ、1PRの変更範囲を測り、最も重い塊から分割を始めてください。

後悔は負債のサインであり、設計を学び直すチャンスです。あなたのチームでは、どの境界から切り始めますか。最初の分割対象と計測項目を決め、次のスプリントで小さく試すことから動き出しましょう。もし迷いがあるなら、モジュールの契約を紙に書き出し、依存の矢印を引いてみてください。図がシンプルに見えるところが、あなたの最初の勝ち筋です。

参考文献

  1. HashiCorp. State of the Cloud Strategy Survey 2023. https://www.hashicorp.com/en/state-of-the-cloud/2023
  2. Terraform documentation. Refactor State. https://developer.hashicorp.com/terraform/language/state/refactor
  3. 技術評論社. みてねのTerraform運用(2022年10月). https://gihyo.jp/article/2022/10/mitene-01terraform
  4. Terraform Cloud Docs. Workspaces Best Practices. https://developer.hashicorp.com/terraform/cloud-docs/workspaces/best-practices
  5. Terraform Tutorial. Organize Configuration. https://developer.hashicorp.com/terraform/tutorials/modules/organize-configuration
  6. HashiCorp. End-to-end testing on Terraform with Terratest. https://www.hashicorp.com/resources/end-to-end-testing-on-terraform-with-terratest