Terraformモジュール設計 実践 interface/composition/test/semver 運用

目次

1. この記事について

fig01: 学習動線: 第1弾入門編 → 本記事実践編 → 次弾運用編

前提知識(必須):

本シリーズの位置づけ:

  • 第 1 弾(公開済): モジュール化 + tfstate + OIDC CI/CD 入門
  • 本記事(第 2 弾): モジュール設計 実践編 — interface / composition / terraform test / semver 運用
  • 次弾候補: Terragrunt / Terraform Cloud による multi-env 運用編

1-1. 本記事のゴール

本記事は、Terraform 入門ハンズオン(第 1 弾)を終えて「実務でモジュールを書き始めたが設計に迷っている」方を対象にした中上級向け実践ガイドです。

読者像:
– チームで Terraform を書き始め、module 設計の粒度や interface 定義に迷っている
for_each は書けるが dynamiclocals の使い分けが曖昧
– terraform test を使ったことがなく、module の品質保証に不安がある
– private registry や semver 運用をどう始めればよいか分からない

本記事で身につくスキル:

本記事で身につくスキル:

  • module interface 設計(validation / optional / object 型 / deprecation)
  • locals / for_each / dynamic / count の実践的使い分け
  • module composition 4 パターン(root/child / wrapper / shared / 依存注入)
  • terraform test (1.6+) による module 単体テスト記述
  • private module registry(S3 / Git tag / Terraform Cloud)の採用判断
  • monolith → module 段階的リファクタ手順
  • semver + renovate による module version 運用

1-2. 第 1 弾との棲み分け

第 1 弾と本記事の役割分担は明確です。第 1 弾が「Terraform を初めてチームで使えるレベルにする」ことを目的としていたのに対し、本記事は「実務で通用する module 設計・合成・テスト・配布・運用」を 1 本で完結させることを目的としています。

領域第 1 弾(1208)本記事(第 2 弾)
module 構文基礎 / ローカル呼び出し✅ 解説済前提として参照のみ
S3 backend / DynamoDB lock✅ 解説済対象外
workspace(dev/stg/prod)✅ 解説済対象外
GitHub Actions + OIDC CI/CD✅ 解説済対象外
input validation / optional / object 型未収録§2 で正面解説
locals / for_each / dynamic 実践一部のみ§3 で正面解説
module composition 4 パターン未収録§4 で体系化
terraform test (1.6+) native framework未収録§5 で正面解説
private registry / semver 運用一部のみ§6/§8 で解説
monolith → module リファクタ実例未収録§7 で実例

第 1 弾をまだ読んでいない方へ: まず第 1 弾を完了させてから本記事に戻ることを強くおすすめします。本記事は module 構文・S3 backend・OIDC CI/CD の知識を前提として進みます。

1-3. ハンズオン題材と環境

ハンズオン題材: VPC + EC2 (t2.micro) + S3 の軽量 3 モジュール構成。NAT Gateway を使わないため料金は月 $1 以下(EC2 が無料枠内なら実質 $0.03)です。

terraform-module-design-practice/
├── main.tf # root で 3 module を呼び出す
├── terraform.tfvars.example
├── modules/
│├── network/  # VPC + public subnet × 2AZ + IGW
│├── compute/  # EC2 (t2.micro) + security group
│└── storage/  # S3 bucket + versioning
└── tests/
 ├── network.tftest.hcl
 └── compute.tftest.hcl

Terraform バージョン要件:

terraform {
  required_version = "~> 1.9"
  required_providers {
 aws = {
source  = "hashicorp/aws"
version = "~> 5.0"
 }
  }
}

前提チェックリスト:

terraform version# 1.9.x 以上であること
aws --version # v2 以上
aws sts get-caller-identity  # IAM 認証確認(VPC/EC2/S3 の Read+Write 必要)
git --version # tag / branch 操作が必要

1-4. 所要時間と料金

項目目安
初回(環境構築込み)120〜150 分
2 回目以降(init + test + apply + destroy)40〜60 分
全破棄(terraform destroy)約 5 分
EC2 t2.micro 月額(無料枠外)約 $8.35
EC2 t2.micro 月額(無料枠内)$0
S3 + データ転送 月額合計約 $0.03

重要: ハンズオン完了後は必ず terraform destroy を実行してリソースを削除してください。EC2 を起動したまま放置すると料金が発生します(§9 に destroy 手順を記載)。NAT Gateway は本記事では 使用しません(月 $32 相当の節約)。

1-5. 本記事を読み進める前に

本記事の各セクションは独立した設計テーマを扱いますが、§3(locals/for_each/dynamic)→ §4(composition)→ §5(terraform test)の順に関連性が高いため、順番に読むことを推奨します。

各セクションの末尾には「アンチパターン」を収録しています。実務でよくある誤りを把握しておくことで、コードレビューの質が上がります。

本記事の読み方のコツ: 各セクションのハンズオンは独立して実行可能ですが、§2 → §3 → §4 → §5 の流れで進めると「設計 → 実装 → テスト」の体系が身につきます。§6〜§8 は運用フェーズの知識として後から戻って参照することもできます。

前提記事を読んでいない場合: module ブロックの基本構文・terraform.tfvars・S3 backend・workspace の基礎は第 1 弾(WP ID:1208)で解説しています。本記事ではこれらを既知として進めます。

それでは §2 から module interface 設計の実践に入ります。まず各 variable の validation を丁寧に書くところから始めましょう。


2. module interface 設計 — input validation / optional / object 型 / deprecation

fig02: module I/O contract — variable/validation/optional/output の全体像

module を「関数」と見立てると、variable は引数、output は戻り値です。関数の引数に型チェックや値検証がなければバグが混入しやすいのと同様に、Terraform module でも input/output の契約(interface)を厳密に定義することで、呼び出し元が安全に使えるブラックボックスになります。

本セクションでは VPC + EC2 + S3 の 3 モジュールを題材に、実務で必要な interface 設計パターンを網羅します。

2-1. input validation — validation block の 3 パターン

Terraform 0.13 から使える validation block は、condition が false の場合に error_message を出して terraform plan を中断します。バグを apply 前に捕捉できる最初の防衛線です。

パターン 1: IP アドレス・CIDR 系

# modules/network/variables.tf
variable "vpc_cidr" {
  type  = string
  description = "VPC の CIDR ブロック(例: 10.0.0.0/16)"

  validation {
 condition  = can(cidrnetmask(var.vpc_cidr))
 error_message = "vpc_cidr は有効な CIDR 表記(例: 10.0.0.0/16)で指定してください。"
  }
}

variable "subnet_cidrs" {
  type  = list(string)
  description = "パブリックサブネットの CIDR リスト(2 件以上・VPC 内に収まること)"

  validation {
 condition  = length(var.subnet_cidrs) >= 2
 error_message = "subnet_cidrs は 2 件以上指定してください(マルチ AZ 構成のため)。"
  }

  validation {
 condition  = alltrue([for cidr in var.subnet_cidrs : can(cidrnetmask(cidr))])
 error_message = "subnet_cidrs の各要素は有効な CIDR 表記で指定してください。"
  }
}

パターン 2: enum 系(許可値リストチェック)

