# Terraform モジュール設計とステート運用：責務分離・stg/prod ステート分割・ドリフト検知で『壊れないIaC』を作る

> Terraformで保守可能なIaCを設計する実装ガイド。モジュールの切り出し基準と標準構造、合成(composition)優先、環境ごとのステート分離とリモートステート＋ロック、責務分離によるドリフト防止、tfsec付きplan／権限境界ロールでのapply／定期ドリフト検知のCIゲートまでを、実設定で解説します。コスト最適化(FinOps)は別記事に分離し、本記事は構造とステート運用に集中します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: Terraform, IaC, アーキテクチャ設計, GCP, AWS
- URL: https://tomodahinata.com/blog/terraform-module-design-state-isolation-drift-detection-guide

## 要点

- 良いモジュールは変更理由が1つ・入出力が明確・再利用可能で、単一リソースの薄いラッパは作らない
- 深いネストを避け、依存物は内部生成せずルートから注入する合成（dependency inversion）でフラットに保つ
- stg / prod はワークスペースではなく別ステート（ディレクトリ分離）で分けるのが公式準拠で安全
- アプリのデリバリとインフラ変更を責務分離し、可変属性は ignore_changes でドリフトを構造的に防ぐ
- CI は PR=plan+tfsec（read-only）／ merge=apply（権限境界ロール）／ 定期 drift 検知の3ゲートで自動化する

---

「インフラをコード化したい」——要件としては一行です。けれど Terraform を本番に載せようとした瞬間、判断すべきことが一気に増えます。**どこからをモジュールに切り出すのか。モジュールはどう構造化するのか。ネストはどこまで許すのか。ステートは環境ごとに分けるのか、それともワークスペースで足りるのか。誰がどの権限で `apply` するのか。そして——コードと現実がいつの間にかズレる「ドリフト」を、どう検知して潰すのか。**

この記事は、Terraform を**保守可能な形**で設計・運用するための実装ガイドです。題材として、私が国内大手放送事業者向けに構築した社内AIプラットフォーム（GCP 全体を 100% Terraform でコード化）と、林業DX案件（AWS、経済産業大臣賞受賞プロダクト）での設計判断を交えます。

