🧩 なぜモジュール設計が「再利用性」を決めるのか
本記事はシリーズ 「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-run、cloud-sql、service-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 = null、vpc_access = null のように、null 既定で機能そのものを無効化できる設計にしておくと、for_each や dynamic ブロックと組み合わせて「設定があるときだけブロックを生成」が綺麗に書けます。
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 Run | v2 | Ingress 制御・プローブ・Direct VPC Egress に対応 |
| VPC 接続 | Direct VPC Egress | VPC Connector より低コスト・高スループット・管理が単純 |
| ロードバランサ | グローバル外部 ALB(Serverless NEG) | マルチリージョン対応・NEG 統合最適・国内でもコスト中立 |
| SSL 証明書 | 構成に合った証明書方式を選ぶ | マルチプロジェクト構成では証明書の検証方式によって相性問題が出る |
| CPU 課金 | リクエスト課金を活かす設定 | 常時アロケーションを避けコストを抑える |
| WIF | impersonation なしを基本 | 認証フローが単純・最小権限 |
| ネットワーク | Shared VPC | NAT コスト集約・ネットワーク中央管理 |
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 | モジュール設計編(本記事) | 再利用可能なモジュールの設計原則 |
| 4 | CI/CD パイプライン編 | 動的 child pipeline・WIF キーレス認証・GCP↔AWS |
| 5 | 運用編 | State drift 解消・障害対応・AI 運用ループ |
🤖 このモジュール群は AI(Claude Code)と組んで設計しています。 「責務分離」「命名規約」「変数の型で守る」といった設計原則は、人間のレビュー観点であると同時に、AI エージェントに守らせるルール(CLAUDE.md / Skill)として明文化しています。AI 駆動で IaC を書く運用は、第 1 回 概要 と ステアリング駆動開発 で扱います。
関連記事
- WIF でキーレス認証 —
gitlab-wifモジュールが実装する認証の詳細 - BtoB SaaS セキュリティ設計総覧 — インフラ設計の全体位置づけ
- ステアリング駆動開発とは:概要 — 本基盤を AI と組んで開発する手法
- Claude Code 7層ハーネスエンジニアリング — 設計原則を AI に守らせるルール化の仕組み
外部資料