只从列表里删掉一个元素,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 生成键标识。
| 元参数 | 地址形态 | 单实例引用 | 整组引用 |
|---|---|---|---|
| count | aws_instance.server[0] | aws_instance.server[0].id | aws_instance.server |
| for_each | aws_iam_user.the-accounts[“Todd”] | aws_iam_user.the-accounts[“Todd”].id | aws_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)。同一层内再看身份:实例可互换、从不单独增删,索引足够;实例有名字、集合会变,键才安全。
| 维度 | count | for_each | dynamic |
|---|---|---|---|
| 适用对象 | 资源 / 模块多实例 | 资源 / 模块多实例 | 资源内的嵌套块 |
| 标识方式 | 数字索引 | 键(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 输出会验证这些结论吗?
五、实操:复现漂移,对比两种写法
按四步复现上文那次事故,你亲手对比两种追踪方式:
- 你用变量与 terraform_data 写出上文的 count 配置,执行 terraform apply。
- 你跑 terraform state list,记录状态里的五个地址。
- 你从 default 列表删掉 to_delete,再跑 terraform plan,核对输出。
- 你把同一份数据交给 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 命令