インフラ

terraform dynamicブロックとcount・for_eachの使い分け|index事故の回避

Terraformの繰り返しでつまずく箇所は、構文の暗記ではなく層の取り違えにあります。countfor_each はリソースそのものを増やす引数で、dynamic は1つのリソースの内側にネストブロックを増やす構文です。この層が混ざったまま書くと、リストの要素を1つ削っただけで無関係なサーバーが作り直される事故につながります。この記事では三者の担当範囲、インデックス同一性が招く再作成とその戻し方、for_each が止まる条件、for式と templatefile の使いどころを、HashiCorp公式ドキュメントの記述に沿って整理します。

まとめ:反復はfor_each優先・ブロック内の反復だけdynamic

判断の起点は「何を増やしたいか」の一点です。EC2インスタンスやS3バケットのようにリソースを複数作りたいなら countfor_each、セキュリティグループの ingress のように1つのリソースが持つネストブロックを可変にしたいなら dynamic。この2層は排他ではなく、外側で for_each、内側で dynamic という組み合わせも成立します。

リソースを増やす側は for_each を既定にします。count はインスタンスの同一性を整数インデックスで持つため、リストの途中を削ると後続のアドレスが1つずつ繰り上がり、Terraformは別物になったと判断して破棄と再作成を計画します。for_each はキーで同一性を持つので、途中の要素を消しても残りのアドレスは動きません。

dynamic は書けば書くほど読めなくなる構文です。公式ドキュメント自身が「Overuse of dynamic blocks can make configuration hard to read and maintain」と注意し、可能な範囲でリテラルなブロック定義を推奨しています。反復数が固定に近いなら、素直に並べたほうが差分レビューが通ります。

count・for_each・dynamicが担う3つの反復の層

3つを同じ「繰り返し」として覚えると選択を誤ります。担当範囲を先に固定します。

リソースを増やすcountとfor_each、ブロックを増やすdynamic

count と for_each はメタ引数です。resource ブロックや module ブロックに付けると、そのブロック全体が指定回数ぶん複製されます。

dynamic の担当は別です。リソースを複製せず、リソース定義の内側に書く ingresssetting のようなネストブロックを、コレクションの要素数ぶん生成します。生成されるのはあくまで引数であり、公式は「A dynamic block can only generate arguments that belong to the resource type, data source, provider or provisioner being configured」と対象を限定しています。宣言的な構成管理そのものの考え方はIaCとは?Infrastructure as Codeの仕組み・メリットと導入判断を参照してください。

countがインデックス、for_eachがキーで同一性を持つ

両者の差は書き味ではなく、stateに記録されるアドレスの形にあります。count を使ったリソースは aws_instance.web[0] のように連番で識別され、for_each を使ったリソースは aws_instance.web["ap-northeast-1a"] のようにキーで識別されます。

公式の使い分けも同じ線で引かれています。「Use the count argument when you want to create nearly identical instances. Use for_each when some instance arguments must have distinct values that can’t be directly derived from an integer index.」という記述です。整数から機械的に導けない値を持つなら for_each、という判定になります。

count・for_each・dynamicの担当範囲を1枚で見比べる

下の表は、選択のときに見る観点だけを並べたものです。

構文 増えるもの 同一性の持ち方 参照変数 向く場面
count リソース・モジュール 整数インデックス count.index 0/1の作成スイッチ・完全同型のN台
for_each リソース・モジュール マップキー/セット値 each.key・each.value 要素ごとに値が異なる複数リソース
dynamic ネストブロック(引数) —(順序のみ) ラベル名.key・.value ingress等の可変長ブロック

dynamic の行に同一性が無いのは、生成されるのがリソースではなく引数だからです。順序が変わればプランに差分は出ますが、リソースが破棄されるわけではありません。命名規則やブロックの並び順といった記述面の統一ルールはTerraformコーディング規約(スタイルガイド)で扱っています。

countのインデックス差し替えで起きる作り直しと、その戻し方

count の事故は、コードのレビューでは見つからず、plan の出力で初めて姿を現します。

countでリストの途中の要素を削ると後続のアドレスがずれる仕組み

var.names = ["api", "batch", "web"] に対して count = length(var.names) と書いた場合、stateには [0]=api[1]=batch[2]=web が記録されます。ここから “batch” を外して2要素にすると、[1] の中身は web に、[2] は存在しないものに変わります。