# modules/compute/variables.tf
variable "instance_type" {
  type  = string
  description = "EC2 インスタンスタイプ(t2.micro / t3.small / t3.medium)"
  default  = "t2.micro"

  validation {
 condition  = contains(["t2.micro", "t3.small", "t3.medium"], var.instance_type)
 error_message = "instance_type は t2.micro / t3.small / t3.medium のいずれかを指定してください。"
  }
}

variable "environment" {
  type  = string
  description = "デプロイ環境(dev / stg / prod)"

  validation {
 condition  = contains(["dev", "stg", "prod"], var.environment)
 error_message = "environment は dev / stg / prod のいずれかを指定してください。"
  }
}

パターン 3: 複合条件(AND / OR の組み合わせ)

# modules/storage/variables.tf
variable "bucket_name" {
  type  = string
  description = "S3 バケット名(3-63 文字・小文字英数字とハイフンのみ)"

  validation {
 condition = (
length(var.bucket_name) >= 3 &&
length(var.bucket_name) <= 63 &&
can(regex("^[a-z0-9][a-z0-9-]*[a-z0-9]$", var.bucket_name))
 )
 error_message = "bucket_name は 3-63 文字、小文字英数字とハイフンのみ、先頭末尾は英数字で指定してください。"
  }
}

2-2. optional() と object 型のネスト設計

Terraform 1.3 から使える optional() は、object 型の特定フィールドを省略可能にしつつデフォルト値を持たせられます。「設定が多い variable をまとめたいが、全項目を必須にしたくない」という実務ニーズに応えるパターンです。

# modules/compute/variables.tf
variable "compute_config" {
  type = object({
 instance_type = string
 ami_id  = string
 key_name= optional(string, null) # SSH キー(省略可)
 root_volume_size = optional(number, 20) # GB(省略時 20 GB)
 enable_eip = optional(bool, false)# Elastic IP(省略時 false)
 tags = optional(map(string), {})  # 追加タグ(省略時 空 map)
  })
  description = "EC2 インスタンスの設定。key_name / root_volume_size / enable_eip / tags は省略可能。"
}

呼び出し側(root の main.tf)では省略可能フィールドを自由に省けます:

# main.tf(root)
module "compute" {
  source = "./modules/compute"

  compute_config = {
 instance_type = "t2.micro"
 ami_id  = data.aws_ami.amazon_linux.id
 # key_name / root_volume_size / enable_eip / tags は省略(defaults 適用)
  }
}

module 内部では var.compute_config.root_volume_size のように参照できます:

# modules/compute/main.tf
resource "aws_instance" "main" {
  ami  = var.compute_config.ami_id
  instance_type = var.compute_config.instance_type
  key_name= var.compute_config.key_name

  root_block_device {
 volume_size = var.compute_config.root_volume_size
  }

  tags = merge(
 { Name = "compute-${var.environment}" },
 var.compute_config.tags
  )
}

2-3. nullable = falsesensitive = true の使い分け

# modules/compute/variables.tf
variable "db_password" {
  type  = string
  description = "DB 接続パスワード(Secrets Manager ARN または直接値)"
  sensitive= true# plan/apply ログ・state ファイルで値をマスク
  nullable = false  # null を許容しない(必ず値を要求)
}

variable "allowed_cidr_blocks" {
  type  = list(string)
  default  = null  # null = 無制限(本番では必ず絞る)
  nullable = true  # null を許容する
}

sensitive = true の効果と注意点:
terraform plan / apply の標準出力で (sensitive value) と表示される
terraform.tfstate の JSON 内には平文で記録される(state ファイル自体の暗号化が別途必要)
– output に sensitive な variable を渡す場合、output 側も sensitive = true を付けること

2-4. 条件付き output と sensitive output

# modules/compute/outputs.tf
output "instance_id" {
  description = "EC2 インスタンス ID"
  value = aws_instance.main.id
}

output "public_ip" {
  description = "パブリック IP(enable_eip = true の場合のみ有効)"
  value = var.compute_config.enable_eip ? aws_eip.main[0].public_ip : aws_instance.main.public_ip
}

output "private_ip" {
  description = "プライベート IP"
  value = aws_instance.main.private_ip
}

# sensitive output の例(DB パスワードを呼び出し元へ渡す場合)
output "db_connection_string" {
  description = "DB 接続文字列(sensitive)"
  value = "postgresql://admin:${var.db_password}@${aws_instance.main.private_ip}:5432/mydb"
  sensitive= true
}

2-5. deprecated argument の段階的廃止パターン

module のインターフェースを変更する際、いきなり variable を削除するとすべての呼び出し元が壊れます。deprecation 表明 → 次 MAJOR で削除、の 2 ステップが安全です。

# modules/network/variables.tf

# v1.x: 旧変数(DEPRECATED)
variable "vpc_name" {
  type  = string
  description = "DEPRECATED: name_prefix を使用してください。v2.0 で削除予定。"
  default  = null# 省略可能にして呼び出し元の即時対応を強制しない
}

# v1.x 以降: 新変数(推奨)
variable "name_prefix" {
  type  = string
  description = "リソース名のプレフィックス(例: myapp-prod)"
  default  = null
}

# 内部で新旧を吸収する locals
locals {
  # vpc_name が残っていれば警告しつつ採用、なければ name_prefix を使う
  resolved_name_prefix = coalesce(var.name_prefix, var.vpc_name, "terraform-module")
}

呼び出し側への移行案内(CHANGELOG.md に記載する内容):

## v1.3.0 (2026-04-21)
### Deprecated
- `variable "vpc_name"` は非推奨。`name_prefix` に移行してください。v2.0 で削除予定。

### Migration
# Before: module "network" { vpc_name = "myapp-prod" }
# After:  module "network" { name_prefix = "myapp-prod" }

2-6. interface 設計のアンチパターン

実務でよく見る NG 例と修正方法をまとめます。

アンチパターン問題修正方法
type = any を多用型チェックが無効化・計画外の型が入り込む必ず具体的な型(string/list/object)を指定
validation なし誤った値で apply 後にリソース側エラーvalidation block で plan 段階で検出
output に description なしterraform output の意味が不明全 output に description を必須化
sensitive output を非 sensitive で渡すログにパスワードが露出連鎖する output は全て sensitive = true
旧変数を警告なしに削除呼び出し元が突然壊れるdeprecation → 1 MAJOR 後に削除の 2 ステップ

本セクションで設計した interface を §3 の for_each / dynamic 実装、§4 の composition パターン、§5 の terraform test で検証していきます。

3. locals / for_each / dynamic / count の実践的使い分け

fig03: count vs for_each 判定フローチャート

第 1 弾(1208)で for_each の基本を学んだ方も、実務では「for_each と count はどちらを使うべきか」「dynamic block はいつ使うか」「locals は何のために使うか」という疑問にぶつかります。本セクションでは VPC + EC2 モジュールを題材に、4 つの機能の実践的な使い分けを整理します。

3-1. locals の 3 用途

locals は計算結果に名前を付けるための仕組みです。冗長な式を繰り返す必要がなくなり、変更箇所が 1 か所に集約されます。実務では主に 3 つの用途で使います。

用途 (a): 共通タグの集約

# modules/network/main.tf
locals {
  common_tags = {
 Project  = var.project_name
 Environment = var.environment
 ManagedBy= "terraform"
  }
}

