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 实例:

plain 复制代码
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 的输出却是:

latex 复制代码
# 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.server0 aws_instance.server0.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 适合变动集的根因。反过来说,列表只追加不改写的场景里,索引与键的差别会被掩盖,直到第一次中间删除才暴露。

latex 复制代码
删除列表中间元素前后的地址集合

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] : [] 跳过
典型场景 固定数量同质节点 按身份逐个创建 安全组规则、监听器

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

plain 复制代码
# 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 里堆叠长表达式:

plain 复制代码
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 版配置把身份交给键:

plain 复制代码
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)把已有对象挂到新地址上:

bash 复制代码
terraform state mv 'terraform_data.my_count[0]' 'terraform_data.my_each["zero"]'

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

plain 复制代码
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 改写状态地址的操作

八、参考

相关推荐
螺蛳粉 螺蛳粉16 小时前
第 05 篇:etcd 深入:Raft 共识、watch 机制与快照备份恢复实战(底座认知 05)
docker·云原生·容器·kubernetes·etcd·分布式存储·kube-apiserver
知已难寻17 小时前
03 · OpenStack的计算管理 Nova
运维·服务器·开发语言·云原生·云计算·openstack·开源软件
n1121219 小时前
Ollama 接 Open WebUI 中文界面:我踩的 4 个坑
云原生·eureka·ollama·本地大模型·open webui·私有部署·中文界面
阿里云云原生20 小时前
用户说 App“下不了单”:用 Session Replay 看清流程卡在哪一步
云原生
阿里云云原生20 小时前
运维走出控制台:阿里云 AgentCore AI Native 运维实践
云原生
SelectDB技术团队1 天前
Apache Doris 异步物化视图与 Snowflake MV / Dynamic Tables 工程化对照
云原生·私有化部署·olap·实时数仓·apache doris·国产化替代·异步物化视图
分布式存储与RustFS1 天前
可观测性数据落进对象存储:Proscia 案例拆解与 ClickHouse 自托管配置
运维·云原生·开源·对象存储·分布式存储·s3·性能基准
fb_123451 天前
K8s动态卷供应实战_StorageClass与LocalPath_NFS双Provisioner
云原生·容器·kubernetes
会议咨询1 天前
2027年云计算、机电一体化与电子通信国际会议(CMEC 2027)
云计算·机电一体化·电子通信
时间售卖铺1 天前
Docker私有仓库搭建与实战指南
运维·docker·云原生·容器