一、先说症状:规则顺序全乱套了
两周前,我们团队在生产环境部署 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 = 1、position = 2、position = 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。虽然代码从“优雅”变成了“朴实”,但运维同事终于能睡个安稳觉了。