resource "aws_vpc" "main" {
  cidr_block = var.vpc_cidr
  tags = merge(local.common_tags, { Name = "${var.project_name}-vpc" })
}

resource "aws_subnet" "public" {
  for_each = toset(var.availability_zones)
  vpc_id= aws_vpc.main.id
  cidr_block  = var.subnet_cidr_map[each.key]
  availability_zone = each.key
  tags  = merge(local.common_tags, { Name = "${var.project_name}-public-${each.key}" })
}

用途 (b): 条件付き派生値の計算

locals {
  # 本番環境のみ削除保護を有効化
  enable_deletion_protection = var.environment == "prod" ? true : false

  # enable_eip が true の場合のみ EIP リソースのカウントを 1 にする
  eip_count = var.enable_eip ? 1 : 0

  # name_prefix が指定されていればそれを使い、なければ project + environment で生成
  resolved_name_prefix = coalesce(var.name_prefix, "${var.project_name}-${var.environment}")
}

用途 (c): for 式による map 生成(複雑な変換)

variable "subnets" {
  type = list(object({
 name  = string
 cidr_block  = string
 availability_zone = string
  }))
}

locals {
  # list(object) を map(object) に変換(for_each で使うため)
  subnet_map = { for s in var.subnets : s.name => s }

  # 全サブネットの CIDR だけを抽出したリスト
  subnet_cidrs = [for s in var.subnets : s.cidr_block]
}

resource "aws_subnet" "main" {
  for_each = local.subnet_map
  vpc_id= aws_vpc.main.id
  cidr_block  = each.value.cidr_block
  availability_zone = each.value.availability_zone
  tags  = merge(local.common_tags, { Name = each.key })
}

3-2. for_each vs count の判定基準

for_eachcount の使い分けは「リストの順序が将来変わりうるか?」で判定します。

┌────────────────────────────────────────┐
│ 複数リソースを動的に作りたい  │
└──────────────┬─────────────────────────┘
│
  ┌──────────────────▼──────────────────────┐
  │  リストの順序・要素数が将来変わりうるか? │
  └──────────┬──────────────┬───────────────┘
 │ Yes │ No
  ┌────▼────┐ ┌────▼────┐
  │for_each │ │  count  │
  └─────────┘ └─────────┘
  map/set で フラグ的な
  キー管理0/1 切替

for_each を使うべき場面(順序・要素が変わりうる):

# AZ の数や構成が変わっても既存リソースが destroy されない
resource "aws_subnet" "public" {
  for_each = toset(var.availability_zones)  # ["ap-northeast-1a", "ap-northeast-1c"]

  vpc_id= aws_vpc.main.id
  cidr_block  = var.subnet_cidr_map[each.key]
  availability_zone = each.key
}

toset() を使うと list を set に変換して重複排除とキー化を同時に行います。

count を使うべき場面(0/1 フラグ的な切替):

# EIP は有効/無効の 2 択のみで「順序変化」は起きない
resource "aws_eip" "main" {
  count  = var.enable_eip ? 1 : 0
  domain = "vpc"
  tags= merge(local.common_tags, { Name = "${local.resolved_name_prefix}-eip" })
}

for_each に list を直接渡すアンチパターン:

# NG: list(string) を直接渡すと Terraform がエラー
resource "aws_subnet" "bad" {
  for_each = var.availability_zones  # Error: for_each は set か map を要求
}

# OK: toset() で変換
resource "aws_subnet" "good" {
  for_each = toset(var.availability_zones)
}

each.key / each.value の参照パターン(map of object):

variable "security_group_rules" {
  type = map(object({
 from_port= number
 to_port  = number
 protocol = string
 cidr_blocks = list(string)
  }))
  default = {
 http  = { from_port = 80,  to_port = 80,  protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] }
 https = { from_port = 443, to_port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] }
 ssh= { from_port = 22,  to_port = 22,  protocol = "tcp", cidr_blocks = ["10.0.0.0/8"] }
  }
}

resource "aws_security_group_rule" "ingress" {
  for_each = var.security_group_rules
  security_group_id = aws_security_group.main.id
  type  = "ingress"
  from_port= each.value.from_port# map の value オブジェクトのフィールドを参照
  to_port  = each.value.to_port
  protocol = each.value.protocol
  cidr_blocks = each.value.cidr_blocks
  description = "Allow ${each.key}"# map の key をそのまま使える
}

3-3. dynamic block の 2 用途

dynamic block は、resource の内部ブロックを動的に繰り返すための仕組みです。通常の for_each(リソース全体を繰り返す)と違い、ひとつのリソース内のサブブロックを可変長にします。

用途 (a): ingress ルールを可変長で設定(security group)

# modules/compute/main.tf
resource "aws_security_group" "main" {
  name  = "${local.resolved_name_prefix}-sg"
  description = "Compute module security group"
  vpc_id= var.vpc_id

  dynamic "ingress" {
 for_each = var.security_group_rules  # map(object) をループ
 content {
from_port= ingress.value.from_port
to_port  = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
description = "Allow ${ingress.key}"
 }
  }

  egress {
 from_port= 0
 to_port  = 0
 protocol = "-1"
 cidr_blocks = ["0.0.0.0/0"]
  }

  tags = merge(local.common_tags, { Name = "${local.resolved_name_prefix}-sg" })
}

用途 (b): オプション設定ブロックの条件付き展開

# root_block_device は optional な設定をまとめて条件付き展開
resource "aws_instance" "main" {
  ami  = var.compute_config.ami_id
  instance_type = var.compute_config.instance_type

  # enable_custom_volume = true の場合のみ root_block_device ブロックを展開
  dynamic "root_block_device" {
 for_each = var.compute_config.root_volume_size != 20 ? [1] : []
 content {
volume_size = var.compute_config.root_volume_size
volume_type = "gp3"
encrypted= true
 }
  }

  tags = merge(local.common_tags, { Name = "${local.resolved_name_prefix}-ec2" })
}

for_each = [1] という書き方は「条件が true なら 1 回だけブロックを展開する」定番パターンです。

3-4. アンチパターン集

アンチパターン問題修正方法
for_eachcount を同一 resource で混在Terraform がエラーどちらか一方のみ使う
for_each = var.list(list を直接渡す)型エラーtoset(var.list) で変換
dynamic 内で dynamic をネストコードが複雑化・デバッグ困難locals でデータ変換してフラット化
count で index を key に使う要素削除時に後続リソースが destroy/recreatefor_each + toset() に切り替える
locals を使わず同じ式を 10 回書く変更箇所が分散してバグの温床locals に集約して 1 か所で管理

次の §4 では、これらの実装テクニックを使って複数 module を組み合わせる composition パターンを学びます。

4. module composition patterns — root/child / wrapper / shared / 依存注入

fig04: module composition 4パターン比較

§2・§3 で interface 設計と for_each/dynamic を習得した。次は 複数 module をどう組み合わせるか の設計判断だ。本セクションでは現場で繰り返し登場する 4 つの composition pattern を、VPC + EC2 + S3 の 3 module を題材に体系化する。

本セクションで扱う 4 パターン

  • Pattern A: root/child 分離 — root が child を呼び出す基本形
  • Pattern B: wrapper module — 社内標準(タグ・暗号化)を強制する薄い中間層
  • Pattern C: shared module — 複数 root から共有参照される共通 module
  • Pattern D: dependency injection — module 内 data source 禁止・全て引数で注入

