CTS-KB

Terraform IaC 実践ガイド:モジュール設計編 — 再利用できるモジュールの作法

⏱ 約 10 分で読めます
#Terraform #IaC #モジュール設計 #GCP #AWS #Cloud Run

🧩 なぜモジュール設計が「再利用性」を決めるのか

本記事はシリーズ 「Terraform IaC 実践ガイド」第 3 回(モジュール設計編) です。第 1 回 概要・第 2 回 準備編 で土台を整えたら、次は 「同じ構成を複数のクライアント・複数の環境で何度も組む」 ためのモジュール設計に入ります。

モジュール設計を誤ると、こうなります。

  • クライアントが増えるたびに cloud-run の定義をコピペし、修正漏れでバグが横展開する
  • 環境固有の値がモジュール内にハードコードされ、stg と prod で別モジュールになる
  • 変数が string だらけで、渡し間違いが apply まで気付けない

逆に、設計が効いていれば 1 つの cloud-run モジュールを、クライアント A の API・クライアント B の API・EC サービス群すべてで同一コードのまま再利用できます。差分は変数だけ。本記事は、実運用のマルチクライアント Terraform 基盤で確立した「再利用できるモジュールの作法」をまとめます。

🗂️ モジュールは 3 層に分ける

まず構成。モジュールを責務の抽象度で 3 層に分離します。

terraform/
├── environments/               # 環境別エントリーポイント(②③を呼ぶ・薄い)
│   ├── stg/                    #   stg/{client-a, auth-stack, app-svc, ...}/
│   └── prod/                   #   prod/{client-a, client-b, auth-stack, ...}/
├── infra/
│   └── prod/infra-bootstrap/   # bootstrap(State バケット・WIF Pool 等/ローカル実行のみ)
└── modules/
    ├── infra/                  # ① 基盤プリミティブ(クラウドの 1 リソース群)
    │   ├── gcp/                #   cloud-run, cloud-sql, multi-service-load-balancer,
    │   │                       #   gitlab-wif, service-account, dns-zone, pub-sub, ...
    │   └── aws/                #   cognito, ses, lightsail, gitlab-oidc, static-site, ...
    ├── clients/                # ② クライアント単位の構成(①を束ねる)
    │   ├── client-a/
    │   └── client-b/
    └── services/               # ③ 横断サービス(業務ドメイン単位でグルーピング)
        └── app-domain/         #   例: EC 事業ドメイン
            └── shared-svc/     #   複数クライアントで共有するサービス

構成のポイント: services/{業務ドメイン}/{サービス} の 2 階層でグルーピングします(フラットに並べない)。また bootstrap だけは modules/ ではなく最上位の infra/prod/infra-bootstrap/ に置き、ローカル実行で土台を作ります(準備編)。

役割
① infraクラウドの 1 リソース群を最小単位で表現cloud-runcloud-sqlservice-account
② clientsクライアント案件 1 件分の構成を組み立てるclient-a(project + WIF + cloud-run + LB)
③ services複数クライアントで共有する横断サービス認証基盤、共通 API

①は「部品」、②③は「組み立て図」 です。environments/{env}/{stack}/ は組み立て図に環境の値を流し込むだけの薄いエントリーポイントに保ちます。

📏 原則 1:単一責任 — 1 モジュール 1 責務

infra 層の各モジュールは、責務を 1 つに絞ります

✅ 良い例❌ 悪い例
cloud-run/ は Cloud Run サービスの定義のみcloud-run/ の中で LB も DNS も SSL も作る
service-account/ は SA 作成と IAM 付与のみservice-account/ が API 有効化まで兼ねる
multi-service-load-balancer/ は LB ルーティングのみLB モジュールが NEG も Cloud Run も内包

責務を絞ると、LB だけ差し替える・SA だけ再利用するといった部分組み替えが効きます。逆に「全部入りモジュール」は、一部だけ使いたいときに必ず破綻します。

単一責任を貫くと、外部 LB(単一サービス用 load-balancer)と複数サービスルーティング用(multi-service-load-balancer)、内部 LB(internal-load-balancer)のように、用途別に小さく割る判断が自然と出てきます。これは「割りすぎ」ではなく、合成側で選べる自由度になります。

🔤 原則 2:命名規約をそろえる

モジュール名・ディレクトリ名

小文字・ハイフン区切りで統一します。

✅ cloud-run                      ❌ cloudRun / cloud_run
✅ multi-service-load-balancer    ❌ multi_svc_lb
✅ gitlab-wif                     ❌ gitlabwif

モジュール内のリソース名

モジュール内ではそのモジュールの主役リソースを汎用名にします。module.cloud_run.this のように、呼び出し側から見て意味が通る名前にするのがコツです。

# モジュールの主役は singular な汎用名(service / this など)
resource "google_cloud_run_v2_service" "service" {
  # ...
}

