Terraformを使い始めると、最初はうまくいく。VPCを作り、EC2を作り、サブネットを定義する。しかし3つ目、4つ目の環境を作り始めたとき、コードが爆発的に膨らんでいることに気づく。
この記事では、terraform moduleの使い方と、for_each・ countを組み合わせた構成の再利用設計パターンを解説します。moduleの基本構造から、countとfor_eachの使い分け基準、localsブロックを使った環境設定の一元管理まで、実務で通用するパターンを段階的に説明します。
この記事のポイント
・terraform moduleでリソース定義を再利用可能な単位にまとめられる
・countは数値で繰り返し、for_eachはマップで繰り返す
・削除が伴う変更にはfor_eachの方がリスクが低い
・inputとoutputを設計すればmodule間を疎結合につなげられる
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜterraform moduleが必要なのか
Terraformを書き始めたエンジニアが最初にぶつかる壁は「コードの重複」だ。開発環境と本番環境、dev・staging・prodの3環境、Tokyo/Osaka/Singaporeの3リージョンなど、似た構成を複数作ろうとすると、コピー&ペーストしかないのかという疑問が生まれる。その答えが moduleだ。Terraformのmoduleとは、関連するリソース定義をひとまとまりにして「部品」として扱えるようにする仕組みだ。プログラミングでいう関数に近い概念で、引数(input variables)と戻り値(outputs)を持ち、何度でも呼び出せる。
moduleを使わない場合、例えばEC2+SecurityGroup+EIPのセットを3環境分作ると、ほぼ同じコードが3箇所に存在する。moduleを使えば、同じセットを1箇所に定義して3回呼び出すだけになる。
・変更が1箇所で済む(保守性向上)
・テスト済みの定義を使い回せる(品質安定)
・環境ごとの差分が変数だけになる(差分管理の明確化)
moduleの基本構造とディレクトリ設計
1. ディレクトリ構造の基本
moduleはディレクトリ=1moduleが基本の設計思想だ。以下が一般的なディレクトリ構成だ。# プロジェクトルート構成 . ├── main.tf # ルートモジュール(moduleを呼び出す側) ├── variables.tf ├── outputs.tf └── modules/ ├── ec2/ # EC2モジュール │ ├── main.tf │ ├── variables.tf │ └── outputs.tf └── vpc/ # VPCモジュール ├── main.tf ├── variables.tf └── outputs.tf
# ルートのmain.tf module "web_server" { source = "./modules/ec2" instance_type = "t3.micro" ami_id = "ami-0c7217cdde317cfec" name = "web" } module "app_server" { source = "./modules/ec2" instance_type = "t3.small" ami_id = "ami-0c7217cdde317cfec" name = "app" }
2. moduleの内側(variables.tf / outputs.tf)
modules/ec2/variables.tf でmoduleが受け取る引数を定義する。# modules/ec2/variables.tf variable "instance_type" { description = "EC2インスタンスタイプ" type = string default = "t3.micro" } variable "ami_id" { description = "AMI ID" type = string } variable "name" { description = "リソース名(Nameタグに使用)" type = string }
# modules/ec2/outputs.tf output "instance_id" { value = aws_instance.this.id } output "private_ip" { value = aws_instance.this.private_ip }
countによる繰り返し
3. countの基本
同じリソースを指定した個数だけ作りたい場合は count を使う。最もシンプルな繰り返し方法だ。# 3台のEC2インスタンスを作る例 resource "aws_instance" "worker" { count = 3 ami = "ami-0c7217cdde317cfec" instance_type = "t3.micro" tags = { Name = "worker-${count.index}" # worker-0, worker-1, worker-2 } }
変数から個数を制御する使い方もよく使われる。
variable "worker_count" { type = number default = 3 } resource "aws_instance" "worker" { count = var.worker_count ami = "ami-0c7217cdde317cfec" instance_type = "t3.micro" tags = { Name = "worker-${count.index}" } }
4. countをmoduleに適用する
moduleブロックにも count を指定できる。module "web_server" { count = 3 source = "./modules/ec2" instance_type = "t3.micro" ami_id = "ami-0c7217cdde317cfec" name = "web-${count.index}" } # 参照はインデックスで行う output "web_server_ids" { value = module.web_server[*].instance_id }
for_eachによる繰り返し
5. for_eachの基本(マップを使う)
for_each はマップ(map)またはセット(set)を使って繰り返す。各要素に意味のある名前(キー)をつけられることが特徴だ。variable "servers" { type = map(object({ instance_type = string ami_id = string })) default = { "web" = { instance_type = "t3.micro" ami_id = "ami-0c7217cdde317cfec" } "app" = { instance_type = "t3.small" ami_id = "ami-0c7217cdde317cfec" } "db" = { instance_type = "t3.medium" ami_id = "ami-0c7217cdde317cfec" } } } resource "aws_instance" "servers" { for_each = var.servers ami = each.value.ami_id instance_type = each.value.instance_type tags = { Name = each.key # "web", "app", "db" } }
6. for_eachをmoduleに適用する
for_each はmoduleブロックにも使える。環境(dev/staging/prod)ごとに構成を変えたいときに特に便利だ。インスタンスタイプだけでなく、台数やDBクラスなど複数のパラメータをまとめて管理できる。locals { environments = { dev = { instance_type = "t3.micro" min_size = 1 max_size = 2 db_instance_class = "db.t3.micro" } staging = { instance_type = "t3.small" min_size = 1 max_size = 3 db_instance_class = "db.t3.small" } prod = { instance_type = "t3.large" min_size = 3 max_size = 10 db_instance_class = "db.m5.large" } } } module "env" { for_each = local.environments source = "./modules/app-env" env_name = each.key instance_type = each.value.instance_type min_size = each.value.min_size max_size = each.value.max_size db_instance_class = each.value.db_instance_class } # 環境ごとのURLを出力する例 output "env_urls" { value = { for k, v in module.env : k => v.app_url } }
countとfor_eachの使い分け判断基準
どちらを使うべきか迷うことが多い。以下の基準で判断してほしい。| 状況 | 推奨 | 理由 |
|---|---|---|
| 数だけ決まっていて、個々に意味がない | count |
シンプルに書ける |
| 各要素に意味のある名前がある | for_each |
リソース管理がキー名で明確になる |
| 途中の要素を削除する可能性がある | for_each |
インデックスがずれないため安全 |
| マップ/セット型の変数がある | for_each |
変数の構造をそのまま活用できる |
| dev・staging・prodなど環境ごとの設定がある | for_each |
localsマップと組み合わせて一元管理できる |
| シンプルな同種リソースのコピー | count |
コードが短くなる |
count で3台([0][1][2])作った後、[1]を削除したとする。Terraformはインデックスを詰め直すため、[2]だったリソースが[1]になり、既存リソースが「削除して再作成」と判断されることがある。
for_each の場合はキー名で管理するため、ひとつのキーを削除しても他のリソースには影響しない。これが「変更が多い構成にはfor_each」と言われる主な理由だ。
実践的なmodule設計パターン
7. ネストmodule(moduleからmoduleを呼ぶ)
moduleの中からさらに別のmoduleを呼び出すことができる。ネストmoduleと呼ぶ。# modules/app-env/main.tf # このmodule内でvpcモジュールを呼び出す module "vpc" { source = "../vpc" cidr_block = var.vpc_cidr env_name = var.env_name } module "ec2" { source = "../ec2" subnet_id = module.vpc.public_subnet_id instance_type = var.instance_type name = "${var.env_name}-app" }
8. 入力変数の型定義を丁寧に書く
moduleの品質は入力変数(variables.tf)の設計で決まると言っても過言ではない。型を明確にし、descriptionを書き、validationを追加するとmoduleの使いやすさが格段に上がる。# modules/ec2/variables.tf (丁寧な定義の例) variable "instance_type" { description = "EC2インスタンスタイプ(t3.micro / t3.small / t3.medium)" type = string default = "t3.micro" validation { condition = contains(["t3.micro", "t3.small", "t3.medium", "t3.large"], var.instance_type) error_message = "許可されるインスタンスタイプ: t3.micro, t3.small, t3.medium, t3.large" } } variable "tags" { description = "リソースに付与するタグのマップ" type = map(string) default = {} }
9. outputの設計(必要な値だけ外に出す)
outputは「moduleの外から何を参照できるか」を定義する。すべての値を外に出す必要はない。呼び出し元が実際に使う値に絞ることで、moduleのインターフェースがシンプルになる。# modules/ec2/outputs.tf output "instance_id" { description = "EC2インスタンスID" value = aws_instance.this.id } output "private_ip" { description = "プライベートIPアドレス" value = aws_instance.this.private_ip } # sensitive=trueで機密値をplan/applyの出力に表示させない output "connection_string" { description = "DB接続文字列(機密)" value = local.db_connection_string sensitive = true }
10. localsブロックで環境設定を一元管理する
for_eachでmoduleを複数環境に展開するとき、インスタンスタイプ・台数・DBクラスなどの環境差分がコードのあちこちに散らばりやすい。localsブロックに環境設定マップを一箇所に集約することで、「どの環境にどのスペックを使うか」を一目で把握できるようになる。# locals.tf: 環境設定をマップで一元管理する locals { env_config = { dev = { instance_type = "t3.micro" min_size = 1 max_size = 2 db_instance_class = "db.t3.micro" enable_deletion_protection = false } staging = { instance_type = "t3.small" min_size = 1 max_size = 3 db_instance_class = "db.t3.small" enable_deletion_protection = false } prod = { instance_type = "m5.large" min_size = 2 max_size = 10 db_instance_class = "db.m5.large" enable_deletion_protection = true } } } # main.tf: localsのマップをfor_eachで展開する module "app_env" { for_each = local.env_config source = "./modules/app-env" env_name = each.key instance_type = each.value.instance_type min_size = each.value.min_size max_size = each.value.max_size db_instance_class = each.value.db_instance_class enable_deletion_protection = each.value.enable_deletion_protection }
よくあるトラブルと解決法
11. 「Error: Duplicate resource」が出る
for_each のキーに重複がある場合に発生する。同じキーを2つのマップに使っていないか確認する。# エラーになる例(重複キー) variable "servers" { default = { "web" = { ... } "web" = { ... } # NG: 同じキー "web" が2つある } } # 実際の確認コマンド terraform plan 2>&1 | grep -i "duplicate"
12. moduleを削除するとリソースが消える
moduleブロックをコメントアウトしたり削除したりすると、そのmodule内のリソースが全て削除される。これは意図通りの動作だが、誤って消してしまう事故が多い。削除前に必ず `terraform plan` でdestroy対象を確認すること。本番環境での削除は `lifecycle { prevent_destroy = true }` を設定しておくと安全だ。
# 削除を防ぐlifecycleの設定 resource "aws_db_instance" "main" { ... lifecycle { prevent_destroy = true } } # terraform planで削除対象を必ず確認する terraform plan -out=tfplan terraform show -json tfplan | jq '.resource_changes[] | select(.change.actions[] == "delete")'
13. for_each でセットを使う場合の注意点
set型のfor_eachを使う場合、要素の順序が保証されない。また、セットの要素はキーとしてそのまま使われるため、要素の値が変わると「削除して再作成」になる。# setを使う例(シンプルなケース) variable "availability_zones" { type = set(string) default = ["ap-northeast-1a", "ap-northeast-1c", "ap-northeast-1d"] } resource "aws_subnet" "public" { for_each = var.availability_zones vpc_id = aws_vpc.main.id availability_zone = each.key cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, index(tolist(var.availability_zones), each.key)) }
14. moduleのsourceを変更すると既存リソースが削除される
moduleのsourceパスを変更(例:ローカルパス→Terraform Registryへの移行)した場合、Terraformはそれを「別のmodule」として認識し、既存リソースを削除して再作成しようとする。これを防ぐには `terraform state mv` コマンドで既存リソースを新しいmoduleのアドレスに移動してから plan/apply する。
# stateを移動してリソースを保護する例 # 旧アドレス → 新アドレスに移動 terraform state mv 'module.old_name.aws_instance.web' 'module.new_name.aws_instance.web' # 確認 terraform state list | grep module
本記事のまとめ
terraform moduleとfor_each・countの設計パターンを改めて整理する。| やりたいこと | 使い方 |
|---|---|
| リソース定義を部品として再利用する | module "name" { source = "..." } |
| 指定した数だけリソースを作る | count = 3 で count.index を参照 |
| マップ/セットで繰り返す | for_each = var.map で each.key/each.value |
| moduleにfor_eachを使う | module "env" { for_each = local.envs } |
| 環境設定を一元管理する | localsブロックにマップで定義しfor_eachで展開する |
| 途中の要素を削除しても安全に | countではなくfor_eachを選ぶ |
| moduleの出力を別moduleに渡す | module.module_name.output_name で参照 |
| 誤削除を防ぐ | lifecycle { prevent_destroy = true } |
| stateのリソースを移動する | terraform state mv 旧アドレス 新アドレス |
最初は「module化するほど規模がない」と感じるかもしれないが、3つ目の環境を作る前に設計しておくことをすすめる。後からの切り出しは想像以上に手間がかかる。
Terraformでのインフラ設計をさらに深めたい方は、tfstateのチーム運用や変数の設計パターンも合わせて学ぶと、より堅牢な構成管理ができるようになる。
・postfix mynetworks の書き方はこちら
Terraform実践セミナーの詳細を見る >>
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
登録10秒/合わなければ解除3秒 / 詳細はこちら
- 次のページへ:AnsibleとシェルスクリプトSSHループの違い|構成管理に踏み出す判断
- 前のページへ:Ansibleのinventory・role・module設計|現場で崩れない構成管理の基礎
- この記事の属するカテゴリ:Ansibleへ戻る

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は1分、解除もいつでも可。
登録無料・いつでも解除できます