4-1. Pattern A: root/child 分離

最も基本的なパターン。main.tf に直接リソースを書くのをやめ、module ブロックで child を呼び出すことで変更単位を分離する。

terraform-project/
├── main.tf← root module
└── modules/
 ├── vpc/
 ├── ec2/
 └── s3/
# root/main.tf — VPC → EC2 の output を受け渡す
module "vpc" {
  source = "./modules/vpc"

  vpc_cidr  = var.vpc_cidr
  availability_zones = var.availability_zones
  environment  = var.environment
}

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

  vpc_id  = module.vpc.vpc_id# 前段 output を参照
  subnet_ids = module.vpc.private_subnet_ids
  environment = var.environment
}

module.vpc.vpc_id を参照するだけで暗黙的な依存関係が成立し、depends_on は不要だ。設計の原則: 変更ライフサイクルが異なるリソースは child を分ける。VPC と EC2 を同じ child に入れると、EC2 変更時に VPC の plan も走る。

4-2. Pattern B: wrapper module

社内標準(タグ付け・暗号化ポリシー)を全チームに強制する薄い中間層。公式 module を直接呼ばず社内 wrapper を経由させることで「必ず company_name タグが付く」「S3 は必ず KMS 暗号化される」を一元管理できる。

# modules/company-s3/main.tf — S3 を社内標準でラップ
module "base_s3" {
  source  = "terraform-aws-modules/s3-bucket/aws"
  version = "~> 4.0"

  bucket = var.bucket_name

  server_side_encryption_configuration = {
 rule = {
apply_server_side_encryption_by_default = {
  sse_algorithm = "aws:kms"
}
 }
  }

  tags = merge(var.extra_tags, {
 ManagedBy= "terraform"
 CompanyName = "mycompany"
 CostCenter  = var.cost_center  # 必須パラメータ・呼び出し側で省略不可
  })
}
# root からは company-s3 を呼ぶだけ——暗号化・タグは自動保証
module "logs" {
  source = "./modules/company-s3"

  bucket_name = "mycompany-access-logs-${var.environment}"
  cost_center = "platform"
}

wrapper が効く場面: 複数チームが同一 AWS アカウントを使い、タグ欠落がコスト配賦ミスに直結する場合。外部 module のバージョン固定も wrapper 内で一元管理できる。

4-3. Pattern C: shared module

複数の root module から共有参照される共通 module。environments/prodenvironments/staging が同じ VPC module を参照することで定義の重複を解消する。

my-project/
├── environments/
│├── prod/main.tf← ../../modules/vpc を参照
│└── staging/main.tf← ../../modules/vpc を参照(同じ module)
└── modules/
 └── vpc/  ← shared module
# environments/prod/main.tf
module "vpc" {
  source = "../../modules/vpc"

  vpc_cidr  = "10.0.0.0/16"
  availability_zones = ["ap-northeast-1a", "ap-northeast-1c"]
  environment  = "prod"
}
# environments/staging/main.tf
module "vpc" {
  source = "../../modules/vpc"# 同じ shared module・別の変数値

  vpc_cidr  = "10.1.0.0/16"
  availability_zones = ["ap-northeast-1a"]
  environment  = "staging"
}

shared module の変更は全 environment に影響する。破壊的変更は semver で管理(§8 で詳述)し、CI で全 environment の terraform plan を確認してからマージする運用が推奨だ。

4-4. Pattern D: 依存注入(dependency injection)

最も見落とされやすいが最も重要なパターン。module 内部で data source を使うと AWS 環境の現在状態に直接依存し、terraform test(§5)でモックを注入できなくなる。

# NG: module 内部で data source を参照——テストが困難
data "aws_vpc" "main" {
  tags = { Environment = var.environment }
}
resource "aws_instance" "this" {
  subnet_id = tolist(data.aws_vpc.main.private_subnets)[0]
}
# OK: 依存注入——vpc_id と subnet_ids を引数で受け取る
# modules/ec2/variables.tf
variable "vpc_id" {
  type  = string
  description = "VPC ID injected from parent module"
}
variable "subnet_ids" {
  type  = list(string)
  description = "Subnet IDs injected from parent module"
}

# modules/ec2/main.tf
resource "aws_instance" "this" {
  subnet_id = var.subnet_ids[0]  # data source ではなく引数から取得
  # ...
}

DI の利点は 3 つ: ① terraform test で任意の値を変数から注入できる / ② 同じ EC2 module を prod・staging・DR など異なる VPC で再利用できる / ③ terraform plandata source の API 読み取り結果に左右されない。

4-5. アンチパターンと判断マトリクス

3 つのアンチパターン

  • module 内 data source 濫用: Pattern D(DI)で解決。module 内では data source を禁止し、全て変数で受け取る。
  • module 間の循環参照: A が B の output を使い B が A の output を使う循環は Terraform がエラーにする。共通部分を新たな shared module に切り出すことで解消。
  • wrapper の多重化: wrapper が別の wrapper を呼ぶ二重構造はデバッグが困難になる。wrapper は最大 1 層に留める。
状況推奨パターン
最初の module 化(1 環境・小チーム)Pattern A のみ
社内標準タグ・暗号化を全プロジェクトに強制Pattern B を追加
prod/staging など複数環境で同じインフラを定義Pattern C を追加
terraform test を CI に組み込みたいPattern D を全 module に適用
中規模以上・複数チームA + B + C + D を組み合わせる

Pattern D(DI)はすべての状況で適用する

DI はパターンというより設計原則だ。A・B・C のどれを採用するにしても、module 内部の data source 依存は避けること。DI を守るだけで terraform test(§5)の導入コストが大幅に下がる。

§4 で 4 つの composition pattern を体系化した。次の §5 では、これらの module を terraform test (1.6+) でどう単体テストするか——特に Pattern D(DI)との組み合わせ——を、実際に動く .tftest.hcl を書いて動作確認する。

5. terraform test (1.6+) による module 単体テスト

fig05: terraform test 実行フロー (run block → assert)

Terraform 1.6 で native の testing framework が追加され、.tftest.hcl ファイルで module の単体テストを書けるようになった。従来は Go ベースの terratest が使われていたが、native framework は Terraform の構文だけで完結し Go の知識が不要だ。command = plan のみなら実リソース不要・料金 $0 で CI に常時組み込める。

§4 の Pattern D(依存注入)を守っておくと module 内に data source がなく変数から全て注入できるため、テストケースの variables ブロックだけで完全な制御が可能になる。

5-1. テストファイルの配置

.tftest.hcl ファイルは 2 か所に置ける。

modules/vpc/
├── main.tf
├── variables.tf
├── outputs.tf
└── tests/  ← 推奨: tests/ サブディレクトリ
 └── vpc.tftest.hcl

terraform testtests/ ディレクトリと module root の両方を自動検索する。複数のテストファイルがある場合は -filter フラグで絞り込める。

# 全テストファイルを実行
terraform test

# 特定のファイルのみ実行
terraform test -filter=tests/vpc.tftest.hcl

5-2. .tftest.hcl の基本構造