# 用途が特定される従属リソースは目的を表す名前
resource "google_cloud_run_v2_service_iam_member" "invokers" {
  for_each = toset(var.invokers)   # 複数は for_each で展開
  name     = google_cloud_run_v2_service.service.name
  role     = "roles/run.invoker"
  member   = each.value
}

count ではなく for_each を使うのも規約です。count はリストの途中要素を削除すると以降が全部作り直しになりますが、for_each はキー単位で安定します。

🎛️ 原則 3:variables を「型で守る」

ここがモジュール品質の核心です。string を並べるだけのモジュールは、渡し間違いを apply 直前まで検出できません。型・validation・optional・object を使い倒します。

validation で enum を縛る

variable "ingress" {
  description = "Cloud Run の Ingress 設定"
  type        = string
  default     = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"

  validation {
    condition = contains([
      "INGRESS_TRAFFIC_ALL",
      "INGRESS_TRAFFIC_INTERNAL_ONLY",
      "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER",
    ], var.ingress)
    error_message = "ingress は ALL / INTERNAL_ONLY / INTERNAL_LOAD_BALANCER のいずれか。"
  }
}

タイポは terraform plan の段階で弾かれます。「ロードバランサ経由のみ」のつもりが ALL で全世界公開、といった事故を構造的に防げます。

optional() で「省略可能な構造」に既定値を持たせる

variable "startup_probe" {
  description = "起動プローブ設定(null で無効)"
  type = object({
    path              = string
    port              = optional(number, 8080)
    failure_threshold = optional(number, 30)
    period_seconds    = optional(number, 10)
    timeout_seconds   = optional(number, 5)
  })
  default = null
}

呼び出し側は path だけ渡せば、残りは既定値が補完されます。**「全項目を毎回書かせない」**ことで、モジュールの利用コストが下がります。

object 型でスキーマを明示する

variable "secret_env_vars" {
  description = "Secret Manager から注入する環境変数"
  type = map(object({
    secret_name    = string
    secret_version = string
  }))
  default = {}
}

map(string) のような曖昧な型ではなく、期待する構造をそのまま型で表現します。これにより、IDE 補完・plan 時検証・ドキュメント性の 3 つが同時に手に入ります。

null を「機能 OFF」のスイッチにする

startup_probe = nullvpc_access = null のように、null 既定で機能そのものを無効化できる設計にしておくと、for_eachdynamic ブロックと組み合わせて「設定があるときだけブロックを生成」が綺麗に書けます。

dynamic "startup_probe" {
  for_each = var.startup_probe == null ? [] : [var.startup_probe]
  content {
    # ... var.startup_probe.path 等
  }
}

📤 原則 4:outputs は「次のモジュールの入力」として設計する

出力はログ用ではなく、後段モジュールへのデータ受け渡しとして設計します。

# modules/infra/gcp/cloud-run/outputs.tf
output "service_url" {
  description = "Cloud Run サービスの URL"
  value       = google_cloud_run_v2_service.service.uri
}

output "service_name" {
  description = "Cloud Run サービス名(LB の NEG が参照)"
  value       = google_cloud_run_v2_service.service.name
}

service_name は、後段の LB モジュールが Serverless NEG を作るための入力になります。「誰がこの出力を消費するか」を意識して output を選ぶと、モジュール間が疎結合のまま繋がります。

📌 原則 5:バージョンを固定する

required_version と provider バージョンを必ず明示します。固定しないと、CI ランナーの Terraform/provider が上がった瞬間に plan 差分が暴れます。

terraform {
  required_version = ">= 1.5.0"
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = ">= 6.0"   # メジャーは固定、マイナーは追従
    }
  }
}

