Terraformのmoduleとfor_each・countで構成を再利用する設計パターン

宮崎智広 この記事の監修:宮崎智広(Linux実務・教育歴20年以上・受講者3,100名超)
HOMELinux技術 リナックスマスター.JP(Linuxマスター.JP)Terraform > Terraformのmoduleとfor_each・countで構成を再利用する設計パターン
「Terraformコードが増えるにつれて、似たようなリソース定義が至る所にコピーされてしまう」
Terraformを使い始めると、最初はうまくいく。VPCを作り、EC2を作り、サブネットを定義する。しかし3つ目、4つ目の環境を作り始めたとき、コードが爆発的に膨らんでいることに気づく。

この記事では、terraform moduleの使い方と、for_each・countを組み合わせた構成の再利用設計パターンを解説します。moduleの基本構造から、countとfor_eachの使い分け基準、ネストmoduleの設計まで、実務で通用するパターンを段階的に説明します。

この記事のポイント

・terraform moduleでリソース定義を再利用可能な単位にまとめられる
・countは数値で繰り返し、for_eachはマップで繰り返す
・削除が伴う変更にはfor_eachの方がリスクが低い
・inputとoutputを設計すればmodule間を疎結合につなげる


「このままじゃマズい」と感じていませんか?
参考書を開く気力もない、同年代に取り残される不安——
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
図解60P/登録10秒/解除も3秒 / 詳細はこちら

なぜterraform moduleが必要なのか

Terraformを書き始めたエンジニアが最初にぶつかる壁は「コードの重複」だ。開発環境と本番環境、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ブロックを使う。

# ルートの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" }

同じmodule(./modules/ec2)を2回呼び出している。それぞれ異なる引数を渡すことで、別々のEC2インスタンスが作られる。

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 では、呼び出し元が参照できる値を定義する。

# modules/ec2/outputs.tf output "instance_id" { value = aws_instance.this.id } output "private_ip" { value = aws_instance.this.private_ip }

呼び出し元では `module.web_server.instance_id` のように参照できる。

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 } }

count.index には 0 から始まる通し番号が入る。`aws_instance.worker[0]` `aws_instance.worker[1]` のようにインデックスでアクセスできる。

変数から個数を制御する使い方もよく使われる。

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" } }

each.key でキー名("web"など)、each.value で対応する値にアクセスできる。リソースIDは `aws_instance.servers["web"]` のように文字列キーで参照する。

6. for_eachをmoduleに適用する

for_each はmoduleブロックにも使える。環境(dev/staging/prod)ごとに構成を変えたいときに便利だ。

locals { environments = { dev = { instance_type = "t3.micro" min_size = 1 } staging = { instance_type = "t3.small" min_size = 2 } prod = { instance_type = "t3.large" min_size = 3 } } } 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 } # 環境ごとの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 変数の構造をそのまま活用できる
シンプルな同種リソースのコピー count コードが短くなる
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" }

ネストmoduleのポイントは、moduleのoutputを別のmoduleのinputとして渡せることだ。上の例では、vpcモジュールが出力する `public_subnet_id` を ec2モジュールの `subnet_id` に渡している。

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 = {} }

validationブロックを入れることで、不正な値が渡された場合に `terraform plan` 時点でエラーとして検出できる。実行時のエラーより格段に早い段階で問題を発見できる。

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. 「Error: Duplicate resource」が出る

for_each のキーに重複がある場合に発生する。同じキーを2つのマップに使っていないか確認する。

# エラーになる例(重複キー) variable "servers" { default = { "web" = { ... } "web" = { ... } # NG: 同じキー "web" が2つある } } # 実際の確認コマンド terraform plan 2>&1 | grep -i "duplicate"

11. moduleを削除するとリソースが消える

moduleブロックをコメントアウトしたり削除したりすると、そのmodule内のリソースが全て削除される。これは意図通りの動作だが、誤って消してしまう事故が多い。

削除前に必ず `terraform plan` でdestroy対象を確認すること。本番環境での削除は `lifecycle { prevent_destroy = true }` を設定しておくと安全だ。

# 削除を防ぐlifecycleの設定 resource "aws_db_instance" "main" { ... lifecycle { prevent_destroy = true } } # terraformplanで削除対象を必ず確認する terraform plan -out=tfplan terraform show -json tfplan | jq '.resource_changes[] | select(.change.actions[] == "delete")'

12. 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)) }

13. 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 = 3count.index を参照
マップ/セットで繰り返す for_each = var.mapeach.key/each.value
moduleにfor_eachを使う module "env" { for_each = local.envs }
途中の要素を削除しても安全に countではなくfor_eachを選ぶ
moduleの出力を別moduleに渡す module.module_name.output_name で参照
誤削除を防ぐ lifecycle { prevent_destroy = true }
stateのリソースを移動する terraform state mv 旧アドレス 新アドレス
moduleとfor_each/countを組み合わせることで、コードの重複を排除し、環境差分を変数だけで表現できるようになる。最初は「module化するほど規模がない」と感じるかもしれないが、3つ目の環境を作る前に設計しておくことをすすめる。後からの切り出しは想像以上に手間がかかる。

Terraformでのインフラ設計をさらに深めたい方は、tfstateのチーム運用や変数の設計パターンも合わせて学ぶと、より堅牢な構成管理ができるようになる。

postfix mynetworks の書き方はこちら
現場で通用する安全なLinuxサーバー構築の「型」を体系的に身につけたい方へ、20年以上の運用経験を持つ現役エンジニアが基礎から教えます。
Terraform実践セミナーの詳細を見る >>

無料メルマガで学習を続ける

Linuxの実践スキルをメールで毎週お届け。
登録は1分、解除もいつでも可。

登録無料・いつでも解除できます

暗記不要・1時間後にはサーバーが動く

3,100名以上が実践した「型」を無料で公開中

プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。

登録10秒/合わなければ解除3秒 / 詳細はこちら

Linux無料マニュアル(図解60P) 名前とメールで30秒登録
宮崎 智広

この記事を書いた人

宮崎 智広(みやざき ともひろ)

株式会社イーネットマーキュリー代表。現役のLinuxサーバー管理者として20年以上の実務経験を持ち、これまでに累計3,100名以上のエンジニアを指導してきたLinux教育のプロフェッショナル。「現場で本当に使える技術」を体系的に伝えることをモットーに、実践型のLinuxセミナーの開催や無料マニュアルの配布を通じてLinux人材の育成に取り組んでいる。

趣味は、キャンプにカメラ、トラウト釣り。好きな食べ物は、ラーメンにお酒。休肝日が作れない、酒量を減らせないのが悩み。最近、ドラマ「フライトエンジェル」を観て涙腺が崩壊しました。