mock_provider "aws" {}# AWS API を呼ばずにテスト(Terraform 1.7+)

run "テスト名" {# run block: 1 テストケース(複数書ける)
  command = plan# plan または apply

  variables {# このケースで使う変数値
 vpc_cidr = "10.0.0.0/16"
  }

  assert {# 合否判定(複数書ける)
 condition  = output.vpc_cidr == "10.0.0.0/16"
 error_message = "VPC CIDR should match the input"
  }
}

run ブロックは上から順に実行される。apply の場合は前の run の state が次の run に引き継がれる(plan のみなら state なし)。

5-3. VPC module のテストを実際に書く

§4 で作成した VPC module(variables.tfvalidation ブロック付き)をテストする。

VPC module の構成(復習)

# modules/vpc/variables.tf
variable "vpc_cidr" {
  type = string
  validation {
 condition  = can(cidrhost(var.vpc_cidr, 0))
 error_message = "vpc_cidr must be a valid CIDR block."
  }
}

variable "environment" {
  type = string
  validation {
 condition  = contains(["prod", "staging", "dev"], var.environment)
 error_message = "environment must be one of: prod, staging, dev."
  }
}

variable "availability_zones" {
  type = list(string)
  validation {
 condition  = length(var.availability_zones) >= 1
 error_message = "At least one availability zone must be specified."
  }
}

テストファイル(実際に動作確認済み)

# modules/vpc/tests/vpc.tftest.hcl

mock_provider "aws" {}

# -------------------------------------------------------
# run 1: 基本的な VPC が plan で正しく定義されるか
# -------------------------------------------------------
run "basic_vpc_plan" {
  command = plan

  variables {
 vpc_cidr  = "10.0.0.0/16"
 environment  = "staging"
 availability_zones = ["ap-northeast-1a", "ap-northeast-1c"]
  }

  assert {
 condition  = output.vpc_cidr == "10.0.0.0/16"
 error_message = "VPC CIDR should match the input variable"
  }

  assert {
 condition  = output.subnet_count == 2
 error_message = "Subnet count should match the number of AZs"
  }
}

# -------------------------------------------------------
# run 2: prod 環境で 3 AZ のサブネットが計画されるか
# -------------------------------------------------------
run "prod_environment_plan" {
  command = plan

  variables {
 vpc_cidr  = "10.0.0.0/16"
 environment  = "prod"
 availability_zones = ["ap-northeast-1a", "ap-northeast-1c", "ap-northeast-1d"]
  }

  assert {
 condition  = output.subnet_count == 3
 error_message = "Prod environment should create 3 subnets for 3 AZs"
  }
}

# -------------------------------------------------------
# run 3: 無効な environment 値で validation が発動するか(negative test)
# -------------------------------------------------------
run "invalid_environment_fails" {
  command = plan

  variables {
 vpc_cidr  = "10.0.0.0/16"
 environment  = "production"# "prod" が正解・この値は無効
 availability_zones = ["ap-northeast-1a"]
  }

  expect_failures = [
 var.environment,# validation block の発動を期待
  ]
}

# -------------------------------------------------------
# run 4: 無効な CIDR で validation が発動するか(negative test)
# -------------------------------------------------------
run "invalid_cidr_fails" {
  command = plan

  variables {
 vpc_cidr  = "not-a-cidr"
 environment  = "dev"
 availability_zones = ["ap-northeast-1a"]
  }

  expect_failures = [
 var.vpc_cidr,
  ]
}

5-4. terraform test の実行確認

cd modules/vpc
terraform init
terraform test -filter=tests/vpc.tftest.hcl

実行結果(Terraform v1.9.8 で確認済み):

tests/vpc.tftest.hcl... in progress
  run "basic_vpc_plan"... pass
  run "prod_environment_plan"... pass
  run "invalid_environment_fails"... pass
  run "invalid_cidr_fails"... pass
tests/vpc.tftest.hcl... tearing down
tests/vpc.tftest.hcl... pass

Success! 4 passed, 0 failed.

4 ケース全て pass。mock_provider "aws" {} により AWS API を一切呼び出さず、plan 差分のみで検証できている。

5-5. mock_provider(Terraform 1.7+)

mock_provider "aws" {} を宣言すると、AWS API を呼ばずに aws_* リソースの全属性にモック値が自動割り当てられる。command = apply のテストでも実リソースを作成せず output を assert できる。output に具体的な値(例: vpc-mock12345678)を期待する場合は mock_resourcedefaults ブロックで明示する。

mock_provider "aws" {
  mock_resource "aws_vpc" {
 defaults = {
id = "vpc-mock12345678"
 }
  }
}

run "apply_with_mock" {
  command = apply# mock_provider があるので実リソースは作成されない

  variables {
 vpc_cidr  = "10.0.0.0/16"
 environment  = "dev"
 availability_zones = ["ap-northeast-1a"]
  }

  assert {
 condition  = output.vpc_id != ""
 error_message = "vpc_id should not be empty"
  }
}

推奨運用: 日常 CI では空の mock_provider + command = plan で料金 $0 の高速テスト。週次 nightly run では実 AWS + command = apply で本物のリソース検証を使い分ける。

5-6. CI 統合と注意点

terraform test は 1 ケースでも失敗すると exit code 1 を返す。GitHub Actions では hashicorp/setup-terraform@v3 で Terraform をインストールし、terraform init && terraform test -filter=tests/vpc.tftest.hcl を実行するだけで CI に組み込める。command = plan のみのテストなら AWS 認証不要・料金 $0 で常時実行できる。

5-7. アンチパターン

terraform test の 2 つのアンチパターン

① assert なしの run block

run "just_plan" { command = plan } と書くだけでは「plan が通ること」しか確認できず、output が正しい値かどうか検証されない。必ず assert を 1 つ以上書く。

② mock 過多で実プロバイダ検証が失われる

全テストケースで mock_provider を使うと、実際の AWS リソース仕様(例: CIDR の重複制約、AZ の利用可否)が検証されない。command = plan テストは mock で常時実行し、週次 nightly run で command = apply + 実プロバイダを組み合わせる 2 層テスト戦略が推奨だ。

§5 では terraform test (1.6+) の基本構造・mock_providerexpect_failures・CI 統合を実コードで確認した。§4 の Pattern D(DI)と組み合わせることで、AWS API を呼ばずに module の品質を担保できる。次の §6 では、作成した module を private registry でバージョン管理する方法を扱う。

6. private module registry 運用 — S3 / Git tag / Terraform Cloud

チームで Terraform モジュールを共有し始めると、「どこにモジュールを置いてどう参照するか」という配布・管理の問題が浮上します。Terraform には 3 つの private registry 方式 があり、チーム規模やコスト制約によって最適解が異なります。

6-1. 3 方式の比較

方式インフラコスト運用負荷バージョン可視性認証
Git tag refゼロ低(git tagのみ)中(tag一覧)SSH key / HTTPS token
S3低(〜$0.1/月)中(ZIP+upload)低(ファイル名)AWS IAM
Terraform CloudTFCライセンス低(push自動)高(UI)TFC workspace token

採用指針:

条件推奨
スタートアップ・小規模チーム(〜10人)Git tag ref — zero infra、最速導入
中規模(10〜50人)・AWS中心・IAM統一S3 — 認証一本化
TFC / TFE 導入済み組織Terraform Cloud Private Registry

