ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Terraform 循环与动态资源:count、for_each 与 dynamic

Terraform 循环与动态资源:count、for_each 与 dynamic

只从列表里删掉一个元素,terraform plan 却给出 2 to change, 1 to destroy。
这篇文章从一次真实的索引漂移(index drift)事故出发,讲清 count、for_each、dynamic 三个循环元参数(meta-argument)的追踪机制、能力边界与选型方法。

一、场景:一份配置里的三种重复

写配置时遇到的重复分三种,数量、身份、结构各管一层,工具却有三个,选错的代价从一次 plan 说起。

重复的对象典型场景对应手段
多个同质顶级资源批量创建同配置计算节点count
有独立身份的集合按服务名逐个创建任务for_each
一个资源内重复的结构块安全组的多条规则dynamic

三种重复形似神不似:前者关心数量,中者关心身份,后者关心结构。我早期把三类混为一谈,全用 count 硬扛,直到线上那次删除事故才分开对待。

三类重复如果配错了工具,plan 会付出什么代价?

二、踩坑:删一个元素,2 更改 1 销毁

那次事故的配置用 count 遍历列表,五个字符串各生成一个 terraform_data 实例:

variable "my_list" { type = list(string) default = ["zero", "one", "to_delete", "two", "three"] } resource "terraform_data" "my_count" { count = length(var.my_list) input = "tf_data-${var.my_list[count.index]}" }

我从列表里删掉 to_delete,只改了一个字符串,terraform plan 的输出却是:

# terraform_data.my_count[2] will be updated in-place ~ input = "tf_data-to_delete" -> "tf_data-two" # terraform_data.my_count[3] will be updated in-place ~ input = "tf_data-two" -> "tf_data-three" # terraform_data.my_count[4] will be destroyed - input = "tf_data-three" -> null Plan: 0 to add, 2 to change, 1 to destroy.

两个原地更新加一个销毁,全部源于一次删除。我当时的判断是 Terraform 算错了,复现三遍之后才确认不是偶发。回去翻变更记录才发现,预发环境里类似动作已经发生过一次,只是当时列表短,漂移被巧合掩盖了。

注意:漂移的根源不是 count 的语法,而是它用数字索引给资源编地址。索引一变地址就变,地址一变,plan 眼里的就是另一个资源——地址机制具体是怎样裁决计划动作的?

三、原理:地址决定追踪,键比索引稳

Terraform 用资源地址(resource address)在状态文件里定位每个实例。地址由资源类型、资源名加一段标识组成:count 生成数字索引标识,for_each 生成键标识。

元参数地址形态单实例引用整组引用
countaws_instance.server[0]aws_instance.server[0].idaws_instance.server
for_eachaws_iam_user.the-accounts[“Todd”]aws_iam_user.the-accounts[“Todd”].idaws_iam_user.the-accounts

plan 的裁决分两步:先比对配置算出的地址集合与状态里的地址集合,地址消失判销毁、地址新增判创建;集合一致才逐项比对属性,属性变化判原地更新,无变化才判 no change。

数字索引的数值由元素在列表中的位置决定,与元素是谁无关;键的取值来自元素本身。删掉中间一个元素,索引整体前移而键原地不动,这正是漂移与否的分水岭,也是 count 适合固定池、for_each 适合变动集的根因。反过来说,列表只追加不改写的场景里,索引与键的差别会被掩盖,直到第一次中间删除才暴露。

删除列表中间元素前后的地址集合 count(索引地址,随位置漂移) ┌─ 删除前 ─ [0]zero [1]one [2]to_delete [3]two [4]three ├─ 删除后 ─ [0]zero [1]one [2]two [3]three └─ 裁决 ─── [2] 属性变 -> update [3] 属性变 -> update [4] 地址消失 -> destroy for_each(键地址,跟内容绑定) ┌─ 删除前 ─ "zero" "one" "to_delete" "two" "three" ├─ 删除后 ─ "zero" "one" "two" "three" └─ 裁决 ─── 仅 "to_delete" -> destroy 其余地址与属性均不变 -> no change

以键定位还有两个硬约束:键必须是已知值,uuid、bcrypt、timestamp 这类非纯函数(pure function)的结果不能作键;敏感值也不能作键,否则地址会随计划输出暴露。

注意:非纯函数结果与敏感值都不能作键。机制清楚之后,三个元参数各自该在什么场景出场?

四、方案:三个元参数,各管一层重复

选型时你先看重复发生在哪一层:顶层资源用 count 或 for_each,资源内部的嵌套块(nested block)用动态块(dynamic block)。同一层内再看身份:实例可互换、从不单独增删,索引足够;实例有名字、集合会变,键才安全。

