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 改写状态地址的操作

八、参考

相关推荐
tg_xianheyun16 小时前
亚马逊云服务器部署网站的步骤与上线检查清单
服务器·阿里云·云计算
张洛闻Eren16 小时前
Ansible【第一课】:Ansible介绍
运维·数据库·云原生·kubernetes·github·ansible
杰克尼17 小时前
Kubernetes
云原生·容器·kubernetes
码上上班17 小时前
基于 K3s 的微服务容器化与 GitOps 持续交付平台
微服务·云原生·架构
张洛闻Eren18 小时前
Ansible【第二课】:实施 Ansible Playbook
linux·运维·服务器·云原生·kubernetes·k8s·ansible
做运维的阿瑞18 小时前
Terraform 入门实战:安装与实施
云原生·云计算·terraform
空空dream20 小时前
06 | Prometheus打通FastAPI云原生数据采集
云原生·prometheus·fastapi
池佳齐20 小时前
【软考高级架构师案例】容器化改造:Docker + K8s,如何用云原生视角重构传统多层架构?
docker·云原生·kubernetes
可达智灵21 小时前
堵了门,钥匙还在:一次持续九个月的 AWS 接管修复
云计算·aws·可达智灵