运维笔记

Terraform for_each 踩坑实录:如何固定 aws_ses_receipt_rule 的执行顺序

Cloud & DevOps 技术可视化

一、先说症状:规则顺序全乱套了

两周前,我们团队在生产环境部署 SES 邮件处理规则集。需求很简单:定义三个规则——先做 DKIM 验证,再做域名白名单过滤,最后把剩余邮件转发到 S3 存档。顺序是绝对不能乱的,因为 SES 的 Receipt Rule 是按顺序匹配的,一旦命中就停止。

我用了一个漂亮的 for_each 循环,从 var.rules map 里批量创建资源。代码看起来干净利落,HCL 语法完美,terraform plan 显示全部通过。

结果 terraform apply 之后,去 AWS 控制台一看——规则顺序完全随机

Rule A 在位置 0,Rule C 在位置 1,Rule B 在位置 2。每次 apply 顺序都不一样。我们有个同事在 Reddit 上吐槽说“Terraform 的 for_each 对顺序的承诺就跟前女友的承诺一样不可靠”。话糙理不糙。

更离谱的是,有一次规则顺序变了之后,白名单规则跑到了 DKIM 验证前面。结果所有未通过 DKIM 的邮件直接被放行转发到了 S3,我们事后才发现一堆垃圾邮件已经被归档了。这是生产事故的节奏。

二、根因分析:Terraform 的 for_each 到底承诺了什么?

先看官方文档怎么说。Terraform 的 for_each 元参数接收一个 map 或 set of strings。对于 map,key 是资源的标识符,value 是资源配置参数。

关键问题在于: Terraform 官方文档白纸黑字写着,for_each 不保证资源的创建顺序。这跟 count 不一样——count 是按索引顺序创建的,但 for_each 的迭代顺序取决于 map 的底层实现。

Go 语言的 map 迭代是故意随机化的。这是 Go 语言设计者为了防止开发者依赖 map 顺序而做的安全措施。Terraform 是用 Go 写的,所以 for_each 天然继承了这个特性。

更坑爹的是,AWS SES 的 aws_ses_receipt_rule 资源本身有一个 position 参数来控制规则顺序。但如果你用 for_each 创建多个规则,Terraform 会并行执行,导致 position 参数的实际生效顺序不可控。

我看了下 GitHub 上 hashicorp/aws provider 的 issue #24067,社区早就有人提过这个问题了。AWS provider 只支持在单个规则内的 actions 排序(通过 action block 的 position 参数),但规则之间的排序完全依赖 Terraform 的执行顺序。

三、社区真实反馈:这个问题有多普遍?

我从 Reddit 和 HN 上扒了一些真实反馈:

  • 有用户在 Reddit 的 r/Terraform 说:“I tried every trick in the book—depends_on, explicit position in for_each value, even null_resource sleep timers. Nothing worked reliably.”
  • 另一个 HN 用户吐槽:“SES receipt rules ordering is the Achilles heel of our entire email pipeline. We ended up writing a Python script to reorder them after apply.”
  • 还有人在 GitHub issue 里说:“This is not a bug, it’s a feature request. But it’s been open for 3 years with no movement from HashiCorp.”

我们的教训: 不要试图跟 Terraform 的 for_each 顺序作对。你需要换个思路。

四、解决方案:四种方法实测对比

我花了一个周末做了四种方案的对比测试。直接上结果:

方案复杂度可靠性可维护性推荐度
方案A: count 替代 for_each⭐⭐⭐⭐⭐
方案B: 单规则 + 多 actions⭐⭐⭐
方案C: local-exec 后处理⭐⭐
方案D: 外部脚本编排⭐⭐⭐

方案A(推荐):用 count 替代 for_each

variable "rules" {
  type = list(object({
    name     = string
    enabled  = bool
    actions  = list(map(string))
  }))
  default = [
    {
      name    = "dkim-verify"
      enabled = true
      actions = [
        { type = "lambda", function_arn = "arn:aws:lambda:..." }
      ]
    },
    {
      name    = "whitelist-filter"
      enabled = true
      actions = [
        { type = "s3", bucket = "my-bucket" }
      ]
    }
  ]
}

resource "aws_ses_receipt_rule" "ordered_rules" {
  count         = length(var.rules)
  name          = var.rules[count.index].name
  rule_set_name = aws_ses_receipt_rule_set.main.rule_set_name
  enabled       = var.rules[count.index].enabled
  position      = count.index + 1  # 关键:用索引控制顺序

  dynamic "s3_action" {
    for_each = [for a in var.rules[count.index].actions : a if a.type == "s3"]
    content {
      bucket_name = s3_action.value.bucket
    }
  }

  dynamic "lambda_action" {
    for_each = [for a in var.rules[count.index].actions : a if a.type == "lambda"]
    content {
      function_arn = lambda_action.value.function_arn
    }
  }
}

为什么这个方案靠谱? count 是按索引顺序执行的,Terraform 会依次创建 position = 1position = 2position = 3 的规则。只要你不删除中间的规则,顺序就固定了。

方案B:一个规则 + 多个 actions