维度countfor_eachdynamic
适用对象资源 / 模块多实例资源 / 模块多实例资源内的嵌套块
标识方式数字索引键(map key 或 set 成员)无独立标识
中间增删索引漂移,连锁变更键不变则零影响不涉及生命周期
条件创建? 1 : 0全有或全无按条件过滤? [1] : []跳过
典型场景固定数量同质节点按身份逐个创建安全组规则、监听器

条件创建的三种写法对应三种粒度:

# count:全有或全无 count = var.create_instance ? 1 : 0 # for_each:用 for 表达式(for expression)按条件过滤 for_each = { for name, cfg in var.services : name => cfg if cfg.enabled } # dynamic:用空列表跳过嵌套块 for_each = var.enable_access_logs ? [1] : []

多个布尔条件先用 locals 命名化,避免在 count 里堆叠长表达式:

locals { needs_waf = var.environment == "production" && var.expose_public && var.enable_waf }

动态块的四个要素是 label、for_each、迭代器(iterator)、content,迭代器可改名以避免内外层变量遮蔽。

方案落在真实配置里,plan 输出会验证这些结论吗?

五、实操:复现漂移,对比两种写法

按四步复现上文那次事故,你亲手对比两种追踪方式:

  1. 你用变量与 terraform_data 写出上文的 count 配置,执行 terraform apply。
  2. 你跑 terraform state list,记录状态里的五个地址。
  3. 你从 default 列表删掉 to_delete,再跑 terraform plan,核对输出。
  4. 你把同一份数据交给 for_each,重复前三步,对比两次 plan。

for_each 版配置把身份交给键:

resource "terraform_data" "my_each" { for_each = toset(var.my_list) input = "tf_data-${each.value}" }

两次 plan 的差别一句话能概括:count 版输出 2 to change, 1 to destroy,for_each 版只输出 1 to destroy,其余四个实例 no change。同一次删除,动作数差三倍,差别不在资源,在地址。

从 count 迁移到 for_each 时地址形态整个更换,旧索引地址与新键地址对不上,Terraform 默认按销毁加创建处理,必须显式迁移状态(state migration)把已有对象挂到新地址上:

terraform statemv'terraform_data.my_count[0]''terraform_data.my_each["zero"]'

嵌套动态块用迭代器改名,规避外层变量遮蔽:内外层块同名时,内层临时变量会覆盖外层,取值错位且不报错:

dynamic "outer_block" { for_each = var.outer_items content { outer_attribute = outer_block.value.name dynamic "inner_block" { for_each = outer_block.value.inner_items iterator = "inner" content { inner_attribute = inner.value.setting } } } }

注意:地址形态更换后不迁移状态,整组资源按销毁加创建处理。跑完两组 plan,选型规则能不能压缩成一句话?

六、总结:一张表选型,三件事避坑

选型规则压缩成一句话:实例可互换用 count,身份有意义用 for_each,嵌套块重复用 dynamic。

你的重复场景选择理由
N 个完全相同、从不单独增删的临时节点count索引稳定,无需身份
按名字或环境逐个创建,集合会变for_each键追踪,增删不波及他人
一个资源内多条规则或监听器dynamic循环嵌套块,不碰生命周期

我踩过的三个问题,按教训频率排:

问题根因教训
删中间元素引发连锁变更索引漂移集合会变就用 for_each
拿 uuid 当键每次 plan 都重建键非已知值键必须稳定且已知
改写法忘迁移状态地址形态更换count 改 for_each 必跑 state mv

三个元参数覆盖了 Terraform 配置里的绝大多数重复:顶层多实例由 count 与 for_each 按身份二选一,资源内重复结构交给 dynamic,模块级重复直接复用 for_each 的键机制。

下一篇用这三个元参数重构多环境 VPC 模块时,再展开模块级 for_each 的输出引用?

七、术语速查表

术语英文说明
循环元参数meta-argument作用于资源块的 count、for_each 等参数
索引漂移index drift列表中间增删导致数字索引整体前移
资源地址resource address状态文件中定位实例的标识
for 表达式for expression从集合推导新集合,可带 if 过滤
嵌套块nested block资源块内可重复的结构块
动态块dynamic block按集合动态生成嵌套块的结构
迭代器iterator动态块内可改名的临时变量
状态迁移state migration用 state mv 改写状态地址的操作

八、参考

  • count 元参数
  • for_each 元参数
  • dynamic 动态块
  • for 表达式
  • terraform state mv 命令
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进