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

宮崎智広 この記事の監修:宮崎智広(Linux実務・教育歴20年以上・受講者3,100名超)
HOME > Linux技術 リナックスマスター.JP(Linuxマスター.JP) > Ansible > Terraformのmoduleとfor_each・countで構成を再利用する設計パターン
「Terraformコードが増えるにつれて、似たようなリソース定義が至る所にコピーされてしまう」
「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を使うとリソース内のブロックを宣言的に管理できる


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

なぜ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ブロックを使う。

# ルートの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インスタンスが作られる。

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

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

countで作成されたリソースはstate内で数値インデックス付きのアドレスで管理される。terraform state listで確認すると以下のようになる。

$ 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.

リストから1要素を削除しただけで、2台が破棄される計画が立てられた。本番環境ではサービス断につながる致命的な問題だ。

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 }

`[*]` はスプラット式で、全インデックスの値をリストとして取得できる。moduleにcountを使う場合もインデックスずれ問題は同様に発生するため、各サーバーに意味のある名前がある場合はfor_eachを選ぶほうが安全だ。

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

for_eachで作成されたリソースはstate内で文字列キーで識別される。terraform state listで確認すると、countとは異なりキー名がそのままアドレスになっていることがわかる。

$ terraform state list aws_instance.web["web-01"] aws_instance.web["web-02"] aws_instance.web["web-03"]

「web-02」を削除しても、Terraformは `aws_instance.web["web-01"]` と `aws_instance.web["web-03"]` が引き続き存在することを認識する。破棄されるのは `aws_instance.web["web-02"]` だけだ。

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

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

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

環境ごとに複数のパラメータがあっても、localsのマップに集約しておけばfor_eachで一気に展開できる。どの環境にどのスペックを使うかが1箇所で把握できる点が実務上の大きなメリットだ。

countとfor_eachの使い分け判断基準

どちらを使うべきか迷うことが多い。以下の基準で判断してほしい。
状況 推奨 理由
数だけ決まっていて、個々に意味がない count シンプルに書ける
各要素に意味のある名前がある for_each リソース管理がキー名で明確になる
途中の要素を削除する可能性がある for_each インデックスがずれないため安全
マップ/セット型の変数がある for_each 変数の構造をそのまま活用できる
dev・staging・prodなど環境ごとの設定がある for_each localsマップと組み合わせて一元管理できる
シンプルな同種リソースのコピー count コードが短くなる
リソースを「作る・作らない」を切り替えたい count = 条件 ? 1 : 0 0か1の切り替えならインデックスずれなし
countの落とし穴:インデックスのずれ問題

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

このパターンは要素が常に0個か1個のため、インデックスずれの心配がない。迷ったらfor_eachを選んでよいが、「0か1か」の条件制御はcountが自然に書ける場面として覚えておくとよい。

実践的な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" }

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

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

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

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 }

このパターンのメリットは「新しい環境を追加するとき、localsのマップに1エントリ追加するだけで完結する」点だ。本番環境だけ `enable_deletion_protection = true` にするといった環境固有の設定もマップ内で明示できる。運用が本格化してパラメータが増えてきたら、locals.tfを独立ファイルにして環境設定を集中管理することを強く勧める。module本体のコードには環境差分を持ち込まず、ロジックと設定を分離するのが実務での設計の鉄則だ。

管理環境が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を使わない場合、ingressブロックをルールの個数分コピーして書かなければならない。dynamic blockにすることで変数でルールを管理でき、ルールの追加・削除が変数の編集だけで完結する。

ひとつ重要な違いがある。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)

原因2: apply前に値が確定しないリソース属性をキーに使っている

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.

この場合の解決策は2つある。ひとつは、localsで「plan時点で確定できる固定文字列」をキーとして使う方法だ。

# 解決策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 } }

もうひとつは、エラーメッセージにも記載されている `-target` を使って依存元リソースだけを先にapplyし、2回に分けて実行する方法だ。

# 解決策2: -targetで依存元リソースを先にapplyする(2段階実行) # ステップ1: for_eachが依存するVPCだけ先にapply terraform apply -target=aws_vpc.main # ステップ2: VPCのidが確定した後で全体をapply terraform apply

`-target` を使うアプローチは応急処置的であり、可能であれば固定文字列キーを使う設計に変更することが望ましい。

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の実行後は必ずterraform planで差分がないことを確認してからapplyに進む。確認なしでapplyすると、想定外の差分が残っていた場合にリソースの再作成が発生する。

# 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 # エラー }

countで作成されたリソースはリスト形式で管理される。count = 0のときはリストの長さがゼロになり、インデックスなしの直接参照でエラーになる。length()で存在確認してから参照するのが正しいパターンだ。

# 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.lock.hcl` はプロバイダのバージョンロックファイルだ。チームで同じプロバイダバージョンを使うために、このファイルはgitignoreに含めずコミット対象にすることを推奨する。一方、機密値(APIキーやパスワード)を含む `.tfvars` ファイルは必ずgitignoreに追加すること。

本記事のまとめ

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段階実行
moduleとfor_each/countを組み合わせることで、コードの重複を排除し、環境差分を変数だけで表現できるようになる。localsブロックへの環境設定の集約とfor_eachの組み合わせは、チームで運用するリポジトリで特に効果を発揮する。「prod環境のスペックを変えたいとき、どのファイルを編集すればいいか」が誰でも一目でわかるからだ。

countは直感的でシンプルだが、リストへの変更が発生する構成に使い続けると本番環境で意図しないリソース破棄を引き起こす。迷ったらfor_eachを選んでおく方が、長期的な運用リスクを下げられる。

最初は「module化するほど規模がない」と感じるかもしれないが、3つ目の環境を作る前に設計しておくことをすすめる。後からの切り出しは想像以上に手間がかかる。

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

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

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

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

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

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

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

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

姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

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

この記事を書いた人

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

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

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