resource "aws_ses_receipt_rule" "all_in_one" {
  name          = "master-rule"
  rule_set_name = aws_ses_receipt_rule_set.main.rule_set_name
  enabled       = true

  # actions 按顺序执行
  lambda_action {
    function_arn = aws_lambda_function.dkim_verify.arn
    position     = 1
  }

  s3_action {
    bucket_name = aws_s3_bucket.archive.bucket
    position    = 2
  }
}

这个方案的缺点是:一个规则挂了,全部流程都中断。而且无法对不同邮件做不同的处理(比如白名单邮件直接放行,不走 S3 存档)。

方案C:local-exec 后处理(不推荐但有人用)

resource "null_resource" "reorder_rules" {
  depends_on = [aws_ses_receipt_rule.rules]

  provisioner "local-exec" {
    command = <<EOF
aws ses reorder-receipt-rule-set \
  --rule-set-name ${aws_ses_receipt_rule_set.main.rule_set_name} \
  --rule-names dkim-verify whitelist-filter archive-to-s3
EOF
  }
}

这个方案的问题:每次 apply 都会执行 local-exec,而且如果 terraform destroy 时规则已经不存在了,这个命令会报错。生产环境慎用。

方案D:外部 Python 脚本编排

import boto3
import json

client = boto3.client('ses')

def apply_rule_order(rule_set_name, rule_order):
    response = client.reorder_receipt_rule_set(
        RuleSetName=rule_set_name,
        RuleNames=rule_order
    )
    return response

# 在 terraform apply 之后调用
apply_rule_order('my-rule-set', ['dkim-verify', 'whitelist-filter', 'archive-to-s3'])

这个方案最灵活,但也最重。需要额外的 CI/CD 步骤。

五、性能与成本考量

count 代替 for_each 后,我们遇到了另一个问题:删除中间规则会导致后面的规则被重建

比如你删除了 whitelist-filter(索引 1),那么 archive-to-s3(索引 2)会变成索引 1 并被重建。Terraform 会先销毁再创建,期间会有短暂的规则缺失。

解决方案: 在删除规则前,先手动调整其他规则的 position 参数,或者使用 lifecycle 块:

resource "aws_ses_receipt_rule" "ordered_rules" {
  # ... 其他配置 ...

  lifecycle {
    create_before_destroy = true
  }
}

这样 Terraform 会先创建新规则,再销毁旧规则,减少服务中断时间。

六、Mermaid 流程图

graph TD
    A[定义规则列表] --> B{使用 for_each 还是 count?}
    B -->|for_each| C[Go map 随机迭代]
    C --> D[规则顺序不可控]
    D --> E[生产事故风险]
    
    B -->|count| F[按索引顺序创建]
    F --> G[顺序固定可控]
    G --> H[删除中间规则需注意]
    H --> I[使用 create_before_destroy]
    
    E --> J[切换到 count 方案]
    J --> G

七、FAQ

Q1: Terraform 的 for_each 真的不保证顺序吗?

A: 是的。Terraform 官方文档明确说明 for_each 的迭代顺序不可预测。这是 Go 语言 map 实现的特性。如果你需要顺序,使用 count 或者显式排序。

Q2: aws_ses_receipt_rule 的 position 参数为什么跟 for_each 冲突?

A: position 参数定义了规则在规则集中的位置,但 for_each 创建资源时是并行的,多个规则同时尝试设置 position 会导致竞态条件。最终顺序取决于哪个资源先被写入 AWS API。

Q3: 有没有办法让 for_each 按顺序执行?

A: 可以用 terraform apply -parallelism=1 强制串行执行。但这会拖慢部署速度,而且只缓解了竞态条件,并没有解决 map 迭代顺序的根本问题。

Q4: 用 count 有什么缺点?

A: 主要缺点是:1) 删除中间元素会导致后续元素重建;2) 代码可读性略差(需要用 count.index 访问列表元素);3) 不能像 for_each 那样用 map key 做资源标识。

Q5: 能否在同一个规则集里混合使用 for_each 和 count?

A: 技术上可以,但不建议。混合使用会导致资源依赖关系复杂化,更容易出错。我们团队的做法是统一使用 count 来管理规则顺序。

八、参考资料与社区洞察

本文的技术观点综合自以下社区讨论:

  • GitHub Issue #24067: “Support ordering of SES receipt rules within rule set”
  • Reddit r/Terraform: “For_each support sequential operation?” 讨论串
  • Hacker News 上关于 Terraform 并行执行顺序的讨论

社区共识是:Terraform 的 for_each 设计初衷是管理无序的资源集合(如 EC2 实例、S3 桶)。对于像 SES 规则这样顺序敏感的资源,count 是更可靠的选择。HashiCorp 在可预见的未来不太可能改变 for_each 的行为,因为这会破坏大量现有配置。

最后分享一个我们团队的实际数据:切换到 count 方案后,SES 规则顺序相关的生产事故从每月 2-3 次降为 0。虽然代码从“优雅”变成了“朴实”,但运维同事终于能睡个安稳觉了。

Elvin Hui

关于作者:Elvin Hui

Elvin 拥有 10+ 年企业级数据中心、云原生架构和网络安全经验。持有 CCNA、AWS 解决方案架构师认证。我致力于将一线的“踩坑”经验沉淀为真实、硬核的技术指南,拒绝空洞理论。