Terraformが見ているのはアドレスであって名前ではありません。[1] は batch のままだと想定して差分を取るため、「[1] を batch から web へ変更、[2] を破棄」というプランが出ます。実体としては batch を1台消したかっただけなのに、web の作り直しが混ざる状態です。plan の出力から破壊的な操作を読み取る手順はterraform planの差分の読み方|init・apply・destroyの安全手順でまとめています。

movedブロックでアドレスを付け替えて破棄と再作成を止める

すでに count で運用しているコードを for_each へ切り替えるときも、同じずれが発生します。この付け替えに使うのが moved ブロックです。公式によれば、Terraformは from フィールドのアドレスで既存オブジェクトを確認し、to フィールドで指定されたアドレスへ改名してからプランを作ります。破棄と再作成を挟まずに移せる仕組みです。

moved {
  from = aws_instance.web[0]
  to   = aws_instance.web["api"]
}

要素の数だけ moved ブロックを書き、terraform plan で「0 to destroy」を確認してからapplyします。ここで destroy が残るなら、from のアドレスが実際のstateと合っていません。tfstateの中身を直接確認したい場合はTerraform stateとは?tfstateの構造とS3バックエンド・移動削除の安全手順を参照してください。

countを使ってよい条件は作成スイッチと同型N台の2つに絞る

for_each を既定にしたうえで、count を選んでよい場面は限られます。1つは count = var.create_alb ? 1 : 0 のような作成スイッチで、要素の増減が起きないため、インデックスがずれる余地がありません。もう1つは、引数が完全に同一で名前も持たないN台の複製、たとえば検証用に同一構成のワーカーを並べるケースです。

逆に、リストの要素が名前やゾーンを持ち、将来1つだけ外す可能性があるなら、初回から for_each で書きます。

for_eachが受け付ける型と、apply時未確定値でつまずく条件

for_each は万能ではなく、明確に止まる条件があります。エラー文面と原因を結び付けておきます。

受け付けるのはmapとset(string)、listはtosetで変換する

公式は for_each が受け入れる型を map と文字列の set に限定し、list や tuple は暗黙変換されないと説明しています。リストを渡すときは toset(var.names) で明示的に変換してください。モジュールの入力として受ける場合は、変数の型を set(string) と宣言しておけば呼び出し側での変換が不要です。変数の型制約の書き方はterraform variableとlocals・tfvarsの使い分けで扱っています。

参照する変数も型で変わります。each.key はマップキーまたはセットメンバー、each.value はマップの値で、セットを渡した場合は each.key と同じ値になります。setに対して each.value を使ってもエラーにはならないため、マップへ差し替えたときに意味が変わる点だけ意識してください。

for_eachのキーがapply後にしか分からないと計画が止まる

最も多い停止理由はこれです。公式は「All values that the for_each argument iterates over must be known before Terraform performs any remote resource operations」と明記しています。作成後にAWS側が採番するIDをキーに使うと、プラン時点でキー集合が確定せず、Terraformは処理を進められません。

回避策は、キーに使う値を自分で決めることです。リソースのIDではなく、変数やlocalsで定義した論理名をキーにし、値の側にIDを持たせます。uuid()bcrypt()timestamp() のような実行ごとに結果が変わる関数も、同じ理由でキーに使えません。data sourceの戻り値をキーにする設計も、参照先の確定タイミングによっては同じ壁に当たります。この依存関係の扱いはterraform dataの使い方|data sourceの参照設計と依存の暗黙化で整理しました。

sensitiveな値をfor_eachのキーに渡せない理由と回避策

公式は sensitive な入力変数や出力を for_each の引数として認めていません。キーはリソースのアドレスとしてplanの出力やstateに現れるため、機密値をキーにすると隠す手段が無くなるからです。

APIキーごとにリソースを作るような設計をしたい場合は、キーを論理名にして、機密値は各インスタンスの引数として渡す形に組み替えます。同一ブロックで count と for_each は併用できません。

dynamicブロックの構文とiterator・labels・contentの役割

ここからは内側の層です。構文要素を1つずつ確認します。

dynamicの5つの構成要素と、iteratorを省略したときの変数名

dynamic ブロックは、生成するネストブロックの種類を示すブロックラベル、反復対象を与える for_each、現在の要素を指す一時変数名を決める iterator、生成する各ブロックのラベル列を与える labels、ブロック本体を書く content で構成されます。iterator と labels は省略でき、iterator を省いた場合はブロックラベルと同じ名前が変数名になります。

