「for_eachとcountはどう使い分ければいいのか。リストの途中を変更したら、意図しないリソースが破棄されてしまった」
Terraformを使い始めると、最初はうまくいく。VPCを作り、EC2を作り、サブネットを定義する。しかし3つ目、4つ目の環境を作り始めたとき、コードが爆発的に膨らんでいることに気づく。workspace機能や-var-fileで環境を分けるアプローチも有効だが、S3バックエンドのバケット名・DynamoDBテーブル名を環境ごとに切り替える記述が煩雑になりやすく、コードの三重管理という根本的な問題は解消しない。
この記事では、terraform moduleの使い方と、for_each・countを組み合わせた構成の再利用設計パターンを解説します。moduleの基本構造から、countとfor_eachの使い分け基準(インデックスずれによるリソース破棄リスクを含む)、localsブロックを使った環境設定の一元管理、dynamic blockの活用、よくあるトラブルの対処まで、実務で通用するパターンを段階的に説明します。
この記事のポイント
・terraform moduleでリソース定義を再利用可能な単位にまとめられる
・countはインデックス(数値)でリソースを識別するため途中削除でずれが起きる
・for_eachはキー名(文字列)で管理するため途中追加・削除しても影響が限定される
・dynamic blockでfor_eachを使うとリソース内のブロックを宣言的に管理できる
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜterraform moduleが必要なのか
Terraformを書き始めたエンジニアが最初にぶつかる壁は「コードの重複」だ。開発環境と本番環境、dev・staging・prodの3環境、Tokyo/Osaka/Singaporeの3リージョンなど、似た構成を複数作ろうとすると、コピー&ペーストしかないのかという疑問が生まれる。その答えが moduleだ。Terraformのmoduleとは、関連するリソース定義をひとまとまりにして「部品」として扱えるようにする仕組みだ。プログラミングでいう関数に近い概念で、引数(input variables)と戻り値(outputs)を持ち、何度でも呼び出せる。
Terraform標準のworkspace機能や-var-fileによる環境分離も有効な手段ではあるが、限界がある。S3バックエンドのバケット名やDynamoDBのロックテーブル名を環境ごとに切り替えようとすると、backend設定ブロックは変数を受け付けないため、環境数だけバックエンド設定を手書きするか、外部スクリプトを使わざるを得なくなる。moduleを使うと、環境差分をinputとして渡す設計ができ、コードの重複を構造的に排除できる。
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" }
terraform initの再実行が必要なタイミング
moduleのsourceを新たに追加・変更した後は必ず `terraform init` を再実行する必要がある。initを忘れると「Module not installed」エラーが出てplanもapplyも実行できない。新しいメンバーがリポジトリをクローンした場合も同様だ。この点はGitHubやTerraform Registryからmoduleをダウンロードする場合も変わらない。
# moduleのsourceを変更・追加した後は必ずinitを再実行する $ terraform init # エラーが出た場合は -upgrade フラグで強制更新できる $ terraform init -upgrade
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}" } }
$ terraform state list aws_instance.worker[0] aws_instance.worker[1] aws_instance.worker[2]
4. countのインデックスずれ問題
countにはひとつ大きな落とし穴がある。リストの途中の要素を削除したとき、インデックスがずれて既存リソースが予期せず破棄・再作成されることだ。例えば `["web-01", "web-02", "web-03"]` のリストで3台作成した後、「web-02」を削除してリストが `["web-01", "web-03"]` になったとする。Terraformはstate内の `web[1]` が「web-03」に変わったと解釈して、既存の `web[1]`(web-02)を破棄してから `web[1]`(web-03)として再作成しようとする。さらに `web[2]`(web-03)も「参照がなくなった」として削除される。
実際にterraform planを実行すると以下のような出力になる。
# リスト変更後のterraform plan出力(抜粋) # aws_instance.web[1] will be destroyed - resource "aws_instance" "web" { - tags = { "Name" = "web-02" -> null } } # aws_instance.web[1] will be created + resource "aws_instance" "web" { + tags = { "Name" = "web-03" } } # aws_instance.web[2] will be destroyed - resource "aws_instance" "web" { - tags = { "Name" = "web-03" -> null } } Plan: 1 to add, 0 to change, 2 to destroy.
5. 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による繰り返し
6. for_eachの基本(setとmapの使い分け)
for_each はマップ(map)またはセット(set)を使って繰り返す。各要素に意味のある名前(キー)をつけられることが特徴で、countと違いインデックスではなく文字列キーでリソースを識別する。set型(名前のリストがある場合)
variable "instance_names" { type = set(string) default = ["web-01", "web-02", "web-03"] } resource "aws_instance" "web" { for_each = var.instance_names ami = "ami-0c7217cdde317cfec" instance_type = "t3.micro" tags = { Name = each.key # "web-01", "web-02", "web-03" } }
$ terraform state list aws_instance.web["web-01"] aws_instance.web["web-02"] aws_instance.web["web-03"]
map型(各要素に複数の属性を持たせる場合)
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" } }
7. 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 = 条件 ? 1 : 0 |
0か1の切り替えならインデックスずれなし |
count で3台([0][1][2])作った後、[1]を削除したとする。Terraformはインデックスを詰め直すため、[2]だったリソースが[1]になり、既存リソースが「削除して再作成」と判断されることがある。
for_each の場合はキー名で管理するため、ひとつのキーを削除しても他のリソースには影響しない。これが「変更が多い構成にはfor_each」と言われる主な理由だ。
「0か1か」の条件制御はcountが得意
countが最も適しているのは、リソースを作る・作らないを条件で切り替えるパターンだ。
# NATゲートウェイを作る・作らないを変数で切り替える例 resource "aws_eip" "nat" { count = var.create_nat ? 1 : 0 domain = "vpc" }
実践的なmodule設計パターン
8. ネスト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" }
9. 入力変数の型定義を丁寧に書く
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 = {} }
10. 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 }
11. 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 }
管理環境が10を超えるような大規模構成になってきたら、Terraformのラッパーツール「Terragrunt」を検討する価値がある。TerragruntはS3バックエンドの設定(バケット名・DynamoDBテーブル名)を環境名から自動生成する仕組みや、複数モジュールを依存関係の順に一括apply/destroyするrun-all機能を持つ。locals+for_eachで書けるうちは素のTerraformで進め、backend設定の三重管理が限界に感じてきたタイミングで導入を判断するとよい。
dynamic blockでfor_eachを活用する
for_eachはリソース自体の繰り返しだけでなく、リソース内のブロック要素の繰り返しにも使える。これをdynamic blockといい、セキュリティグループのingressルールのように「数が変動する設定ブロック」を宣言的に記述できる。12. dynamic blockの基本(セキュリティグループのingressルール)
# dynamic blockでingressルールを動的生成する例 variable "ingress_rules" { type = list(object({ port = number protocol = string cidr_blocks = list(string) description = string })) default = [ { port = 22, protocol = "tcp", cidr_blocks = ["10.0.0.0/8"], description = "SSH from internal" }, { port = 80, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"], description = "HTTP" }, { port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"], description = "HTTPS" }, ] } resource "aws_security_group" "web" { name = "web-sg" vpc_id = var.vpc_id dynamic "ingress" { for_each = var.ingress_rules content { from_port = ingress.value.port to_port = ingress.value.port protocol = ingress.value.protocol cidr_blocks = ingress.value.cidr_blocks description = ingress.value.description } } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } }
ひとつ重要な違いがある。dynamic blockのfor_eachはlistを渡しても動作する。これはリソース単位のfor_each(setやmapが必要)と異なる動作だ。dynamic blockで作成されるブロックはtfstateの独立したリソースではなく「1つのリソースの設定の一部」として扱われるため、リストのインデックスずれが本番リソースの破棄につながることはない。
よくあるトラブルと解決法
13. 「Error: Duplicate resource」が出る
for_each のキーに重複がある場合に発生する。同じキーを2つのマップに使っていないか確認する。# エラーになる例(重複キー) variable "servers" { default = { "web" = { ... } "web" = { ... } # NG: 同じキー "web" が2つある } } # 実際の確認コマンド terraform plan 2>&1 | grep -i "duplicate"
14. 「Error: Invalid for_each argument」が出る
このエラーには2つの異なる原因がある。原因1: list型をそのまま渡している
for_eachはsetまたはmapしか受け付けない。変数の型定義を確認して対処する。
# エラーになるパターン(list型をそのまま渡している) for_each = var.instance_names # type = list(string) だとエラー # 解決策1: 変数の型をset(string)に変更する(推奨) variable "instance_names" { type = set(string) } # 解決策2: toset()関数でlist型をset型に変換する for_each = toset(var.instance_names)
for_eachのキーはplan実行時点で値が確定していなければならない。まだ作られていないリソースのidなど、apply前には確定しない値をキーに使うと以下のエラーが出る。
# エラーになるパターン(apply前未確定の値をキーに使う) resource "aws_subnet" "main" { for_each = aws_vpc.main.id # plan時点ではidが確定しないためエラー ... } # エラーメッセージ(抜粋) # Error: Invalid for_each argument # The "for_each" value depends on resource attributes that cannot be # determined until apply, so Terraform cannot predict how many instances # will be created. To work around this, use the -target argument to first # apply only the resources that the for_each depends on.
# 解決策1: plan時点で確定できる固定文字列をキーにする locals { subnet_types = toset(["public", "private"]) } resource "aws_subnet" "main" { for_each = local.subnet_types vpc_id = aws_vpc.main.id cidr_block = each.key == "public" ? "10.0.1.0/24" : "10.0.2.0/24" availability_zone = "ap-northeast-1a" tags = { Name = each.key } }
# 解決策2: -targetで依存元リソースを先にapplyする(2段階実行) # ステップ1: for_eachが依存するVPCだけ先にapply terraform apply -target=aws_vpc.main # ステップ2: VPCのidが確定した後で全体をapply terraform apply
15. 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")'
16. 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)) }
17. countからfor_eachへの移行でリソースが破棄される問題
既存のcountリソースをfor_eachに切り替えると、stateのキーがインデックスから文字列に変わるため、Terraformが全リソースを破棄・再作成しようとする。本番環境で突然この切り替えを行うと、サービス停止が発生する危険がある。移行には `terraform state mv` でstateのキーを先に書き換えてから、コードを変更する手順を必ず踏んでほしい。# countからfor_eachへの移行手順(3台の例) # 移行前: aws_instance.web[0] → 移行後: aws_instance.web["web-01"] terraform state mv 'aws_instance.web[0]' 'aws_instance.web["web-01"]' terraform state mv 'aws_instance.web[1]' 'aws_instance.web["web-02"]' terraform state mv 'aws_instance.web[2]' 'aws_instance.web["web-03"]' # state移行後にコードをfor_eachに書き換えてplan実行 # Plan: 0 to add, 0 to change, 0 to destroy. になれば成功
# state mv後の確認(「No changes」になれば移行成功) $ terraform plan No changes. Your infrastructure matches the configuration. # 差分が出た場合は中止して原因を調査する
18. 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
19. count = 0のリソースを直接参照するとエラーになる
countで条件付きリソース(`count = var.flag ? 1 : 0`)を作成するとき、count = 0の状態で当該リソースの属性を直接参照するとエラーになる。resource "aws_eip" "nat" { count = var.create_nat ? 1 : 0 domain = "vpc" } # NG: count = 0のとき aws_eip.nat はリスト(長さゼロ)になるため直接参照はエラー output "nat_ip" { value = aws_eip.nat.public_ip # エラー }
# OK: length()で存在確認してから参照する output "nat_ip" { value = length(aws_eip.nat) > 0 ? aws_eip.nat[0].public_ip : null }
20. .terraform/ ディレクトリとtfstateをgitignoreに含めていない
チームでTerraformを使い始めたときに多いミスだ。`terraform init` が生成する `.terraform/` ディレクトリにはプロバイダのバイナリが含まれており、コミットしてしまうとリポジトリが数十MB単位で肥大化する。また、ローカルバックエンドを使っている場合の `terraform.tfstate` をコミットすると、複数人が同時に操作したときにstateの競合が起きる。# Terraformプロジェクトの標準 .gitignore .terraform/ .terraform.lock.hcl # lockファイルはチームで共有する場合は含める terraform.tfstate terraform.tfstate.backup *.tfvars # 機密値を含む変数ファイルは必ず除外
本記事のまとめ
terraform moduleとfor_each・countの設計パターンを改めて整理する。| やりたいこと | 使い方 |
|---|---|
| リソース定義を部品として再利用する | module "name" { source = "..." } |
| 指定した数だけリソースを作る | count = 3 で count.index を参照 |
| マップ/セットで繰り返す | for_each = var.map で each.key/each.value |
| リスト型をfor_eachに渡す | for_each = toset(var.list) |
| moduleにfor_eachを使う | module "env" { for_each = local.envs } |
| 環境設定を一元管理する | localsブロックにマップで定義しfor_eachで展開する |
| 途中の要素を削除しても安全に | countではなくfor_eachを選ぶ |
| リソース内のブロックを動的生成する | dynamic "ブロック名" { for_each = ... } |
| moduleの出力を別moduleに渡す | module.module_name.output_name で参照 |
| 誤削除を防ぐ | lifecycle { prevent_destroy = true } |
| stateのリソースを移動する | terraform state mv 旧アドレス 新アドレス |
| リソースを条件付きで作る・作らない | count = 条件 ? 1 : 0 |
| count = 0のリソースの属性を参照する | length(resource) > 0 ? resource[0].attr : null |
| for_eachのキーをplan時点で確定させる | localsの固定文字列セット/マップをキーにする |
| for_eachの依存元リソースを先にapplyする | terraform apply -target=依存リソース の2段階実行 |
countは直感的でシンプルだが、リストへの変更が発生する構成に使い続けると本番環境で意図しないリソース破棄を引き起こす。迷ったらfor_eachを選んでおく方が、長期的な運用リスクを下げられる。
最初は「module化するほど規模がない」と感じるかもしれないが、3つ目の環境を作る前に設計しておくことをすすめる。後からの切り出しは想像以上に手間がかかる。
Terraformでのインフラ設計をさらに深めたい方は、tfstateのチーム運用や変数の設計パターンも合わせて学ぶと、より堅牢な構成管理ができるようになる。
・postfix mynetworks の書き方はこちら
Terraform実践セミナーの詳細を見る >>
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 次のページへ:AnsibleとシェルスクリプトSSHループの違い|構成管理に踏み出す判断
- 前のページへ:Ansibleのinventory・role・module設計|現場で崩れない構成管理の基礎
- この記事の属するカテゴリ:Ansibleへ戻る

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