6-2. Git tag ref 方式(推奨スタート地点)

Git tag ref は既存 GitHub / GitLab リポジトリをモジュール配布先として使う方法です。追加インフラが不要で、git tag を打つだけで即座に配布できます。

source 記法:

module "vpc" {
  source = "git::ssh://git@github.com/your-org/tf-modules.git//vpc?ref=v1.2.0"

  vpc_cidr = "10.0.0.0/16"
  env= "prod"
}

module "compute" {
  source = "git::ssh://git@github.com/your-org/tf-modules.git//compute?ref=v1.2.0"

  instance_type = "t3.micro"
  subnet_id  = module.vpc.public_subnet_ids[0]
}

// はリポジトリルートからのサブディレクトリ指定です。?ref= にはタグを指定します(ブランチは本番禁止)。

CI/CD での認証(GitHub Actions):

- name: Setup SSH for private module
  uses: webfactory/ssh-agent@v0.8.0
  with:
 ssh-private-key: ${{ secrets.TF_MODULES_DEPLOY_KEY }}

6-3. S3 方式

モジュールを ZIP アーカイブして S3 に配置し、s3:: プロトコルで参照します。認証を IAM に統一できる点が強みです。

source 記法:

module "vpc" {
  source = "s3::https://s3.ap-northeast-1.amazonaws.com/your-tf-modules-bucket/vpc/vpc-1.2.0.zip"

  vpc_cidr = "10.0.0.0/16"
}

ZIP アップロード:

cd modules/vpc
zip -r vpc-1.2.0.zip .
aws s3 cp vpc-1.2.0.zip s3://your-tf-modules-bucket/vpc/vpc-1.2.0.zip

バケット必須設定(暗号化 + パブリックアクセス遮断):

resource "aws_s3_bucket_server_side_encryption_configuration" "modules" {
  bucket = aws_s3_bucket.tf_modules.id
  rule {
 apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
  }
}

resource "aws_s3_bucket_public_access_block" "modules" {
  bucket= aws_s3_bucket.tf_modules.id
  block_public_acls = true
  block_public_policy  = true
  ignore_public_acls= true
  restrict_public_buckets = true
}

6-4. Terraform Cloud Private Registry 方式

TFC / TFE 利用中の場合、命名規則(terraform-<provider>-<name>)に従ったリポジトリを VCS 連携すると、git tag + git push で自動的に新バージョンが登録されます。

module "vpc" {
  source  = "app.terraform.io/your-org/vpc/aws"
  version = "~> 1.2.0"

  vpc_cidr = "10.0.0.0/16"
}

6-5. ハンズオン: Git tag でモジュールをバージョン管理

# Step 1: モジュールリポジトリを初期化してタグを打つ
git init tf-modules && cd tf-modules
mkdir -p vpc
# vpc/main.tf, variables.tf, outputs.tf を作成...
git add . && git commit -m "feat: add vpc module"
git tag -a v1.0.0 -m "Initial release"
git push origin main --tags

# Step 2: 呼び出し側で init
# source = "git::ssh://...//vpc?ref=v1.0.0" に設定して
terraform init
# → Downloading git::ssh://...?ref=v1.0.0 ...

# Step 3: バージョンアップ
git tag -a v1.1.0 -m "Add secondary_cidr_blocks"
git push origin main --tags
# 呼び出し側: ref=v1.1.0 に変更 → terraform init -upgrade

6-6. モジュールリポジトリのディレクトリ構成(推奨)

Git tag ref / S3 方式ともに、モジュールリポジトリは以下の構成が実績ある標準形です:

tf-modules/ # 専用リポジトリ(アプリと分離)
├── vpc/
│├── main.tf
│├── variables.tf
│└── outputs.tf
├── compute/
│├── main.tf
│├── variables.tf
│└── outputs.tf
├── storage/
│├── main.tf
│├── variables.tf
│└── outputs.tf
└── CHANGELOG.md  # バージョン変更履歴

各モジュールは独立したサブディレクトリに格納し、//subdir?ref=vX.Y.Z で個別参照します。1 リポジトリ複数モジュール構成は、モジュール数が少ない(5 個以下)チームに適しています。モジュールが増えてきたら 1 モジュール 1 リポジトリへの移行を検討してください。

6-7. アンチパターン

アンチパターン問題対策
?ref=main でブランチ参照upstream更新で本番破壊?ref=vX.Y.Z タグ固定
S3に暗号化なしで平置きコードが平文でアクセス可能SSE-S3 / SSE-KMS 有効化
公開 GitHub から ref なし参照突然 plan が変わるfork + タグ管理
アプリリポジトリにモジュール混在リリースサイクルが結合モジュール専用リポジトリ分離

7. 既存 monolith → module 段階的リファクタ 実例

fig06: monolith → 3 module 段階的リファクタフロー (moved block使用)

新規プロジェクトではなく、既存の monolith な Terraform コードを module 化する場面が実務では頻繁にあります。このセクションでは 400 行規模の monolith(VPC + EC2 + S3 + IAM が 1 ファイル)を 3 つの module に分解する実例を通じて、state を壊さず安全にリファクタする方法を解説します。

7-1. なぜ moved block を使うか

Terraform は terraform state mv コマンドでリソースを移動できますが、手動操作でミスが起きやすく、CI/CD での再現性がありません。Terraform 1.1+ で導入された moved block は、リファクタ手順をコードで宣言することで:

  • terraform plan 時に自動的に state を移動(No changes になる)
  • コードとして PR でレビューできる
  • ロールバックは branch 差し戻しだけで安全

という 3 つのメリットをもたらします。

7-2. リファクタ前の状態(400 行 monolith)

# main.tf(リファクタ前 — VPC/EC2/S3/IAM が 1 ファイル)

# --- VPC リソース ---
resource "aws_vpc" "main" {
  cidr_block  = "10.0.0.0/16"
  enable_dns_support= true
  enable_dns_hostnames = true
  tags = { Name = "prod-vpc" }
}

resource "aws_subnet" "public_a" {
  vpc_id= aws_vpc.main.id
  cidr_block  = "10.0.1.0/24"
  availability_zone = "ap-northeast-1a"
  tags = { Name = "prod-public-a" }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags= { Name = "prod-igw" }
}

# --- EC2 リソース ---
resource "aws_instance" "app" {
  ami  = data.aws_ami.amazon_linux.id
  instance_type = "t3.micro"
  subnet_id  = aws_subnet.public_a.id
  tags = { Name = "prod-app" }
}

resource "aws_security_group" "app" {
  name= "prod-app-sg"
  vpc_id = aws_vpc.main.id
  # ... ingress/egress rules
}

# --- S3 リソース ---
resource "aws_s3_bucket" "data" {
  bucket = "prod-data-bucket-12345"
}

resource "aws_s3_bucket_versioning" "data" {
  bucket = aws_s3_bucket.data.id
  versioning_configuration { status = "Enabled" }
}

7-3. リファクタ後の構成(module 分割)