リソース選定でもバージョンは効きます。Cloud Run は v1(google_cloud_run_service)ではなく v2(google_cloud_run_v2_service を使います。v2 は Ingress 制御・プローブ・Direct VPC Egress に対応し、google >= 5.0 が前提です。「新しいリソース型を使えるか」は provider バージョン固定とセットで判断します。

🏗️ 原則 6:コンポジション — clients が infra を束ねる

clients 層は、infra 層のモジュールを組み合わせて 1 案件分の構成にします。ポイントは、あるモジュールの output を次のモジュールの input に渡すデータフローです。

# modules/clients/client-a/main.tf
module "project" {
  source = "../../infra/gcp/gcp-project"
  # ...
}

module "gitlab_wif" {
  source     = "../../infra/gcp/gitlab-wif"
  project_id = module.project.project_id     # ← project の output を入力に
}

module "cloud_run" {
  source          = "../../infra/gcp/cloud-run"
  project_id      = module.project.project_id
  service_name    = "api"
  image           = var.docker_image
  service_account = module.gitlab_wif.service_accounts["ci_deployer"]
  ingress         = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"
}

module "load_balancer" {
  source       = "../../infra/gcp/multi-service-load-balancer"
  project_id   = module.project.project_id
  backends = {
    api = { service_name = module.cloud_run.service_name }   # ← cloud_run の output
  }
}

この cloud-run モジュールは、client-a でも client-b でも EC サービス群でも、まったく同じコードで呼ばれます。差分は service_name / image / cpu / memory といった変数だけ。これが「コピペしないマルチクライアント」の正体です。

☁️ 原則 7:マルチクラウドは「抽象化しない」

GCP と AWS を両方扱うとき、誘惑されるのが 「クラウドを隠す共通モジュール」 です。これはほぼ失敗します。クラウドごとにリソースモデルが違いすぎて、抽象化レイヤが「漏れる抽象化」になり、かえって複雑になります。

採用しているのは、抽象化せず、モジュールパスでクラウドを明示する方式です。

# 認証スタックは AWS(Cognito) と GCP(WIF) を「明示的に」併用する
module "cognito" {
  source = "../../../modules/infra/aws/cognito"     # AWS と一目で分かる
  # ...
}

module "wif" {
  source = "../../../modules/infra/gcp/gitlab-wif"  # GCP と一目で分かる
  # ...
}

modules/infra/gcp/modules/infra/aws/ を分け、clients/services 層がどちらのクラウドを使うかをパスで宣言します。「どのクラウドのリソースか」がコードを読むだけで分かることが、マルチクラウドでは抽象化より価値があります。

🧭 主要な設計判断(ADR ダイジェスト)

モジュールの中身を決めた設計判断を、理由つきで残しておきます。モジュール設計は「何を選んだか」より**「なぜ選んだか」**が再利用時に効きます。

判断採用理由
Cloud Runv2Ingress 制御・プローブ・Direct VPC Egress に対応
VPC 接続Direct VPC EgressVPC Connector より低コスト・高スループット・管理が単純
ロードバランサグローバル外部 ALB(Serverless NEG)マルチリージョン対応・NEG 統合最適・国内でもコスト中立
SSL 証明書構成に合った証明書方式を選ぶマルチプロジェクト構成では証明書の検証方式によって相性問題が出る
CPU 課金リクエスト課金を活かす設定常時アロケーションを避けコストを抑える
WIFimpersonation なしを基本認証フローが単純・最小権限
ネットワークShared VPCNAT コスト集約・ネットワーク中央管理

SSL は地味ですが、マルチプロジェクト構成では証明書の検証方式が構成と衝突することがあります。証明書方式は「扱う構成(マルチプロジェクトか・ワイルドカードか)」から逆算して選ぶ、という観点だけ押さえておけば十分です。具体の相性問題は構成ごとに検証してください。

✅ モジュール設計チェックリスト

新しい infra モジュールを切るときの確認項目です。

  • 責務は 1 つに絞れているか(LB と DNS を混ぜていないか)
  • main.tf / variables.tf / outputs.tf に分かれているか
  • enum 的な変数に validation を付けたか
  • 構造化変数は object + optional() でスキーマと既定値を持たせたか
  • 省略可能な機能は null 既定 + dynamic で OFF にできるか
  • output は「後段が消費する値」になっているか
  • required_version と provider version を固定したか
  • 複数生成は count でなく for_each
  • 環境固有の値をハードコードしていないか(すべて変数か)

🎯 まとめ

設計せずにやると設計するとこうなる
クライアントごとにモジュールをコピペ1 モジュールを変数だけ変えて再利用
string だらけで渡し間違いが apply で発覚validation / object で plan 時に検出
全部入りモジュールで部分組み替え不能単一責任で LB だけ・SA だけ差し替え可
クラウドを隠す抽象化が漏れて複雑化パスでクラウドを明示し読めば分かる
provider 更新で plan が暴れるバージョン固定で差分を制御

モジュールは「動けばいい」ではなく 「3 年後に別のクライアントへ再利用できるか」 で設計します。次の第 4 回 CI/CD パイプライン編 では、このモジュール群を 変更スタックだけ自動で plan / apply するパイプラインに載せます。

📚 シリーズ記事

#タイトル内容
1概要マルチクラウド構成の全体像と AI 駆動運用
2準備編アカウント設計・WIF 認証・bootstrap・State 管理
3モジュール設計編(本記事)再利用可能なモジュールの設計原則
4CI/CD パイプライン編動的 child pipeline・WIF キーレス認証・GCP↔AWS
5運用編State drift 解消・障害対応・AI 運用ループ

🤖 このモジュール群は AI(Claude Code)と組んで設計しています。 「責務分離」「命名規約」「変数の型で守る」といった設計原則は、人間のレビュー観点であると同時に、AI エージェントに守らせるルール(CLAUDE.md / Skill)として明文化しています。AI 駆動で IaC を書く運用は、第 1 回 概要ステアリング駆動開発 で扱います。

関連記事

外部資料