イテレータオブジェクトが持つ属性は keyvalue の2つだけです。key にはマップキーまたはリスト要素のインデックスが入り、value には現在の要素が入ります。

セキュリティグループのingressをdynamicで可変にする実装例

用途として最も多いのが、ルール数が環境ごとに変わるセキュリティグループです。

variable "ingress_rules" {
  type = map(object({
    port        = number
    cidr_blocks = list(string)
  }))
  default = {
    https = { port = 443, cidr_blocks = ["0.0.0.0/0"] }
    ssh   = { port = 22,  cidr_blocks = ["10.0.0.0/8"] }
  }
}

resource "aws_security_group" "app" {
  name   = "app-sg"
  vpc_id = var.vpc_id

  dynamic "ingress" {
    for_each = var.ingress_rules
    content {
      description = ingress.key
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}

iterator を省いたため、ingress.keyingress.value で要素を参照しています。マップのキーをそのまま description に流し込めば、コンソール上でどのルールがどの定義由来か追えます。ネストが深くなり同名のラベルが重なる場合は iterator = rule で明示し、参照先の取り違えを防いでください。

dynamicで生成できないlifecycleなどのメタ引数ブロック

メタ引数のブロックは対象外です。公式は lifecycle や provisioner のようなメタ引数ブロックを dynamic では生成できないと明記しています。create_before_destroy を環境ごとに切り替えたいといった要求は、dynamic ではなく変数の埋め込みか、リソース定義自体の分岐で解きます。lifecycleにリテラル値しか書けない理由と、環境差を分岐で表す設計はterraform lifecycleの使い分け|ignore_changes・depends_on・movedの判断で扱いました。

同様に、countfor_each そのもの、depends_on も生成できません。dynamic が触れるのは、プロバイダのスキーマがネストブロックとして定義している引数だけ、と覚えておけば判断を誤りません。

for式とtemplatefileで反復の前段と後段を分ける

反復の可読性は、ループ構文よりも「何を渡すか」で決まります。整形は for 式、ファイル生成は templatefile が担当します。

for式のifフィルタとグルーピングモードで反復対象を整える

for 式は、角括弧で囲めばタプル、波括弧で囲めばオブジェクトを返します。[for s in var.list : upper(s)]{for s in var.list : s => upper(s)} が基本形で、後者は for_each に渡すマップを組み立てる用途にそのまま使えます。

末尾の if はフィルタです。[for s in var.list : upper(s) if s != ""] と書けば、空要素を落としたコレクションが得られます。値式のあとに ... を置くとグルーピングモードになり、同じキーへ複数の要素をまとめた map of lists が返ります。ロールごとにユーザー名を束ねる整形にはこの書式を使ってください。

順序には規則があります。マップとオブジェクトはキーの辞書順、文字列のセットは値の辞書順に並び、それ以外の型のセットは将来のバージョンで変わりうる任意の順序です。並び順に依存した実装を書かないでください。

templatefileで設定ファイル側の反復を外部テンプレートに出す

nginxの設定やcloud-initのように、生成物が長い文字列になるものは templatefile(path, vars) で外部ファイルへ逃がします。テンプレート内では ${ } の補間に加え、%{ for } のループと %{ if } の条件が使え、~ を添えると不要な改行を削れます。

resource "aws_instance" "app" {
  ami           = var.ami_id
  instance_type = "t3.small"
  user_data = templatefile("${path.module}/init.sh.tftpl", {
    hosts = var.backend_hosts
  })
}

テンプレート側の拡張子は *.tftpl が推奨されています。注意点は2つで、templatefile の再帰呼び出しはできないこと、そして対象ファイルはTerraformの実行開始時点で存在している必要があるため、同じ構成の中で動的に生成したファイルは渡せないことです。

HCL側で長い文字列を組み立てるより、テンプレートを別ファイルに置くほうがレビューが通ります。環境ごとに渡す値を変える方式の比較はterraform workspaceの使い方と環境分離の判断基準で扱っています。

dynamicを見送る境界:リテラルで書いたほうが保守できる条件

ここが判断の分かれ目です。公式が「Overuse of dynamic blocks can make configuration hard to read and maintain」と書いている以上、採用条件を自分たちで決めておく必要があります。当社がインフラ構築の受託でレビュー基準に置いている線を、条件付きで言い切ります。

dynamicを使わずリテラルで直接並べたほうがよい3つの条件

第一に、反復数が2以下で固定のとき。ingress が443と22の2本しかなく、環境ごとに増減しないなら、dynamic の6行より literal の8行のほうが読めます。第二に、要素ごとに引数の構成が違うとき。片方だけ security_groups を持つような場合、content の中が条件分岐だらけになり、生成結果を頭の中で展開しないと差分が読めなくなります。

第三に、その値がレビューの対象そのものであるとき。公開範囲を決めるCIDRやIAMポリシーのように、レビュアーが1行ずつ目で確認する類の設定は、変数の奥に隠さず定義ファイルに直接書いたほうが監査に耐えます。dynamic は「見えなくする」構文でもあるという前提で選びます。

ネストしたdynamicの多段化は上限を2段までに置く運用の目安

dynamic の中にさらに dynamic を書く多段ネストは文法上は成立しますが、可読性の低下が急です。運用の目安として、ネストは2段までとし、3段が必要になった時点でモジュール分割かリソース分割を検討します。モジュール境界の引き方はTerraform moduleの分割粒度とoutput設計で整理しています。

モジュールの入力がリソース引数の写しになるなら呼び出し側で書く

公式は、モジュールの入力変数の属性がリソースの引数にほぼ直接対応しているだけの構成について、呼び出し側でリソースを定義するほうが適切だと述べています。dynamic でラップしたモジュールは、プロバイダの新しい引数が追加されるたびに変数定義の追従が必要になり、抽象化の利益より維持費が上回ります。

採用してよいのは、複数のリソースをまとめて意味のある単位(VPC一式、監視一式)にしている場合です。1リソース1モジュールで引数を素通しするだけなら、モジュール化そのものを見送ります。こうした構成方針の設計から実装・運用までを含めた相談は、インフラ構築(AWS・Google Cloud・Azure)で受け付けています。

よくある質問

反復構文まわりで実際に検索されている質問へ、公式ドキュメントの記述をもとに答えます。

terraformのcountとfor_eachはどちらを使うべきですか?

原則は for_each です。count はインスタンスを整数インデックスで識別するため、リストの途中の要素を削除すると後続のアドレスが繰り上がり、意図しない破棄と再作成が発生します。for_each はマップキーで識別するので、途中の要素を消しても残りは動きません。count を選んでよいのは、0か1の作成スイッチとして使う場合と、引数が完全に同一のN台を並べる場合の2つに絞られます。

dynamicブロックとfor_eachは何が違いますか?

増やす対象の層が違います。リソースブロックに付ける for_each はリソースそのものを複数作り、dynamic はリソース1つの内側にあるネストブロック(ingressやsettingなど)を複数生成します。dynamic の中に書く for_each はブロックの反復対象を与えるもので、リソースは増えません。外側で for_each、内側で dynamic という併用も成立します。

for_eachで「Invalid for_each argument」と出るのはなぜですか?

キー集合がプラン時点で確定していない場合に出ます。公式は、for_each が反復する値はリモート操作の前にすべて既知でなければならないと明記しています。作成後にクラウド側が採番するIDや、uuid・timestamp のような実行ごとに変わる関数の戻り値をキーにした場合が典型です。キーは変数やlocalsで定義した論理名にし、確定しない値は each.value 側の引数として渡す形へ組み替えてください。sensitive な値もキーには使えません。

countからfor_eachへ移行するとき既存リソースは作り直しになりますか?

moved ブロックを書けば作り直しは避けられます。moved { from = aws_instance.web[0] to = aws_instance.web["api"] } のように旧アドレスと新アドレスを対応付けると、Terraformはstate上のオブジェクトを改名してからプランを作ります。要素数ぶんの moved を書いたうえで plan を実行し、destroy が0件であることを確認してからapplyしてください。destroy が残る場合は from のアドレスがstateと一致していません。

templatefileとtemplate_fileはどちらを使えばよいですか?

関数版の templatefile を使ってください。かつて存在した template_file データソースは外部プロバイダに依存し、現在は関数版に置き換えられています。templatefile はテンプレート内で %{ for }%{ if } のディレクティブに対応し、拡張子は *.tftpl が推奨です。テンプレート内から templatefile を再帰的に呼び出すことはできず、渡すファイルは実行開始時点で存在している必要があります。

関連記事

資料請求

RELATED POSTS 関連記事