terraform-module-design-practice/
├── main.tf  # module呼び出しのみ
├── variables.tf
├── outputs.tf
└── modules/
 ├── network/# VPC + Subnet + IGW
 │├── main.tf
 │├── variables.tf
 │└── outputs.tf
 ├── compute/# EC2 + Security Group
 │├── main.tf
 │├── variables.tf
 │└── outputs.tf
 └── storage/# S3 + Versioning
  ├── main.tf
  ├── variables.tf
  └── outputs.tf

7-4. 4 ステップ手順(state 破壊なし)

Step 1: module ディレクトリを作成し、リソースを移動する

リソースを module 内に移動し、同時に root module に moved block を追加します。ここがリファクタの核心です。

# modules/network/main.tf(新規作成)
resource "aws_vpc" "main" {
  cidr_block  = var.vpc_cidr
  enable_dns_support= true
  enable_dns_hostnames = true
  tags = { Name = "${var.env}-vpc" }
}

resource "aws_subnet" "public_a" {
  vpc_id= aws_vpc.main.id
  cidr_block  = var.public_subnet_cidr_a
  availability_zone = "${var.aws_region}a"
  tags = { Name = "${var.env}-public-a" }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags= { Name = "${var.env}-igw" }
}

Step 2: root module に moved block を追加する

moved block で「古いリソースアドレス → 新しいモジュール内アドレス」を宣言します。

# main.tf(root module — moved block を追加)

# moved blocks: state を壊さず module に移動
moved {
  from = aws_vpc.main
  to= module.network.aws_vpc.main
}

moved {
  from = aws_subnet.public_a
  to= module.network.aws_subnet.public_a
}

moved {
  from = aws_internet_gateway.main
  to= module.network.aws_internet_gateway.main
}

moved {
  from = aws_instance.app
  to= module.compute.aws_instance.app
}

moved {
  from = aws_security_group.app
  to= module.compute.aws_security_group.app
}

moved {
  from = aws_s3_bucket.data
  to= module.storage.aws_s3_bucket.data
}

moved {
  from = aws_s3_bucket_versioning.data
  to= module.storage.aws_s3_bucket_versioning.data
}

# module 呼び出し
module "network" {
  source  = "./modules/network"
  vpc_cidr= "10.0.0.0/16"
  public_subnet_cidr_a = "10.0.1.0/24"
  aws_region = var.aws_region
  env  = var.env
}

module "compute" {
  source  = "./modules/compute"
  subnet_id  = module.network.public_subnet_ids[0]
  vpc_id  = module.network.vpc_id
  instance_type = "t3.micro"
  env  = var.env
}

module "storage" {
  source = "./modules/storage"
  env = var.env
}

Step 3: terraform planNo changes を確認(合格基準)

terraform init# 新しいモジュールを認識させる
terraform plan

# 期待される出力:
# Terraform will perform the following actions:
#
## aws_vpc.main has moved to module.network.aws_vpc.main
#  resource "aws_vpc" "main" { ... }
#
# Plan: 0 to add, 0 to change, 0 to destroy.

Plan: 0 to add, 0 to change, 0 to destroy. が確認できたら Step 4 に進みます。差分が出た場合は moved block の from / to パスを見直してください。

Step 4: moved block を削除してコミット完了

plan: No changes が確認できたら moved block は役目を終えます。削除してコミットします。

# moved block を削除(variables.tf, outputs.tf も整理)
# 削除後に再度 plan で No changes を確認
terraform plan

# git でコミット
git add .
git commit -m "refactor: extract vpc/compute/storage modules from monolith"

7-5. for_each を使ったリソースの moved block

for_each でモジュール化した場合、moved block でもインデックスを指定する必要があります。

# subnet を for_each でモジュール化した場合
moved {
  from = aws_subnet.public_a
  to= module.network.aws_subnet.public["ap-northeast-1a"]
}

moved {
  from = aws_subnet.public_c
  to= module.network.aws_subnet.public["ap-northeast-1c"]
}

インデックスが一致しないと moved block が失敗するため、for_each キーの設計は事前に慎重に行ってください。

7-6. ロールバックと品質ゲート

moved block 削除前(Step 4 前)であれば、branch 差し戻しだけで安全に元の状態に戻れます。本番環境では Step 3 と Step 4 の間に plan: No changes を 2 名でレビューすることを推奨します。

# 品質ゲート(各 Step 後に必須)
terraform plan# No changes 必須(Step 3・Step 4 どちらも)
terraform test# 既存テストが全て pass(Step 4 後)

7-7. アンチパターン

アンチパターン問題対策
一気に全リソースを module 化plan の差分が大量になりレビュー不能1 module ずつ段階的に移行
moved block を省略して terraform applystate が不整合→リソース destroy + recreate必ず moved block でアドレス移動
for_each 化と module 分離を同時に実施moved のインデックス設計が複雑化まず module 分離のみ→for_each 化は別 PR
Step 3 確認なしで moved block を削除No changes 未確認のまま本番変更リスクPlan: 0 to add, 0 to change, 0 to destroy 必須

8. module version pinning / renovate / semver 運用

モジュールをチームで共有し始めると、「このモジュールの変更が他チームの環境に影響しないか」という懸念が生まれます。本セクションでは semver(セマンティックバージョニング)による version 管理ルールと、renovate bot を使った自動更新 PR の仕組みを解説します。

8-1. semver 原則と module での判定基準

semver は MAJOR.MINOR.PATCH の 3 桁で変更の影響範囲を示します。Terraform module では以下の判定基準を使います:

バンプ種別変更内容
MAJOR(X.0.0)破壊的変更(呼び出し側の修正が必要)必須 variable の追加 / output の削除 / resource の削除
MINOR(x.Y.0)後方互換な追加optional variable の追加 / 新規 output の追加
PATCH(x.y.Z)外部 I/F 不変の内部修正タグの変更 / ローカル変数のリファクタ

判定フローチャート:

変更が呼び出し側の修正を必要とするか?
  ├─ YES → MAJOR バンプ
  └─ NO → 呼び出し側から見える I/F が増えたか?
  ├─ YES → MINOR バンプ
  └─ NO  → PATCH バンプ

8-2. Terraform の version 制約記法

Terraform の required_providers や module version では以下の記法が使えます:

# = 1.2.0 → 完全固定(1.2.0 のみ)。自動更新が一切されない
# ~> 1.2→ >= 1.2.0 かつ < 2.0.0(MINOR/PATCH 自動更新、MAJOR 固定)
# ~> 1.2.0 → >= 1.2.0 かつ < 1.3.0(PATCH のみ自動更新)
# >= 1.2, < 2.0  → 範囲指定(複数制約の AND)

module "vpc" {
  source  = "app.terraform.io/your-org/vpc/aws"
  version = "~> 1.2"# 1.x.y の最新を自動追跡(MAJOR 変更は防護)
}

推奨使い分け:

用途制約記法理由
本番環境~> 1.2.0PATCH のみ自動更新・MINOR 変更も手動確認
開発環境~> 1.2MINOR まで自動追跡・最新機能を素早く取り込む
CI テスト>= 1.2, < 2.0上限明示で MAJOR 破壊を防御

8-3. .terraform.lock.hcl の役割と注意点

.terraform.lock.hclprovider のバージョンをロックするファイルです。注意点:

# .terraform.lock.hcl(provider はロックされる)
provider "registry.terraform.io/hashicorp/aws" {
  version  = "5.42.0"
  constraints = "~> 5.0"
  hashes = [
 "h1:abc123...",
 # ...
  ]
}

