「EC2に追加するEBSボリュームの数を環境ごとに変えたいのに、resource{}自体をfor_eachで増やしても内部のブロックを制御できない」
Terraformを実務で使い始めると、ブロック単位での繰り返し設定という壁にぶつかります。resource{}全体をfor_eachで増やすことはできても、resource{}の内部にある特定のネストブロック(ingress・ebs_block_device・global_secondary_index等)を動的に生成するには、別の構文が必要です。それがdynamic blockです。
この記事では、TerraformのHCLでネストブロックを動的に生成するdynamic blockの仕組みを解説します。基本構文から始め、iterator引数のカスタマイズ・セキュリティグループへの実務適用・DynamoDB GSIの動的定義・ネストしたdynamic block・for式との組み合わせ・よくあるエラーの切り分けまで、設計パターンとして体得できます。
実行環境:Terraform 1.8.x(RHEL 9.4 / Ubuntu 24.04 LTS で動作確認済み)
この記事のポイント
・dynamic blockはresource内のネストブロックをリストや集合から動的に生成するHCL構文
・for_each + content{} の組み合わせが基本形。iterator引数で参照名を変更できる
・セキュリティグループのingress・EBSボリューム・DynamoDB GSIが代表的な実務ユースケース
・ネストしたdynamic blockで「typeに応じてブロックを切り替える」条件分岐パターンも実現できる
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜdynamic blockが必要なのか
まず問題を具体的に確認します。AWSセキュリティグループを定義するとき、許可するポートが複数ある場合、静的な記述だとこうなります。# 静的記述の例 ── ポートが増えるたびにブロックをコピーする resource "aws_security_group" "web" { name = "web-sg" vpc_id = var.vpc_id ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } ingress { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } ingress { from_port = 8080 to_port = 8080 protocol = "tcp" cidr_blocks = ["10.0.0.0/8"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } }
for_eachでresource{}を複数作ることはできても、resource{}の内部にあるingress{}ブロックの数を変数で制御することはできません。これを解決するのがdynamic blockです。
dynamic blockの基本構文
dynamic blockの基本形は次のとおりです。# dynamic blockの基本形 resource "リソースタイプ" "名前" { # 通常の属性 dynamic "ネストブロック名" { for_each = 繰り返し元(リスト・マップ・集合) content { # ブロック内で使う属性 属性名 = ネストブロック名.value.キー } } }
・dynamic "ネストブロック名":動的に生成したいブロックの名前を指定する。セキュリティグループなら「ingress」
・for_each:繰り返す元データ。リスト・マップ・set・for式の結果を受け取れる
・content{}:各繰り返しで生成されるブロックの中身。for_eachの各要素が展開される
・ネストブロック名.value:for_eachの各要素を参照する。マップの場合は.keyと.valueが使える
先ほどの静的なセキュリティグループをdynamic blockで書き直します。まず変数定義から。
# variables.tf variable "ingress_rules" { description = "セキュリティグループのingressルール一覧" type = list(object({ from_port = number to_port = number protocol = string cidr_blocks = list(string) })) default = [ { from_port = 80, to_port = 80, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] }, { from_port = 443, to_port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] }, { from_port = 8080, to_port = 8080, protocol = "tcp", cidr_blocks = ["10.0.0.0/8"] }, ] }
# main.tf 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.from_port to_port = ingress.value.to_port protocol = ingress.value.protocol cidr_blocks = ingress.value.cidr_blocks } } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } }
iterator引数で参照名をカスタマイズする
デフォルトでは、content{}内でブロック内の値を参照するとき「ネストブロック名.value」という形式を使います。上の例だと「ingress.value.from_port」です。ブロック名が長かったり、コードの可読性を上げたい場合はiterator引数で参照名を変更できます。
# iteratorで参照名を「rule」に変更する例 resource "aws_security_group" "web" { name = "web-sg" vpc_id = var.vpc_id dynamic "ingress" { for_each = var.ingress_rules iterator = rule # デフォルトの「ingress」を「rule」に変更 content { from_port = rule.value.from_port to_port = rule.value.to_port protocol = rule.value.protocol cidr_blocks = rule.value.cidr_blocks } } }
マップをfor_eachに渡す場合は「iterator名.key」と「iterator名.value」の両方が使えます。リストの場合は「iterator名.key」がインデックス番号(0, 1, 2...)、「iterator名.value」がリストの各要素になります。iteratorを指定した後で元のブロック名を使ってしまうエラーは実務でよく見るミスです。注意してください。
>> Terraform実践セミナーの詳細はこちら
実務ユースケース
セキュリティグループ以外にも、dynamic blockが役立つ実務シーンを3つ紹介します。1. EC2のEBSボリュームを動的に追加する
EC2インスタンスに追加のEBSボリュームをアタッチするebs_block_deviceブロックも、dynamic blockで管理できます。# variables.tf variable "ebs_volumes" { description = "追加EBSボリュームのリスト" type = list(object({ device_name = string volume_size = number volume_type = string encrypted = bool })) default = [ { device_name = "/dev/sdb", volume_size = 100, volume_type = "gp3", encrypted = true }, { device_name = "/dev/sdc", volume_size = 200, volume_type = "gp3", encrypted = true }, ] }
# main.tf resource "aws_instance" "app" { ami = var.ami_id instance_type = var.instance_type dynamic "ebs_block_device" { for_each = var.ebs_volumes iterator = vol content { device_name = vol.value.device_name volume_size = vol.value.volume_size volume_type = vol.value.volume_type encrypted = vol.value.encrypted } } }
2. IAMポリシーのStatementを動的に構成する
aws_iam_policy_documentのstatementブロックも、dynamic blockで動的に組み立てられます。権限セット(ポリシーステートメント)を変数リストで管理し、環境や用途に応じて内容を変える実務ユースケースです。# variables.tf variable "iam_statements" { description = "IAMポリシーのStatementリスト" type = list(object({ sid = string actions = list(string) resources = list(string) effect = string })) default = [ { sid = "AllowS3ReadOnly" actions = ["s3:GetObject", "s3:ListBucket"] resources = ["arn:aws:s3:::my-app-bucket", "arn:aws:s3:::my-app-bucket/*"] effect = "Allow" }, { sid = "AllowCloudWatchLogs" actions = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"] resources = ["arn:aws:logs:ap-northeast-1:123456789012:*"] effect = "Allow" }, ] }
# main.tf data "aws_iam_policy_document" "app" { dynamic "statement" { for_each = var.iam_statements iterator = stmt content { sid = stmt.value.sid actions = stmt.value.actions resources = stmt.value.resources effect = stmt.value.effect } } } resource "aws_iam_policy" "app" { name = "${var.env}-app-policy" policy = data.aws_iam_policy_document.app.json }
3. DynamoDB Global Secondary Indexを動的に定義する
DynamoDBテーブルにGSI(グローバルセカンダリインデックス)を追加する場合、attributeブロックとglobal_secondary_indexブロックの両方を定義する必要があります。GSIの数が可変の場合、dynamic blockで両ブロックを連動して動的生成できます。# variables.tf variable "gsi_definitions" { description = "DynamoDB GSIの定義リスト" type = list(object({ name = string hash_key = string range_key = optional(string) projection_type = optional(string, "ALL") })) default = [ { name = "gsi-status-index", hash_key = "status", range_key = "created_at", projection_type = "ALL" }, { name = "gsi-owner-index", hash_key = "owner_id", projection_type = "KEYS_ONLY" }, ] }
# locals.tf ── GSIのキー属性名をユニークに集約する locals { # GSIのhash_key/range_keyをフラット展開して重複を排除する gsi_attribute_names = toset(flatten([ [for gsi in var.gsi_definitions : gsi.hash_key], [for gsi in var.gsi_definitions : gsi.range_key if gsi.range_key != null], ])) }
# main.tf resource "aws_dynamodb_table" "app" { name = "${var.env}-app-table" billing_mode = "PAY_PER_REQUEST" hash_key = "pk" range_key = "sk" # テーブルのプライマリキー属性(固定) attribute { name = "pk" type = "S" } attribute { name = "sk" type = "S" } # GSIで使うキー属性をプライマリキー以外で動的に追加 dynamic "attribute" { for_each = setsubtract(local.gsi_attribute_names, toset(["pk", "sk"])) content { name = attribute.value type = "S" } } # GSI本体を動的に生成(リストをマップに変換してfor_eachに渡す) dynamic "global_secondary_index" { for_each = { for gsi in var.gsi_definitions : gsi.name => gsi } iterator = gsi content { name = gsi.value.name hash_key = gsi.value.hash_key range_key = gsi.value.range_key projection_type = gsi.value.projection_type } } }
・setsubtract()でプライマリキーとの重複を排除:GSIのキー属性にpkやskが含まれていた場合、同じ名前のattributeブロックを二重定義するとエラーになります。setsubtractでプライマリキーを除外することで重複を防いでいます
・リスト→マップ変換:GSI定義はリストで管理しますが、for_eachではマップを渡す方がtfstate上の識別子が安定します。`{ for gsi in var.gsi_definitions : gsi.name => gsi }`でGSI名をキーにしたマップに変換しています
・2つのdynamic blockが連動:同一の変数リストからattributeブロックとglobal_secondary_indexブロックを別々に生成することで、GSI追加時の変数変更が1箇所で済みます
GSIを追加したい場合はgsi_definitionsに1要素追加するだけで、attributeブロックとglobal_secondary_indexブロックの両方が自動で増えます。
ネストしたdynamic block(応用パターン)
dynamic blockの中にdynamic blockをネストすることもできます。ALBリスナールールのconditionブロックは、その中にpath_patternやhost_headerといったネストブロックを持ちます。conditionのtypeによって生成するネストブロックを切り替えたい場合に、ネストしたdynamic blockが役立ちます。# variables.tf variable "routing_rules" { description = "ALBリスナールールの定義リスト" type = list(object({ priority = number target_group_arn = string conditions = list(object({ type = string # "path" または "host" values = list(string) })) })) default = [ { priority = 100 target_group_arn = "arn:aws:elasticloadbalancing:ap-northeast-1:123456789012:targetgroup/api/xxx" conditions = [ { type = "path", values = ["/api/*"] }, { type = "host", values = ["api.example.com"] }, ] }, ] }
# main.tf resource "aws_lb_listener_rule" "app" { for_each = { for rule in var.routing_rules : rule.priority => rule } listener_arn = var.listener_arn priority = each.value.priority action { type = "forward" target_group_arn = each.value.target_group_arn } # 外側のdynamic block: conditionブロックをリストから生成 dynamic "condition" { for_each = each.value.conditions content { # 内側のdynamic block: typeに応じてpath_patternまたはhost_headerを生成 dynamic "path_pattern" { for_each = condition.value.type == "path" ? [condition.value] : [] content { values = path_pattern.value.values } } dynamic "host_header" { for_each = condition.value.type == "host" ? [condition.value] : [] content { values = host_header.value.values } } } } }
ネストしたdynamic blockを使う際の注意点を2つ挙げます。
・外側のiteratorを内側で引き継ぐ:上の例では外側のdynamic blockで生成される要素を内側のcontent{}内で`condition.value`として参照しています。外側にiteratorを設定した場合はiterator名.valueを使います
・深いネストは可読性を下げる:ネストが3段以上になるとコードの理解が難しくなります。localsで段階的にデータを変換してから各dynamic blockに渡す方が保守しやすくなります
for式との組み合わせでリストを変換・フィルタリングする
dynamic blockのfor_eachにはfor式の結果を渡せます。変数リストをそのまま使うだけでなく、条件フィルタリングや値の変換を加えた動的生成が可能になります。4. 本番環境のみ特定ポートを許可する(条件フィルタリング)
# variables.tf variable "ingress_rules" { type = list(object({ from_port = number to_port = number protocol = string cidr_blocks = list(string) prd_only = optional(bool, false) # 本番専用フラグ })) default = [ { from_port = 80, to_port = 80, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"], prd_only = false }, { from_port = 443, to_port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"], prd_only = false }, { from_port = 8443, to_port = 8443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"], prd_only = true }, ] }
# main.tf resource "aws_security_group" "web" { name = "${var.env}-web-sg" vpc_id = var.vpc_id dynamic "ingress" { # for式でフィルタリング: prd_onlyがtrueのルールは本番環境のみ生成 for_each = [ for rule in var.ingress_rules : rule if !rule.prd_only || var.env == "prd" ] iterator = rule content { from_port = rule.value.from_port to_port = rule.value.to_port protocol = rule.value.protocol cidr_blocks = rule.value.cidr_blocks } } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } }
開発環境でterraform planを実行して確認します。
# TF_VAR_envにdevを渡してplan $ TF_VAR_env=dev terraform plan # 出力(ingressブロックが2つ生成される ─ ポート8443は除外) # aws_security_group.web will be created + resource "aws_security_group" "web" { + ingress { + from_port = 80 + to_port = 80 ... } + ingress { + from_port = 443 + to_port = 443 ... } } # TF_VAR_envにprdを渡してplan(ポート8443のブロックが追加される) $ TF_VAR_env=prd terraform plan + resource "aws_security_group" "web" { + ingress { from_port = 80 ... } + ingress { from_port = 443 ... } + ingress { from_port = 8443 ... } }
トラブルシュート:dynamic blockのよくあるエラー
5. 「The for_each value depends on resource attributes...」エラー
Error: Invalid for_each argument on main.tf line 15, in dynamic "ingress": 15: for_each = aws_instance.app.*.network_interface The "for_each" value depends on resource attributes that cannot be determined until apply, so Terraform cannot determine the full set of keys that will identify the instances of this resource.
対処:for_eachにはplanで確定できる静的な値(variable・locals・data source)を渡す。どうしても動的な値が必要な場合は`-target`で依存リソースを先にapplyしてから対象リソースをapplyする。
6. content{}内でブロック名の参照が動かない
Error: Reference to undeclared resource on main.tf line 18, in resource "aws_security_group" "web": 18: from_port = ingress.value.from_port A managed resource "ingress" "value" has not been declared.
対処:`iterator = rule`と指定した場合は`rule.value.from_port`で参照する。iteratorを指定しない場合はブロック名`ingress.value.from_port`で参照する。どちらかに統一する。
7. for式のフィルタ後が空になり期待通りにならない
# フィルタ条件のミスで空リストになる例 dynamic "ingress" { for_each = [ for rule in var.ingress_rules : rule if rule.prd_only && var.env == "dev" # dev環境ではprd_only=trueが全除外 ] ... } # 結果: ingress{}ブロックが1つも生成されない
対処:for式のフィルタ条件を見直す。開発環境では最低限のルール(HTTP 80番等)が必ず残るように条件を設計する。terraform planで生成されるブロック数を必ず確認してからapplyすること。
8. setsubtractやtosetで型変換エラーが出る
Error: Invalid function argument on locals.tf line 4: 4: gsi_attribute_names = setsubtract(local.gsi_keys, ["pk", "sk"]) Invalid value for "a" parameter: argument must be a set type, not list of string.
対処:toset()で明示的にset型に変換してから渡す。localsで`toset(flatten([...]))`のようにset型で定義しておくか、呼び出し側で`setsubtract(toset(local.gsi_keys), toset(["pk", "sk"]))`と変換する。
本記事のまとめ
Terraformのdynamic blockについてまとめます。| ポイント | 内容 |
|---|---|
| dynamic blockの用途 | resource{}内のネストブロック(ingress・ebs_block_device・global_secondary_index等)をリストやマップから動的に生成する |
| 基本構文 | dynamic "ブロック名" { for_each = リスト; content { ブロック名.value.キー } } |
| iterator引数 | content{}内の参照名を変更できる。長いブロック名を短縮して可読性を上げる時に使う |
| for式との組み合わせ | for_eachにfor式の結果を渡し、条件フィルタリングや変換後のリストを動的生成の元データにできる |
| ネストしたdynamic block | dynamic blockの内側にさらにdynamic blockを書ける。typeで生成するブロックを切り替える「1要素か空リストか」パターンが定石 |
| 空リストの扱い | for_eachが空の場合、dynamic blockはブロックを0個生成する(エラーではない) |
| for_eachの制限 | planで値が確定しないリソース属性は渡せない。variable・locals・data sourceを使う |
| DynamoDB GSIの動的定義 | attributeブロックとglobal_secondary_indexブロックを1つの変数リストから連動して生成できる。setsubtractでプライマリキーとの重複を防ぐ |
>> Terraform実践セミナーの詳細はこちら
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

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