> **この記事のルール**：モジュール仕様・ステート・バックエンドの挙動は **HashiCorp 公式ドキュメント（2026年6月時点）** に基づきます。ブロック名・引数名は公式準拠ですが、Terraform はバージョンで挙動が変わるため、本番投入前に必ず[公式ドキュメント](https://developer.hashicorp.com/terraform/language)で最新仕様を確認してください。コードは実運用で使える形に整えていますが、認証情報・state バケット名などは環境変数 / バックエンド設定前提です（ハードコード厳禁）。

> **コスト最適化（FinOps）は別記事です。** タグ設計・無駄リソースの棚卸し・予算ガードレールなど「Terraform でいくらに抑えるか」は [Terraform でスタートアップのコストを最適化する（FinOps）](/blog/aws-terraform-startup-cost-optimization-finops) に分離しました。本記事は**モジュール構造とステート運用**——つまり「壊れないIaCの骨格」に集中します。両者は補完関係です。

---

## 0. メンタルモデル：良いモジュールと「本番の現実」としてのステート

設計に入る前に、この記事を貫く2つのメンタルモデルを先に固定します。これがブレると、後でモジュールが肥大化し、ステートが事故ります。

**① 良いモジュール = 「変更理由が1つ・入出力が明確・再利用できる」**

公式ドキュメントは、モジュールの目的を「プロバイダが提供するリソース型を組み合わせて、**アーキテクチャ上の新しい概念を記述し、抽象度を上げること**」と定義しています。そして明確に警告します——**「単一のリソース型を薄くラップしただけのモジュール（thin wrappers around single other resource types）を作るな」**と。

| | 良いモジュール | 悪いモジュール |
| --- | --- | --- |
| 責務 | 変更理由が1つ（SRP） | 「ネットワークも DB も IAM も」全部入り |
| 入出力 | `variable` / `output` で明示・最小 | 何を渡せば動くか読まないと分からない |
| 構造 | フラット（1段の子モジュール） | 深いネスト・モジュールがモジュールを生む |
| 再利用 | 別環境・別案件に持っていける | この案件専用に癒着している |
| 抽象度 | 「VPC」「Cloud Run サービス」など概念単位 | リソース1個の薄いラッパ（無価値） |

**② ステート = 「本番の現実」のスナップショット**

Terraform のステート（state）は、コードと実インフラを対応づける台帳です。これが**本番の現実そのもの**だと考えてください。だからこそ——**環境ごとに分け、ロックして同時更新事故を防ぎ、責務を分けてドリフト（コードと現実のズレ）を生まない**。ステートを軽視した IaC は、いずれ「コードを見ても本番が分からない」状態に陥ります。

この2点を軸に、切り出し基準 → 標準構造 → 合成 → ステート分離 → CI ゲート → ドリフト検知、の順で具体に降りていきます。

---

## 1. モジュールの基礎：公式が定める構造

### 1.1 ルートモジュールと子モジュール

公式の定義は明快です。

- **ルートモジュール（root module）**：作業ディレクトリ直下の `.tf` 群。`terraform` コマンドを実行する起点。
- **子モジュール（child module）**：ルートから `module` ブロックで呼び出されるモジュール。

モジュールのソース（`source`）は、ローカルパス・Terraform Registry・Git リポジトリ・S3・プライベートレジストリなどから読み込めます。最小の呼び出しはこうです。

```hcl
module "consul" {
  source  = "hashicorp/consul/aws"
  version = "0.1.0"

  servers = 3
}
```

`source` がどこからモジュールを持ってくるか、`version` がどのバージョンを固定するか、そしてそれ以外の引数（ここでは `servers`）が**そのモジュールの入力変数**です。

### 1.2 ソースの書き方（公式準拠）

`source` は出所によって書式が決まっています。**ここを曖昧に書くと再現性が壊れる**ので、公式の例を正確に踏襲します。

```hcl
# ① ローカルパス（必ず ./ か ../ で始める）
module "app_cluster" {
  source = "./app-cluster"
}

# ② Terraform Registry（NAMESPACE/NAME/PROVIDER）+ version 固定
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "6.0.1"
}

# ③ 汎用 Git（必ず ?ref でタグ/SHA を固定する）
module "vpc_git" {
  source = "git::https://example.com/vpc.git?ref=v1.2.0"
}

# ④ Git + SHA-1 ハッシュ固定（最も厳密）
module "storage" {
  source = "git::https://example.com/storage.git?ref=51d462976d84fdea54b47d80dcabbf680badcdb8"
}
```

ここでの鉄則は **「バージョンを必ず固定する」**。`version` 引数は **Registry モジュール専用**です。Git ソースには `version` を付けられないので、代わりに `?ref=v1.2.0`（タグ）か `?ref=<SHA>` で固定します。`ref` には「`git checkout` が受け付ける値（ブランチ・SHA-1・タグ）」を渡せますが、**`main` のような可変ブランチを本番モジュールの ref にしてはいけません**。昨日の `apply` と今日の `apply` で別物を引いてしまい、再現性が消えます。

### 1.3 入力・出力・参照

子モジュールは「入力（`variable`）を受け取り、リソースを作り、出力（`output`）を返す」純粋な関数のように振る舞うのが理想です。

```hcl
# 呼び出し側：入力を引数で渡す
module "ec2_instance" {
  source  = "terraform-aws-modules/ec2-instance/aws"
  version = "6.0.2"

  name          = "example-instance"
  ami           = data.aws_ami.latest_amazon_linux.id
  instance_type = "t2.micro"
}

# 別リソースから出力を参照：module.<LABEL>.<output>
resource "aws_security_group_rule" "example" {
  security_group_id = module.consul.security_group_id
}
```

出力の参照は `module.<LABEL>.<output>` という固定構文です。**モジュールの外から触れるのは `output` で公開した値だけ**——これが「入出力が明確」というモジュール品質の正体です。内部リソースに外から直接触れる設計は、カプセル化が壊れている兆候です。

### 1.4 モジュールのメタ引数：`for_each` / `count`

同じモジュールを複数インスタンス化したいとき、リソースと同じく `count` / `for_each` がモジュールブロックでも使えます。

```hcl
locals {
  instance_configs = {
    web = { instance_type = "t2.micro" }
    job = { instance_type = "t3.small" }
  }
}

# for_each：キーごとに「異なる設定」で複数モジュールを生成
module "ec2_instance" {
  source   = "terraform-aws-modules/ec2-instance/aws"
  version  = "6.0.2"
  for_each = local.instance_configs

  name          = each.key
  instance_type = each.value.instance_type
}
```

```hcl
# count：同型を「個数」で増やす
module "ec2_instance" {
  source  = "terraform-aws-modules/ec2-instance/aws"
  version = "6.0.2"
  count   = length(local.instance_names)

  name          = local.instance_names[count.index]
  instance_type = "t2.micro"
}
```

**使い分け**：要素が「キーで識別できる集合」なら `for_each`（途中の1個を消しても他がインデックスずれで作り直しにならない）、単なる「N個」なら `count`。本番では `for_each` を第一候補にしてください。`count` はリストの途中要素を削除すると以降が全部置き換わる罠があります。

このほか、モジュールブロックでは `providers`（代替プロバイダ構成の割り当て）、`depends_on`（明示依存）も使えます。

---

## 2. いつモジュールを切り出すか：YAGNI と SRP の線引き

ここが Terraform 設計で最も判断を誤りやすいポイントです。**「とりあえずモジュール化」は技術的負債の典型**です。公式の警告——「単一リソースの薄いラッパを作るな」「モジュールツリーはなるべくフラットに」——を、実務の判断表に落とします。

### 2.1 切り出す / 切り出さない 判断表

| 状況 | 判断 | 根拠 |
| --- | --- | --- |
| 同じリソース群を **3箇所以上**で繰り返している | **切り出す** | DRY。2回は偶然、3回はパターン |
| 「VPC」「Cloud Run サービス」など**1つの概念**を表す | **切り出す** | 抽象度を上げる＝公式が言うモジュールの目的 |
| 環境（stg/prod）で**同じ構成を再現**したい | **切り出す** | 再利用・再現性の核心 |
| `aws_s3_bucket` 1個を薄くラップするだけ | **切り出さない** | 公式が明確に非推奨（thin wrapper） |
| まだ1箇所でしか使っていない | **切り出さない（保留）** | YAGNI。重複が現実になってから |
| 中に `variable` が30個並び、何でも設定できる | **切り出さない / 分割** | 「設定可能」ではなく「責務が多すぎ」のサイン |
| 切り出すと逆に**呼び出し側が読めなくなる** | **切り出さない** | KISS。抽象が理解コストを上回るなら無価値 |

**YAGNI の適用**：「将来別環境でも使うかも」で先回りモジュール化しない。**過剰なモジュール分割は、深いネストと過剰抽象という最悪の負債**を生みます。切り出しは「3度目の重複」か「明確な概念単位」が現れてからで十分間に合います。

**SRP の適用**：モジュールの責務を一文で言えるか試す。「VPC とサブネットを作る」なら OK。「VPC を作り、**かつ** DB を作り、**かつ** IAM を設定する」——“かつ”が出たら責務過多。分割対象です。

### 2.2 標準モジュール構造

公式が推奨する標準構造に従うと、誰が見ても同じ場所に同じものがある状態になります（保守性）。

```text
modules/
  cloud-run-service/
    main.tf          # リソース本体
    variables.tf     # 入力（variable ブロック）
    outputs.tf       # 出力（output ブロック）
    versions.tf      # required_version / required_providers
    README.md        # 使い方・入出力の説明
    examples/        # 呼び出し例（任意だが本番では推奨）
```

ファイル分割はツールが強制するものではありませんが、**「リソースは main、入口は variables、出口は outputs」という置き場所の合意**が、チームの認知コストを劇的に下げます。私の放送事業者案件では **約71のモジュール**でGCP全体（VPC〜Cloud Run〜Cloud SQL〜Cloud Armor〜Secret Manager〜Identity Platform〜Workflows）をコード化しましたが、全モジュールがこの同一構造に揃っているからこそ、71個あっても迷わず読めます。構造の統一は、規模が大きいほど効きます。

---

## 3. 合成（composition）優先：深いネストを避ける

### 3.1 公式の指針：フラットなツリー + 依存性逆転

公式ドキュメントは合成について明確です。**「深くネストしたモジュールツリーの代わりに、モジュール合成（module composition）を使え」「モジュールツリーはなるべくフラットに（1段の子モジュールに留める）」。**

その中核テクニックが**依存性逆転（dependency inversion）**です。公式の言葉：

> 「モジュールが自分の依存物を内部に抱え込み、自前のコピーを生成・管理するのではなく、**ルートモジュールから依存物を受け取る**」

悪い設計（ネスト・癒着）と良い設計（合成・逆転）を並べます。

```hcl
# ❌ 悪い：consul_cluster が自分でネットワークを内部生成する
#    → ネットワークだけ差し替えたい時にモジュールを書き換える羽目になる
module "consul_cluster" {
  source = "./modules/aws-consul-cluster"
  # 内部で aws_vpc / aws_subnet を勝手に作っている…（隠れた依存）
}
```

```hcl
# ✅ 良い：ネットワークは別モジュールで作り、ID を「入力として渡す」
module "network" {
  source = "./modules/aws-network"
  # ...
}

module "consul_cluster" {
  source = "./modules/aws-consul-cluster"

  vpc_id     = module.network.vpc_id      # 依存を外から注入
  subnet_ids = module.network.subnet_ids
}
```

### 3.2 なぜこれが効くのか

公式が挙げる利点は、ソフトウェア設計の原則そのものです。

- **柔軟性（ETC: Easy To Change）**：依存元を「リソース → データソース」へ差し替えても、`consul_cluster` 側は無変更。インタフェース（`vpc_id` を受け取る）に依存しているから。
- **再利用性**：モジュールが小さく疎結合なので、別の組み合わせで使い回せる。
- **明確さ**：ルートモジュールを読めば「どの部品がどう繋がるか」が一望できる。

**深いネストは ETC の敵**です。`A → B → C → D` とネストすると、`D` に値を渡すために `A → B → C` 全部に引数を貫通させる「変数バケツリレー」が発生し、どこを変えれば何が動くか誰にも分からなくなります。**1段フラット + ルートでの合成**が、規模が増えても破綻しない唯一の構造です。

> マルチクラウド抽象（「DNSレコード」「Kubernetesクラスタ」のような共通概念を object 型変数で表す薄い抽象）も公式は紹介していますが、**「最小公倍数（lowest common denominator）」のトレードオフを受け入れる**ことになる、と釘を刺しています。各社固有の強力な機能を捨てて統一する価値が本当にあるか——YAGNI で問い直してください。

---

## 4. ステート運用①：環境分離（stg / prod）

ここからが本番運用の心臓部です。**ステートは本番の現実**——だから環境ごとに、確実に分ける必要があります。

### 4.1 リモートステート + ロック

公式によれば、Terraform は既定でステートをローカルの `terraform.tfstate` に保存しますが、チーム運用ではこれを**リモートバックエンド**（HCP Terraform / S3 / GCS / Azure Blob など）に置きます。リモート化の目的は2つ。

1. **共有**：チーム全員が同じ現実を見る。
2. **ロック**：「同一ステートに対する Terraform の同時実行を防ぐ」。同時 `apply` によるステート破壊・競合を防ぐ生命線です。

バックエンドは `terraform` ブロック内に1つだけ書けます。

```hcl
# GCS バックエンド（放送事業者案件で採用した GCP 構成）
terraform {
  backend "gcs" {
    bucket = "my-org-tfstate-prod"     # state を置くバケット
    prefix = "platform/infra"          # バケット内のパス
  }
}
```

```hcl
# S3 バックエンド + ネイティブロック（林業DX案件で採用した AWS 構成）
terraform {
  backend "s3" {
    bucket       = "my-org-tfstate-prod"
    key          = "platform/infra/terraform.tfstate"
    region       = "ap-northeast-1"
    encrypt      = true
    use_lockfile = true   # S3 ネイティブのロック（旧来の DynamoDB ロック不要）
  }
}
```

> **林業DX案件のメモ**：以前は S3 のロックに DynamoDB テーブルを併用するのが定石でしたが、S3 ネイティブロック（`use_lockfile`）でステートを管理し、ロック専用テーブルという余分なリソースを持たずに同時実行を防いでいます。リソースが1つ減る＝管理対象が1つ減る、は地味に効きます（コスト効率・運用負荷低減）。

**公式の重要な制約**：バックエンドブロックは **変数・local・データソースなどの「名前付き値」を参照できません**。`bucket = var.state_bucket` のような書き方は不可です。環境ごとに値を変えたいときは、後述の**ディレクトリ分離**か、**部分設定（partial configuration）** を使います。

```bash
# 部分設定：backend ブロックは空にしておき、init 時に値を注入する
terraform init -backend-config="bucket=my-org-tfstate-stg" \
               -backend-config="prefix=platform/infra"
# あるいはファイルで：terraform init -backend-config=stg.backend.hcl
```

### 4.2 環境分離：workspaces vs 別ステート（最重要の判断）

「stg と prod をどう分けるか」で、多くのチームが**ワークスペース（workspace）に手を出して後悔します**。公式の指針は明確なので、まず引用します。

> **「Workspaces are not appropriate for system decomposition or deployments requiring separate credentials and access controls.（ワークスペースは、システム分解や、別個の認証情報・アクセス制御を要するデプロイには適さない）」**

つまり公式自身が **「ワークスペースで prod と staging を分けるな」** と言っています。判断表にします。

| 観点 | ワークスペース（`terraform workspace`） | 別ステート（ディレクトリ / バックエンド分離） |
| --- | --- | --- |
| 認証情報の分離 | できない（同一バックエンド・同一認証） | **できる**（prod 用バケット・prod 用ロール） |
| 誤操作の隔離 | `select` し忘れて prod を壊すリスク | ディレクトリが物理的に別＝事故りにくい |
| バックエンド設定の差し替え | 不可（1バックエンド共有） | **可能**（環境ごとに bucket/prefix を変える） |
| アクセス制御 | 環境別の IAM を分けにくい | **環境別の最小権限ロールを割り当て可** |
| 向いている用途 | 同一構成の**短命な並行作業**（機能ブランチの試行、軽い検証） | **本番を含む環境分離（stg/prod）** |
| 公式の立場 | 強い分離には**非推奨** | 強い分離の**推奨形** |

**結論**：**stg / prod は別ステート（ディレクトリ分離）で分ける。** これが公式準拠であり、私の実案件の選択でもあります。

```text
envs/
  stg/
    main.tf            # module "platform" { source = "../../modules/..." }
    backend.tf         # backend "gcs" { bucket = "...-tfstate-stg" }
    terraform.tfvars   # stg 固有の値（インスタンスサイズ等）
  prod/
    main.tf            # 同じモジュールを呼ぶ（再現性）
    backend.tf         # backend "gcs" { bucket = "...-tfstate-prod" }
    terraform.tfvars   # prod 固有の値
modules/               # stg/prod が共有する「構成の定義」
  cloud-run-service/
  network/
  ...
```

ポイントは、**`modules/` は1つ（DRY：構成の単一の真実）／ `envs/<env>/` がそれを呼び出して環境ごとの値を与える（再現性）**という形です。stg で検証した構成をそのまま prod に持ち上げられ、かつ prod のステート・認証・権限は物理的に隔離されます。ワークスペースは「同一構成の使い捨て検証」など、分離が弱くてよい場面に限定して使ってください。

```bash
# ワークスペースを使う数少ない場面の例（強い分離が不要なとき）
terraform workspace new feature-x
terraform workspace select feature-x
terraform workspace list
# 構成内では terraform.workspace で現在のワークスペース名を参照できる
```

### 4.3 ステート間の値の受け渡し：terraform_remote_state

環境やレイヤーをまたいで値を共有したいとき（例：ネットワーク層のステートから VPC ID を取得する）、公式の `terraform_remote_state` データソースで**読み取り専用**に参照できます。

```hcl
# 別ステート（ネットワーク層）の出力を読み取る
data "terraform_remote_state" "network" {
  backend = "gcs"
  config = {
    bucket = "my-org-tfstate-prod"
    prefix = "platform/network"
  }
}

resource "google_cloud_run_v2_service" "api" {
  # 別ステートが output しているネットワーク情報を参照
  # （注：network 層側で output "vpc_connector" を公開していること）
  template {
    vpc_access {
      connector = data.terraform_remote_state.network.outputs.vpc_connector
    }
  }
}
```

これにより、コア基盤チームが「他チームに公開してよい情報だけ」を `output` で晒し、利用側は読み取り専用で受け取る——という**疎結合なチーム分割**が成立します。注意点は、**参照先が `output` で公開した値しか取れない**こと。ここでも「外から触れるのは output だけ」というカプセル化の原則が効いています。

---

## 5. ステート運用②：責務分離でドリフトを防ぐ

ドリフト（drift）とは、**コードが宣言した状態と、実インフラの現実がズレること**です。IaC が壊れる最大の原因はこれです。そして**ドリフトの最大の発生源は「責務の混在」**です。

### 5.1 アプリのデプロイとインフラ変更を混ぜない

最も多いアンチパターンが、**アプリのデプロイ（コンテナイメージの更新）と、インフラ構成の変更を同じ Terraform で回す**ことです。これをやると、

- アプリチームがイメージタグを変えるたびに Terraform が走り、
- その diff にインフラの意図しない変更が紛れ込み、
- 結果として「誰がいつ何を変えたか」が追えなくなる。

私の放送事業者案件での解は、**責務を明確に2系統へ分けること**でした。

| 責務 | 担当ツール | 何を管理するか |
| --- | --- | --- |
| **アプリのデリバリ** | **Cloud Build** | コンテナイメージのビルド & 最新 env の反映 |
| **インフラ構成** | **Terraform** | VPC / Cloud Run の定義 / Cloud SQL / Cloud Armor / Secret Manager 等 |

つまり **「コンテナイメージと最新 env は Cloud Build」「インフラ構成は Terraform」と責務を分離**しました。Terraform は「実行基盤の形」だけを宣言し、その上で動くアプリのバージョンには関与しない。これにより、頻繁に変わるアプリのデプロイが Terraform のステートを揺らさなくなり、**ドリフトの主要因が構造的に消えます**（SRP をインフラ運用に適用した形）。

```hcl
# Terraform は「実行基盤の形」を宣言する。動くイメージのタグは Terraform の管理外。
resource "google_cloud_run_v2_service" "api" {
  name     = "content-api"
  location = var.region

  template {
    containers {
      # ❌ ここに :v1.2.3 のような可変タグを直書きすると、
      #    デプロイのたびに Terraform が diff を検知してドリフト源になる
      image = var.container_image  # 値の供給はデリバリ側に委ね、ライフサイクルを切る
    }
  }

  lifecycle {
    # アプリのデリバリ系が更新する属性を Terraform の管理対象から外す
    ignore_changes = [template[0].containers[0].image]
  }
}
```

`lifecycle { ignore_changes }` で「Terraform が触らない属性」を宣言するのは、責務分離を**コードレベルで保証**する実践です。これがないと、Cloud Build がイメージを更新するたびに Terraform が「元に戻そう」として永遠に diff が出続けます。

### 5.2 モジュールのバージョン固定（再現性）

3章までで述べた `version` / `?ref` の固定は、ドリフト対策でもあります。**モジュールの参照が `main` ブランチだと、上流のモジュールが更新された瞬間、自分の `plan` に身に覚えのない diff が湧く**——これもドリフトの一種です。Registry なら `version`、Git なら `?ref=<tag/SHA>` で必ずピン留めし、バージョン更新は意図的な PR として行ってください。

---

## 6. CI ゲート：plan(tfsec) → apply(権限境界ロール) → drift 検知

ステートと責務を分けたら、最後は**「人間が手元で `apply` しない」**運用に持っていきます。手元 apply は権限・監査・再現性すべての穴です。私の林業DX案件（AWS）では、Terraform を **plan（PRで tfsec）／ apply（権限境界付きロール）／ drift 検知（定期 cron → Issue 起票）** の3ゲートで自動化しました。

### 6.1 PR で plan + tfsec（静的検査ゲート）

PR が立った瞬間、`plan` の差分を可視化し、`tfsec` でセキュリティ静的検査をかけます。**ここでは何も変更しない（read-only）**のが鉄則です。

```yaml
# .github/workflows/terraform-plan.yml（PR時：read-only）
name: terraform-plan
on:
  pull_request:
    paths: ["envs/**", "modules/**"]

permissions:
  contents: read
  id-token: write        # OIDC で鍵レスにロールを引き受ける（後述リンク参照）
  pull-requests: write   # plan 結果を PR にコメント

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC, read-only role)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/tf-plan-readonly
          aws-region: ap-northeast-1

      - uses: hashicorp/setup-terraform@v3

      - run: terraform -chdir=envs/prod init
      - run: terraform -chdir=envs/prod plan -no-color -lock-timeout=60s

      # セキュリティ静的検査（公開バケット・暗号化漏れ等を CI でブロック）
      - name: tfsec
        uses: aquasecurity/tfsec-action@v1.0.0
        with:
          working_directory: .
```

**plan 用ロールは read-only**にしておくのが要点です。PR の段階で書き込み権限を持たせる理由はありません（最小権限）。`apply` の認証（OIDC で鍵を持たずにロールを引き受ける仕組み）は別記事 [GitHub Actions OIDC で鍵レス CI/CD](/blog/github-actions-oidc-keyless-cicd-aws-gcp-guide) に詳しく書きました。**長期のアクセスキーを CI に置かない**——これは交渉の余地のない前提です。

### 6.2 マージで apply（権限境界付きロール）

`main` へのマージ（= レビュー承認済み）でのみ `apply` を実行します。ここで使うロールには**権限境界（permission boundary）**を付け、「Terraform が触ってよいリソースの上限」を IAM レベルで縛ります。

```yaml
# .github/workflows/terraform-apply.yml（main マージ時のみ）
name: terraform-apply
on:
  push:
    branches: ["main"]
    paths: ["envs/**", "modules/**"]

permissions:
  contents: read
  id-token: write

concurrency:
  group: tf-apply-prod   # 同一環境への apply を直列化（ステート競合の予防）
  cancel-in-progress: false

jobs:
  apply:
    runs-on: ubuntu-latest
    environment: production   # GitHub Environments の承認ゲートを噛ませる
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC, boundaried apply role)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          # 権限境界つきの apply 専用ロール。境界外のリソースは触れない。
          role-to-assume: arn:aws:iam::123456789012:role/tf-apply-boundaried
          aws-region: ap-northeast-1

      - uses: hashicorp/setup-terraform@v3
      - run: terraform -chdir=envs/prod init
      - run: terraform -chdir=envs/prod apply -auto-approve -lock-timeout=300s
```

設計の勘どころは3つです。

1. **権限境界ロール**：`apply` ロールに権限境界を付け、想定外のサービス・リソースへの操作を IAM で物理的に遮断する（最小権限・least privilege）。Terraform のコードがどうあれ、境界外は触れない。
2. **直列化**：`concurrency` で同一環境への `apply` を1本に絞り、`-lock-timeout` と併せて**ステートロック競合**を二重に防ぐ。
3. **承認ゲート**：`environment: production` で人間の承認を1枚噛ませ、自動化と統制を両立する。

### 6.3 定期ドリフト検知（cron → Issue 起票）

CI を通したからといってドリフトはゼロになりません。**コンソールでの手動変更・外部要因・権限境界外の事象**で、現実は静かにズレます。だから**定期的に「コードと現実の差」を検査し、ズレを検知したら Issue を立てる**ゲートを置きます。

```yaml
# .github/workflows/terraform-drift.yml（定期実行：現実とのズレを検知）
name: terraform-drift
on:
  schedule:
    - cron: "0 0 * * *"   # 毎日。現実との乖離を放置しない
  workflow_dispatch: {}

permissions:
  contents: read
  id-token: write
  issues: write           # ドリフトを検知したら Issue を起票

jobs:
  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC, read-only role)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/tf-plan-readonly
          aws-region: ap-northeast-1

      - uses: hashicorp/setup-terraform@v3
      - run: terraform -chdir=envs/prod init

      # -detailed-exitcode: 差分なし=0 / 差分あり=2 / エラー=1
      - name: Detect drift
        id: plan
        run: terraform -chdir=envs/prod plan -detailed-exitcode -lock-timeout=60s
        continue-on-error: true

      - name: Open issue on drift
        if: steps.plan.outputs.exitcode == 2
        uses: actions/github-script@v7
        with:
          script: |
            await github.rest.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: "⚠️ Terraform drift detected in prod",
              body: "定期 plan が差分(exit code 2)を検知しました。コードと本番の現実がズレています。手動変更の有無を確認し、コードに反映するか revert してください。",
              labels: ["drift", "infra"],
            });
```

肝は **`terraform plan -detailed-exitcode`** です。差分なしなら exit 0、**差分ありなら exit 2**、エラーは 1 を返すので、これを使って「ズレたら Issue を自動起票」できます。**ドリフトを“気づいたら直す”ではなく、“毎日機械が見つけて起票する”仕組み**に変えるのが本質です。検知用ロールは plan 同様 read-only で十分です（最小権限）。

---

## 7. 本番運用チェックリスト

ここまでを運用視点で1枚に畳みます。新しい Terraform リポジトリを本番化するとき、私が必ず確認する項目です。

- **ステート**：リモートバックエンド（GCS / S3）に置き、ロック有効（S3 は `use_lockfile`）。`terraform.tfstate` をコミットしていない。
- **環境分離**：stg / prod を**別ステート（ディレクトリ分離）**で分けた。ワークスペースで本番を分けていない（公式非推奨）。
- **責務分離**：アプリのデリバリ（イメージ更新）とインフラ変更を分けた。可変属性は `ignore_changes` で Terraform 管理外にした。
- **モジュール**：薄いラッパを作っていない。ツリーはフラット（1段）。依存は外から注入（dependency inversion）。`version` / `?ref` でバージョン固定。
- **CI ゲート**：PR で `plan` + `tfsec`（read-only ロール）。マージで `apply`（権限境界ロール、直列化、承認ゲート）。手元 apply をしていない。
- **認証**：CI は OIDC で鍵レス（長期アクセスキー不在）。`apply` ロールは最小権限 + 権限境界。
- **ドリフト**：定期 `plan -detailed-exitcode` でズレを検知し、Issue 起票。放置していない。

保守性（誰が見ても同じ構造）・再現性（stg をそのまま prod に持ち上げられる）・統制（誰がどの権限で何を変えたか追える）——この3つが揃って初めて「壊れないIaC」です。

---

## 8. まとめ：チートシート

最後に、迷ったときの早見表です。

- **モジュールを切り出す**：3度目の重複、または「VPC」「Cloud Run サービス」など明確な概念単位のとき。**薄いラッパは作らない（公式非推奨）／先回りしない（YAGNI）。**
- **モジュールの構造**：`main.tf` / `variables.tf` / `outputs.tf` / `versions.tf` に統一。入口は `variable`、出口は `output`、外から触れるのは `output` だけ。
- **合成**：ツリーはフラット（1段）。依存物は内部で作らず**外から注入**（dependency inversion）。深いネスト＝変数バケツリレー＝ETC の敵。
- **バージョン固定**：Registry は `version`、Git は `?ref=<tag/SHA>`。`main` 参照は禁止（再現性 / ドリフト防止）。
- **環境分離**：stg / prod は**別ステート（ディレクトリ + バックエンド分離）**。ワークスペースは「強い分離が要らない短命作業」だけ（公式が prod 分離に非推奨）。
- **ロック**：リモートバックエンド + 同時実行ロック（S3 は `use_lockfile`）。CI は `concurrency` + `-lock-timeout` で二重防御。
- **責務分離**：アプリのデリバリ（Cloud Build 等）とインフラ（Terraform）を分ける。可変属性は `ignore_changes`。これがドリフト最大の予防。
- **CI ゲート**：PR=plan+tfsec（read-only）／ merge=apply（権限境界ロール・承認）／ 定期=drift 検知（`-detailed-exitcode`→Issue）。手元 apply は廃止。
- **認証**：OIDC で鍵レス（→[詳細](/blog/github-actions-oidc-keyless-cicd-aws-gcp-guide)）。**コスト最適化は本記事の範囲外**（→[FinOps 編](/blog/aws-terraform-startup-cost-optimization-finops)）。

Terraform は「インフラをコード化する」だけのツールではありません。**モジュールで責務を切り、ステートで本番の現実を分離・ロックし、CI で人間の手元から `apply` を取り上げ、ドリフトを機械に見張らせる**——ここまでやって初めて、規模が増えても壊れない IaC になります。

私は、放送事業者向けの社内AIプラットフォームで GCP 全体を **約71のモジュール**でコード化し、**stg / prod のステートを分離**して、**Cloud Build と Terraform で責務を分けてドリフトを防止**しました。林業DX案件（AWS、経済産業大臣賞受賞プロダクト）では、インフラを **17モジュール**・S3 ネイティブロックで管理し、**plan（PRで tfsec）／ apply（権限境界ロール）／ drift 検知（定期 cron → Issue 起票）** を自動化しています。一人 × 生成AI（Claude Code）で、GCP / AWS をまたいで 100% Terraform のインフラを設計・運用してきました。

**「自社のインフラをどうコード化し、どう壊れない形で運用に乗せるか」——その設計から CI 整備・ドリフト運用まで、一気通貫で伴走できます。** 既存の Terraform が肥大化して触れなくなっている、という相談も歓迎です。お気軽にご連絡ください。

---

### 参考（公式ドキュメント）

- [Modules Overview — Terraform](https://developer.hashicorp.com/terraform/language/modules) — モジュールの定義・ルート/子・ソースの種類
- [Module Blocks（Use modules）— Terraform](https://developer.hashicorp.com/terraform/language/modules/syntax) — `module` ブロック・`source`/`version`・`count`/`for_each`・出力参照
- [Module Sources — Terraform](https://developer.hashicorp.com/terraform/language/modules/sources) — ローカル/Registry/Git の `source` 書式・`?ref` 固定
- [Develop Modules — Terraform](https://developer.hashicorp.com/terraform/language/modules/develop) — 標準モジュール構造・「薄いラッパを作るな」・フラットツリー
- [Module Composition — Terraform](https://developer.hashicorp.com/terraform/language/modules/develop/composition) — 合成・依存性逆転・フラットツリーの指針
- [Backend Configuration — Terraform](https://developer.hashicorp.com/terraform/language/backend) — `terraform { backend ... }`・1バックエンド制約・変数参照不可・部分設定
- [Remote State — Terraform](https://developer.hashicorp.com/terraform/language/state/remote) — リモートステート・ロック・`terraform_remote_state`
- [Workspaces — Terraform](https://developer.hashicorp.com/terraform/language/state/workspaces) — ワークスペースの定義・`terraform.workspace`・本番分離に非推奨という公式警告