重要: .terraform.lock.hclprovider をロックしますが、module はロックしません。module の固定は version または ?ref=vX.Y.Z で行う必要があります。

# lock.hcl のコミット(必須)
git add .terraform.lock.hcl
git commit -m "chore: update provider lock"

# lock.hcl のアップグレード
terraform init -upgrade

8-4. renovate によるモジュール version 自動更新

renovate は .renovaterc.json(または renovate.json)の設定に従って依存関係の更新 PR を自動生成するボットです。Terraform module にも対応しています。

基本設定(.renovaterc.json:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:base"],
  "terraform": {
 "enabled": true
  },
  "packageRules": [
 {
"matchManagers": ["terraform"],
"matchUpdateTypes": ["patch"],
"automerge": true,
"automergeType": "pr",
"labels": ["terraform", "automerge"]
 },
 {
"matchManagers": ["terraform"],
"matchUpdateTypes": ["minor"],
"reviewers": ["team:platform"],
"labels": ["terraform", "minor-update"]
 },
 {
"matchManagers": ["terraform"],
"matchUpdateTypes": ["major"],
"reviewers": ["team:platform", "team:lead"],
"labels": ["terraform", "breaking-change"],
"dependencyDashboardApproval": true
 }
  ]
}

ポイント解説:
patch アップデートは automerge: true で自動マージ(レビュー不要)
minor アップデートはプラットフォームチームのレビュー必須
major アップデートは Dependency Dashboard での承認が必要

8-5. ハンズオン: git tag による release 手順

# Step 1: 変更を加えてコミット
cd tf-modules
# variables.tf に optional 変数を追加(MINOR バンプ)
git add .
git commit -m "feat: add secondary_cidr_blocks variable"

# Step 2: タグを打つ(MINOR バンプ: 1.1.0 → 1.2.0)
git tag -a v1.2.0 -m "feat: add secondary_cidr_blocks variable

MINOR: optional 変数追加のため後方互換"

# Step 3: タグをリモートに push
git push origin main --tags

# CHANGELOG.md も更新
cat >> CHANGELOG.md << 'EOF'

## v1.2.0 (2026-04-21)
### Added
- `secondary_cidr_blocks` variable を追加(optional、既存の呼び出し側に変更不要)
EOF

破壊的変更(MAJOR バンプ)の手順:

# 必須変数を追加 → MAJOR バンプ(既存の呼び出し側は修正が必要)
git tag -a v2.0.0 -m "feat!: make availability_zones required

BREAKING CHANGE: availability_zones variable が必須化。
呼び出し側に availability_zones = [...] の追加が必要。
移行ガイド: CHANGELOG.md の v2.0.0 セクションを参照。"

git push origin main --tags

8-6. アンチパターン

アンチパターン問題対策
全モジュールを >= 0.0.0 で参照任意バージョンが入り挙動が不安定~> X.Y.0 以上の制約を必ず付ける
MAJOR バンプなしで破壊的変更をリリース呼び出し側が無断で壊れる必須化・削除は必ず MAJOR バンプ
.terraform.lock.hcl を .gitignoreCI/CD で毎回異なる provider version が入るlock.hcl を必ずコミット
renovate なし・手動バージョン管理セキュリティパッチが放置されるrenovate または dependabot を必ず設定
CHANGELOG.md なしMAJOR の移行手順が伝わらないタグ作成時に必ず CHANGELOG を更新

9. まとめとスキルチェックリスト

本記事は「Terraform 入門を終えた方が実務で通用する module 設計・テスト・配布・運用を 1 本で学ぶ」ことを目的とした中上級向けハンズオンでした。

9-1. 本記事で身につくスキル — 第 1 弾との比較

領域第 1 弾(基礎)本記事(実践)
module 構文基礎 / ローカル呼び出し前提として参照
S3 backend / DynamoDB lock対象外
GitHub Actions + OIDC CI/CD対象外
input validation / optional / object 型§2 で習得
locals / for_each / dynamic 実践一部§3 で習得
module composition 4 パターン§4 で習得
terraform test (1.6+) native framework§5 で習得
private registry / semver 運用一部§6/§8 で習得
monolith → module 段階的リファクタ§7 で習得

9-2. スキルチェックリスト

本記事の内容を実務で活用できるか、以下の項目を自己チェックしてください。

□ variable の validation block を書いて invalid な値でエラーを発生させられる
□ optional() を使ったオブジェクト型 variable を設計できる
□ for_each / dynamic block / locals を使い分けられる(count との違いを説明できる)
□ module composition の 4 パターン(root-child / wrapper / shared / DI)を説明できる
□ .tftest.hcl で run block を書き、plan command で assertion を検証できる
□ mock_provider を使って外部 API 呼び出しなしでテストを実行できる
□ Git tag ref を使ってプライベートモジュールを配布・参照できる
□ moved block を使って既存 state を壊さずモジュール分離できる
□ semver の MAJOR/MINOR/PATCH を Terraform module に適用して判断できる
□ renovate.json で Terraform module の自動更新 PR を設定できる

全項目にチェックが入れば、本記事の目標を達成しています。

9-3. 次の発展ステップ

本記事で扱えなかった領域は以下の通りです。興味のある方は次弾をお待ちください:

テーマ概要
TerragruntDRY な multi-env 管理・terragrunt.hcl による backend/provider 自動生成
Terraform Cloud / Enterpriseチーム操作ログ・RBAC・Sentinel policy による組織全体のガードレール
AtlantisPR ベースの terraform plan/apply 自動化・GitOps 運用

9-3-1. 学習ロードマップ

本記事を終えた後の学習パスを以下に示します:

[第 1 弾] モジュール化 / tfstate / OIDC CI/CD
 ↓
[本記事] interface / composition / terraform test / semver
 ↓
[次弾候補 A] Terragrunt によるマルチ環境 DRY 化
[次弾候補 B] Terraform Cloud / Enterprise 組織運用
[次弾候補 C] Atlantis による GitOps plan/apply 自動化

本記事で扱った terraform test は、次弾で解説予定の Terraform Cloud の SpeculativePlanPolicy Check(Sentinel) と組み合わせることで、さらに堅牢な CI ゲートを構築できます。

9-4. ハンズオン後の後片付け

本記事のハンズオンで作成したリソースを必ず削除してください:

cd terraform-module-design-practice
terraform destroy

# 確認メッセージが出たら "yes" を入力
# EC2 → Security Group → Subnet → VPC の順に削除される

EC2 を起動したままにすると料金が発生します(t2.micro: 無料枠外で月 $8.35 程度)。

本記事で身につくスキル:

  • module interface 設計(validation / optional / object 型 / deprecation)
  • locals / for_each / dynamic / count の実践的使い分け
  • module composition 4 パターン(root/child / wrapper / shared / 依存注入)
  • terraform test (1.6+) による module 単体テスト記述
  • private module registry(S3 / Git tag / Terraform Cloud)の採用判断
  • monolith → module 段階的リファクタ手順
  • semver + renovate による module version 運用

次の発展:

  • Terragrunt による multi-env DRY 化
  • Terraform Cloud / Enterprise でのチーム運用
  • Atlantis による PR-based plan/apply 自動化