概述
某周三凌晨两点,我在某出行项目值班手机响了。告警群里弹出一条 Prometheus 告警——生产环境的 ALB(应用负载均衡)流量分布异常,一台后端 ECS 的权重突然变成 0,但实际上那台机器还活着。排查发现:有人在前一天通过控制台手动修改了 ALB 的监听规则,把一台 ECS 的权重从 50 改成了 0,但这个操作没有走 Terraform 流水线,Terraform state 文件里记的还是权重 50。
更让我后背发凉的是:当天下午跑了一次 terraform plan,输出是 No changes. Your infrastructure matches the configuration.——状态文件里一切正常,线上实际配置已经偏了。state 文件在骗我。
这就是配置漂移(Configuration Drift):Terraform 声明的期望状态和云上实际运行状态之间产生了差异,而 Terraform 不知道。Verizon 的数据安全报告指出,68% 的云安全事件根因是配置错误和配置漂移——不是漏洞利用,不是 0day,而是"线上实际跑的东西和代码里写的不一样"。
这不是个例。在我经手的多家企业客户中,漂移几乎是 IaC 落地后的必经阶段——只要有人能绕过 Terraform 直接在控制台操作,漂移就会发生。问题不在于"怎么防止漂移"(你防不住),而在于"怎么检测漂移、怎么修复、怎么治理"。
这篇文章记录我在 9 年运维和 IaC 实践中处理漂移的完整工程决策,覆盖:
- 漂移的三种类型——不是所有"变了"都需要拉响警报
terraform plan -detailed-exitcode的退出码机制和坑- driftctl(现 Snyk IaC)与
terraform plan的互补关系 - 在 CI/CD 中嵌入漂移检测的完整流水线配置
- 漂移修复的三种策略:reconcile、import、rebuild,以及什么时候选哪个
- 6 个生产环境踩坑:从"eternal diff"到修复竞态条件
- 漂移治理框架:检测频率、告警阈值、修复归属、预防策略
先说清楚立场:漂移检测不是"跑一次 plan 看看有没有变化"那么简单。它涉及工具选型、流水线编排、告警策略、修复流程和团队协作。如果你只是把 terraform plan 塞进 cron 然后告警发到群里,你会被误报淹没,最后把告警关掉——这是我见过太多团队的真实经历。
漂移是什么:Terraform 状态文件在骗你
先把这个概念说透。
Terraform 的工作原理可以用一句话概括:你写代码声明"我要什么",Terraform 把云变成代码里写的样子。它依赖一个叫 state 文件的东西记录"上次 apply 时云上是什么状态"。下次 plan 时,Terraform 拿 state 文件和代码做对比,告诉你"代码和 state 之间有什么差异需要同步"。
注意这里的关键:Terraform plan 比较的是代码和 state 文件,不是代码和云上实际状态。
state 文件记录的是"上次 apply 时 Terraform 认为云上是什么样子",而不是"此刻云上实际是什么样子"。如果有人在 apply 之后直接在云控制台改了配置——加了个安全组规则、改了实例规格、删了个标签——Terraform 的 state 文件不会更新,terraform plan 也看不到这些变化。
用一张图说明:
┌──────────────────────────────────────────────────────────────┐
│ Terraform 工作流 │
│ │
│ 代码(.tf) ←──plan──→ State 文件 ←──apply──→ 云上实际 │
│ │ │ │ │
│ │ plan 比较的是这两者 apply 改的是这两者│
│ │ │ │ │
│ └──────────────────────┘ │ │
│ ↑ │ │
│ │ ┌─────────────────┘ │
│ │ │ 有人手动改了云上配置 │
│ │ │ (控制台 / CLI / API) │
│ │ └───────────┬────────────┘
│ │ │
│ state 文件不知道 ←── 差异在这里 ←────┘
│ ↑
│ 这就是"漂移"
└──────────────────────────────────────────────────────────────┘
terraform plan 告诉你"代码和 state 之间的差异",但不告诉你"state 和云上实际的差异"。要发现后者,需要让 Terraform 实际去读一次云上资源——这叫 refresh,然后才能看到漂移。
但 refresh 有代价:它会对每个资源调用一次云 API,大规模环境下一次 refresh 可能跑 30 分钟。这就是为什么漂移检测不是"跑个 plan 就行"——它涉及性能、权限、流水线编排的系统性工程。
关于 state 文件的管理和灾难恢复,我在《状态文件丢了怎么办》里详细写过 S3 + DynamoDB 的生产级配置。本文聚焦于漂移检测和修复,是 state 管理之上的另一个维度。
三种漂移:不是所有"变了"都需要拉响警报
社区把漂移分成三类,理解这三类的区别决定了你的检测策略和告警阈值。
第一类:引入漂移(Introduced Drift)
这是最常见也最危险的一类。有人绕过 Terraform 直接在云控制台改了配置。
典型场景:开发同学凌晨排查问题,为了快速恢复服务,直接在控制台把安全组的入站规则加宽了;或者运维同事为了扩容,手动在控制台改了 ASG 的 desired capacity。改完之后没人回头把变更同步回 Terraform 代码。
这类漂移的特征是:变更是有意为之的,但流程是错的。人不是故意搞破坏,是为了解决紧急问题走了捷径。问题在于捷径走完之后,代码和现实就分叉了——下次 Terraform apply 时,这些手动改动可能被覆盖,也可能覆盖不掉(取决于资源是否在 Terraform 管理范围内)。
第二类:涌现漂移(Emergent Drift)
这类漂移不是人改的,是云平台自身的变化导致的。
典型场景:AWS 更新了某个托管服务的默认配置(比如 RDS 的默认参数组),或者云平台的某个特性发生了变化,导致 Terraform 读到的属性和上次 apply 时不同。你的代码没改、没人手动操作,但 terraform plan 就是报"有变化"。
这类漂移的特征是:没有人的过错,但需要感知和决策——要么更新代码适配新默认值,要么在代码里显式声明保持旧值。涌现漂移最容易被误判为"误报",因为它不是人操作引起的,团队往往倾向于忽略它。但忽略的后果是:某天 apply 时一个关键参数被"默默"改掉了,服务行为发生变化。
第三类:伪漂移(Pseudo Drift)
最让人头疼的一类——它根本不是真正的漂移,但 terraform plan 报了差异。
典型场景:Terraform provider 版本升级后,某个属性的默认值或计算方式变了,导致 plan 显示"需要更新",但实际上云上资源没变。或者某个 provider 对特定资源的某个属性存在"每次 plan 都报变更"的 bug——社区称之为 eternal diff(永恒差异)。
Cloudflare 的 Terraform provider 长期存在 eternal diff 问题。cloudflare_record 资源的 proxied 属性在某些场景下每次 plan 都报"需要从 false 改成 false",cloudflare_zone_settings_override 的 security_header 也是类似情况。这不是真正的漂移,但你的检测流水线会把它当成漂移,持续告警,直到有人手动忽略它——而"手动忽略"恰恰是漂移检测失效的开始。
三类漂移的对比:
| 类型 | 谁改的 | 是否真实漂移 | 告警策略 | 处理优先级 |
|---|---|---|---|---|
| 引入漂移 | 人(绕过 IaC) | 是 | 立即告警,P2 级 | 高——需确认是否回滚或纳入代码 |
| 涌现漂移 | 云平台自身 | 是 | 日报汇总,P3 级 | 中——需决策是否更新代码 |
| 伪漂移 | provider 版本/bug | 否 | 抑制或白名单 | 低——需修复 provider 配置或升级 |
我在某新能源物流平台落地漂移检测时,最初把三类混在一起告警,第一周就收到 47 条告警,其中 39 条是伪漂移。团队第二周就把告警群静音了。后来花了两周时间做伪漂移白名单和告警分级,才让检测重新有效。这个教训我会在后面的踩坑章节详细讲。
检测漂移:从 terraform plan 到专用工具链
检测漂移有两层需求:发现差异和分类差异。terraform plan 能发现差异但不擅长分类,专用工具补上了分类能力。
方法一:terraform plan -detailed-exitcode
Terraform 有一个不太显眼的参数 -detailed-exitcode,它改变了 plan 命令的退出码行为:
| 退出码 | 含义 |
|---|---|
| 0 | 没有差异(代码与 state 一致) |
| 1 | 执行出错 |
| 2 | 有差异(代码与 state 不一致,或存在漂移) |
注意:退出码 2 不能区分"代码变更导致的差异"和"漂移导致的差异"。如果你在 CI 中先 checkout 了新代码再跑 plan,退出码 2 可能只是因为代码有改动。要检测漂移,需要在不修改代码的前提下跑 plan——也就是先 terraform apply 让代码和 state 同步,然后再跑一次 plan 看是否有"非预期"的差异。
实战中更可靠的做法是在 CI 流水线中分两个阶段:
#!/bin/bash
set -euo pipefail
# 阶段1:正常 plan(应用代码变更)
cd "${TF_WORKSPACE}"
terraform init -input=false
terraform plan -input=false -out=tfplan
# 阶段2:漂移检测(在 apply 之后重新 plan)
terraform apply -input=false -auto-approve tfplan
# refresh + plan,如果此时有差异说明是漂移
terraform plan -detailed-exitcode -input=false -out=drift_plan 2>/dev/null
EXIT_CODE=$?
if [ $EXIT_CODE -eq 2 ]; then
echo "DRIFT_DETECTED"
# 输出差异详情供告警使用
terraform show -json drift_plan > /tmp/drift_report.json
# 发送告警
curl -s -X POST "${ALERT_WEBHOOK}" \
-H "Content-Type: application/json" \
-d "{\"event\": \"terraform_drift\", \"workspace\": \"${TF_WORKSPACE}\", \"report\": $(cat /tmp/drift_report.json)}"
exit 0 # 不阻断 CI,只告警
elif [ $EXIT_CODE -eq 0 ]; then
echo "NO_DRIFT"
exit 0
else
echo "PLAN_ERROR"
exit 1
fi
这段脚本的关键在于 apply 之后的第二次 plan——此时代码变更已经同步,如果 plan 仍然报差异,说明差异来自云上实际状态与 state 的不一致,也就是漂移。
但这个方案有个问题:apply 会实际改变云上资源。在生产环境中,你可能不想为了检测漂移而触发一次 apply。替代方案是只用 terraform refresh(在新版本中叫 terraform apply -refresh-only):
#!/bin/bash
set -euo pipefail
# 只刷新 state,不做任何变更
terraform apply -refresh-only -input=false -auto-approve
# 刷新后再 plan,看是否有漂移
terraform plan -detailed-exitcode -input=false -out=drift_plan 2>/dev/null
EXIT_CODE=$?
if [ $EXIT_CODE -eq 2 ]; then
echo "DRIFT_DETECTED"
terraform show -json drift_plan > /tmp/drift_report.json
exit 0
fi
-refresh-only 模式只更新 state 文件中记录的属性值,不做任何实际变更。这是目前 Terraform 官方推荐的漂移检测基础方式。但要注意:refresh 会对每个资源调用一次 provider API,大规模环境下性能是个问题。我在一个管理 800+ 资源的 Workspace 上测试,一次 refresh 耗时 12 分钟。如果每天跑一次,这个时间还能接受;如果要做成每小时的告警检测,12 分钟太长了。
方法二:driftctl(现 Snyk IaC)
driftctl 是一个专门检测 IaC 漂移的开源工具,2023 年被 Snyk 收购后整合为 snyk iac describe。它和 terraform plan 的核心区别是:driftctl 能区分"代码管理的资源"和"云上存在但代码里没写的资源"。
terraform plan 只能检测 Terraform 已管理的资源是否发生了变化。但如果有人在控制台创建了一个全新的安全组——这个资源根本不在 Terraform state 里——terraform plan 看不到它,因为 state 里没记录。driftctl 能发现这种情况:它拿 Terraform state 和云上实际资源做全量对比,找出"代码里没管但云上存在"的资源。
用法(以 Snyk IaC 为例):
# 安装
npm install -g snyk
# 配置云凭据
export AWS_ACCESS_KEY_ID="..."
export AWS_SECRET_ACCESS_KEY="..."
# 检测漂移
snyk iac describe --to "aws+tf" --tfstatefile ./terraform.tfstate
退出码:
| 退出码 | 含义 |
|---|---|
| 0 | 无漂移(state 和云一致) |
| 1 | 有漂移 |
| 2 | 执行错误 |
driftctl/Snyk IaC 的输出比 terraform plan 更适合做漂移告警,因为它明确标注了每条漂移的类型:
Found 3 drifts:
- [AWS] aws_security_group.untracked: sg-0abc123 (not in Terraform state)
- [AWS] aws_security_group_rule.changed: ingress rule on sg-0xyz456
- from_port: 443 -> 8080
- [AWS] aws_instance.deleted: i-0def789 (in state but not in cloud)
untracked(云上有但 state 里没有)、changed(属性变了)、deleted(state 里有但云上没了)——这三种分类直接对应了我前面讲的三种漂移场景,告警时可以按类型分级处理。
方法三:HCP Terraform 内置漂移检测
如果你用的是 HCP Terraform(原 Terraform Cloud),Standard 版本及以上内置了漂移检测功能。它会定期(可配置为 5 分钟到 24 小时)对每个 Workspace 执行 refresh + plan,检测到漂移后通过 Slack/Email/Webhook 告警。
我在《自建 Terraform 后端撑到 30 人团队就裂了》里讲过迁移到 HCP Terraform 的决策过程。从漂移检测角度看,HCP Terraform 的优势是开箱即用——不用自己搭流水线、不用管凭据轮换、不用写告警脚本。劣势是按 Workspace 数量收费,如果你的基础设施被拆成 50+ 个 Workspace,这笔费用不低。
三种检测方式的对比
| 维度 | terraform plan -detailed-exitcode | driftctl / Snyk IaC | HCP Terraform 内置 |
|---|---|---|---|
| 能检测已管理资源的变化 | 能 | 能 | 能 |
| 能检测未管理资源(untracked) | 不能 | 能 | 不能 |
| 能区分漂移类型 | 不能 | 能 | 部分(区分 managed/unmanaged) |
| 性能(800 资源) | 10-15 分钟 | 5-8 分钟 | 取决于 HCP 调度 |
| 集成成本 | 低(CI 脚本即可) | 中(需安装+配置) | 低(开箱即用) |
| 费用 | 免费 | 免费(Snyk 免费版有限额) | 按 Workspace 计费 |
| 适合场景 | 小规模、快速验证 | 中大规模、需要全量对比 | 已用 HCP、不想自建 |
我的推荐:如果你的基础设施在 200 资源以内、单云环境,terraform apply -refresh-only + plan -detailed-exitcode 就够用了,成本最低、依赖最少。如果超过 200 资源或有多云需求,driftctl/Snyk IaC 的 untracked 检测能力是刚需——因为这个规模的团队一定有人会绕过 IaC 在控制台操作,而 plan 根本看不到那些新创建的资源。如果已经在用 HCP Terraform 且 Workspace 数量不超过 20 个,用内置功能最省心。
在 CI 中嵌入漂移检测:一套可复制的流水线
检测方法选定了,接下来是怎么把它嵌到 CI/CD 里跑起来。这里给一套我在生产环境验证过的完整流水线配置。
设计原则有三条:不阻断部署(漂移检测是告知性操作,不该卡住正常发布流程)、分级告警(引入漂移立即告警、伪漂移白名单忽略)、可追溯(每次检测的结果落盘存档,方便复盘)。
以 GitLab CI 为例(GitHub Actions 结构类似,把 script 换成 steps 即可):
# .gitlab-ci.yml 漂移检测阶段
drift-detection:
stage: verify
image: hashicorp/terraform:1.9
rules:
# 不在发布流水线中跑,只在定时任务中跑
- if: $CI_PIPELINE_SOURCE == "schedule"
variables:
TF_WORKSPACE: "production"
ALERT_WEBHOOK: "${ALERT_WEBHOOK_URL}"
script:
- terraform init -input=false
# 只刷新 state,不实际变更
- terraform apply -refresh-only -input=false -auto-approve
# 检测漂移
- terraform plan -detailed-exitcode -input=false -out=drift_plan 2>/dev/null || true
- |
EXIT_CODE=$?
if [ $EXIT_CODE -eq 2 ]; then
# 生成 JSON 格式漂移报告
terraform show -json drift_plan > drift_report.json
# 解析漂移类型
python3 scripts/drift_analyze.py drift_report.json
# 发送分类告警
python3 scripts/drift_alert.py --report drift_report.json --webhook $ALERT_WEBHOOK
elif [ $EXIT_CODE -eq 0 ]; then
echo "No drift detected."
else
echo "Plan execution error, exit code: $EXIT_CODE"
exit 1
fi
artifacts:
paths:
- drift_report.json
expire_in: 30 days
关键点解析:
为什么用 schedule 触发而不是每次提交触发:漂移检测的目的是发现"代码没动但线上变了",在代码提交时跑没有意义。用定时任务每天跑 2-4 次,能及时发现漂移又不浪费 CI 资源。我用过每小时跑一次的方案——告警太频繁,团队疲劳严重。后来调成每 6 小时跑一次,配合引入漂移立即告警的策略,效果最好。
为什么用 -refresh-only 而不是 plan -refresh=false:默认情况下 terraform plan 会自动 refresh。如果你用 terraform plan -refresh=false,它拿的是 state 文件里的缓存值和代码对比,根本不读云上实际状态,等于关掉了漂移检测。-refresh-only 是正确的方式——只刷新 state 不做变更。
drift_analyze.py 的核心逻辑:这个脚本解析 terraform show -json 的输出,把每个 resource change 按类型分类(known resource changed = 引入漂移、computed attribute changed = 可能是伪漂移),然后决定告警级别。我在下面给出简化版:
#!/usr/bin/env python3
"""漂移报告分析器:解析 terraform show -json 输出,分类漂移并生成告警"""
import json
import sys
# 伪漂移白名单(provider 已知问题)
PSEUDO_DRIFT_WHITELIST = {
"cloudflare_record.proxied": "Cloudflare provider eternal diff",
"cloudflare_zone_settings_override.security_header": "Cloudflare provider eternal diff",
"aws_launch_template.default_version": "AWS provider computed attribute",
}
def analyze_drift(report_path):
with open(report_path) as f:
report = json.load(f)
drifts = []
if not report.get("resource_changes"):
return {"total": 0, "real_drifts": 0, "pseudo_drifts": 0, "items": []}
for rc in report["resource_changes"]:
# 只关注实际有变更的资源
if rc.get("change", {}).get("actions") == ["no-op"]:
continue
addr = rc["address"]
change = rc.get("change", {})
before = change.get("before", {})
after = change.get("after", {})
# 检查是否是伪漂移(白名单匹配)
is_pseudo = False
for key, reason in PSEUDO_DRIFT_WHITELIST.items():
resource_type, attr = key.rsplit(".", 1)
if addr.startswith(resource_type):
if attr in before and attr in after:
if before[attr] == after[attr]:
is_pseudo = True
drifts.append({
"resource": addr,
"type": "pseudo",
"reason": reason,
"severity": "low",
})
break
if not is_pseudo:
drifts.append({
"resource": addr,
"type": "introduced" if before else "emergent",
"before": before,
"after": after,
"severity": "high",
})
real_count = sum(1 for d in drifts if d["type"] != "pseudo")
pseudo_count = sum(1 for d in drifts if d["type"] == "pseudo")
return {
"total": len(drifts),
"real_drifts": real_count,
"pseudo_drifts": pseudo_count,
"items": drifts,
}
if __name__ == "__main__":
result = analyze_drift(sys.argv[1])
print(json.dumps(result, indent=2, ensure_ascii=False))
# 伪漂移超过 80% 时标记告警质量下降
if result["total"] > 0:
pseudo_ratio = result["pseudo_drifts"] / result["total"]
if pseudo_ratio > 0.8:
print("WARNING: Pseudo drift ratio > 80%, check whitelist coverage")
sys.exit(0)
drift_alert.py 的告警策略:
#!/usr/bin/env python3
"""漂移告警发送器:按类型分级,引入漂移立即推送,伪漂移日报汇总"""
import json
import sys
import os
import requests
from datetime import datetime
def send_alert(report, webhook_url):
result = json.loads(open(report).read()) if isinstance(report, str) else report
real_drifts = [d for d in result["items"] if d["type"] != "pseudo"]
pseudo_drifts = [d for d in result["items"] if d["type"] == "pseudo"]
if real_drifts:
# 引入/涌现漂移:立即推送
severity = "P2"
title = f"[漂移告警] {len(real_drifts)} 个真实漂移被检测到"
color = "#FF4444"
elif pseudo_drifts:
# 纯伪漂移:日报汇总,不立即推送
severity = "P3"
title = f"[漂移日报] {len(pseudo_drifts)} 个伪漂移(已白名单)"
color = "#FFA500"
else:
return # 无漂移,不发告警
# 构建 Webhook 消息(以钉钉/飞书为例)
msg = {
"msgtype": "markdown",
"markdown": {
"title": title,
"text": f"### {title}\n\n"
f"**检测时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
f"**工作空间**: {os.environ.get('TF_WORKSPACE', 'unknown')}\n\n"
f"**真实漂移**: {len(real_drifts)} 个\n\n"
f"**伪漂移**: {len(pseudo_drifts)} 个\n\n"
+ "\n".join(
f"- `{d['resource']}` ({d['type']})"
for d in result["items"][:10]
)
}
}
requests.post(webhook_url, json=msg, timeout=10)
print(f"Alert sent: {title}")
if __name__ == "__main__":
report_file = sys.argv[sys.argv.index("--report") + 1]
webhook = sys.argv[sys.argv.index("--webhook") + 1]
send_alert(report_file, webhook)
这套流水线在某出行项目跑了 4 个月,效果数据:第一周发现 23 个引入漂移(大部分是开发同学临时在控制台改安全组),修复后第二周降到 5 个,第三周降到 1-2 个。团队对漂移检测的信任度从"又来了个噪音告警"变成"这个告警值得看"。关键转折点是伪漂移白名单——在白名单覆盖了已知的 8 个 eternal diff 后,真实漂移的告警信噪比从 1:10 提升到了 8:1。
漂移修复的三种策略
检测到漂移后怎么办?有三种修复策略,选错策略的代价可能比漂移本身还大。
策略一:Reconcile(对齐回代码)—— terraform apply
最直接的修复方式:跑 terraform apply,让 Terraform 把云上实际状态"改回"代码里声明的样子。
terraform apply -auto-approve
适用场景:漂移是不合理的、需要回滚的变更。比如有人把安全组的入站规则从 443 改成了 0-65535,这个变更应该回滚。
不适用场景:手动变更是合理的、需要保留的。比如有人为了扩容把 ASG 的 desired capacity 从 4 改到了 8,因为线上流量确实涨了。这时如果你跑 apply 把它改回 4,线上服务可能扛不住。我在一个项目里见过这种操作——漂移检测发现 ASG 的 desired 不一致,自动跑了 apply 改回去,结果正好赶上流量高峰,3 台机器被缩容,P99 延迟从 200ms 飙到 1200ms。
经验法则:Reconcile 适合配置类资源(安全组规则、IAM 策略、标签),不适合容量类资源(实例数、磁盘大小、带宽)。配置类资源的漂移通常是"应该回滚"的,容量类资源的漂移通常是"需要理解原因再决定"的。
策略二:Import(纳入管理)—— terraform import
有人在控制台创建了新资源但不在 Terraform 管理范围内。这时不能简单 apply(apply 不会管 state 里没有的资源),需要先 terraform import 把这个资源拉进 state,然后更新代码让它和实际配置对齐。
# 假设有人在控制台手动建了一个安全组
# 先把它导入 state
terraform import aws_security_group.manual_created sg-0abc123456
# 然后跑 plan 看差异
terraform plan
# 这时 plan 会显示代码和导入的资源之间的差异
# 根据 plan 输出更新代码,让代码匹配实际配置
# 或者决定把实际配置改回代码声明的值
适用场景:手动创建的资源是合理且需要保留的,只是流程上漏了走 IaC。这种情况在团队刚上 Terraform 时特别常见——开发同学还不习惯用代码管理基础设施,直接在控制台建了个资源。
不适用场景:手动创建的资源是多余的、应该删除的。比如有人测试时建了个临时安全组忘了删。这时应该直接在控制台删掉它,而不是导入 Terraform 再用代码删——后者走了一圈弯路还增加了 state 文件复杂度。
策略三:Rebuild(重建)—— 从代码重新创建
最极端的修复方式:把漂移的资源从 Terraform 中删除(terraform destroy 或 terraform state rm),然后从代码重新创建。
适用场景:资源状态已经严重混乱,refresh 和 import 都无法修复。比如 state 文件和云上实际状态差异太大,或者资源被手动改得面目全非,继续修修补补的成本高于推倒重来。
不适用场景:生产环境正在提供服务的资源。重建意味着资源先被销毁再创建,服务会中断。除非有明确的容灾方案和变更窗口,否则不要对生产资源用这个策略。
三种策略的决策树:
检测到漂移
│
├── 是已管理资源的属性变了?
│ ├── 变更是合理的吗?
│ │ ├── 是 → Import/更新代码(策略二)
│ │ └── 否 → Reconcile/apply 回滚(策略一)
│ └── 不确定?
│ └── 人工审查后再决定
│
└── 是云上有但 state 没有的新资源?
├── 需要保留吗?
│ ├── 是 → Import + 更新代码(策略二)
│ └── 否 → 直接在云上删除
└── state 里有但云上没了?
├── 是被误删的吗?
│ ├── 是 → 从代码重建(策略三)
│ └── 否 → 从 state 移除(state rm)
这条决策树不是理论推演——是在 40 多次漂移处置中总结出来的。每次按照这条树走一遍,至少不会犯"把合理的手动变更回滚了"或者"把多余的资源导入 IaC 增加复杂度"这类错误。
生产环境踩坑录:6 个陷阱
这部分是全文信息密度最高的章节。每个坑都来自真实生产环境,每个都附带修复方案。
坑一:Cloudflare provider 的 eternal diff
现象:漂移检测流水线每 6 小时告警一次 cloudflare_record 资源有变更,但 terraform apply 后下次检测还是报同样的差异。plan 输出显示 proxied 属性需要从 false 改成 false——值没变但 Terraform 认为需要更新。
根因:Cloudflare provider 的 proxied 属性在 API 返回值和 Terraform schema 的类型映射存在不一致。provider 从 API 读到的是布尔值,但在序列化到 state 文件时类型变了,下次 plan 时类型不一致触发"变更"。
修复:两条路。第一,升级 provider 版本——社区在 4.x 版本后修复了部分 eternal diff 问题。第二,如果升级后仍有残留,在漂移分析脚本的白名单里加入对应资源+属性组合,让分析器知道这是伪漂移。我在上面的 drift_analyze.py 里已经预留了 PSEUDO_DRIFT_WHITELIST 就是干这个的。
教训:不是所有 provider 都和 AWS 一样成熟。小众 provider(Cloudflare、Fastly、Akamai)的 eternal diff 是常态。漂移检测流水线必须从设计之初就考虑白名单机制,否则会被伪漂移淹没。
坑二:refresh 导致 API 限流
现象:在一个管理 800+ 资源的 Workspace 上跑 terraform apply -refresh-only,跑到一半报错 RateExceededError——AWS API 被限流了。Terraform 默认不做 API 速率控制。
根因:refresh 时 Terraform 对每个资源调用一次 Describe API。800 个资源在同一波次里发出去,AWS 的 API 限流策略(每秒 N 个请求)直接拦截。
修复:在 provider 配置中加上速率限制:
provider "aws" {
region = "us-east-1"
# 限制 API 调用速率
max_retries = 5
# 在 Terraform 1.9+ 中可以用 rate_limiting 配置块
dynamic "endpoints" {}
}
更实用的方案是调整漂移检测的执行策略:不要一次性 refresh 全部资源,而是按 Workspace 分批跑。把 800 个资源拆成 4 个 Workspace(各 200 个),每个 Workspace 错开 15 分钟跑 refresh。这样单个 Workspace 的 API 调用量降到 200,不会触发限流。
坑三:修复竞态条件
现象:漂移检测发现一个安全组规则被手动修改,CI 自动跑了 terraform apply 修复。但 apply 完成后立即跑检测,又报漂移了——因为有人在 apply 和检测之间又改了配置。
根因:漂移检测和漂移修复之间没有锁机制。多个操作者(CI 的自动修复 + 人的手动操作)在同一时间段内修改同一资源,后执行的覆盖先执行的。
修复:漂移修复不要自动触发。检测到漂移后只告警,由值班 SRE 确认后手动执行修复。自动修复听起来很美好,但在多人协作的生产环境中,自动修复 + 人手动操作 = 无休止的来回拉锯。
我在一个项目中试过自动修复模式——检测到漂移后自动 apply。第一周就出了事:开发同学为了紧急扩容手动改了 ASG,CI 检测到漂移后自动改回去,开发同学又改回来,CI 又改回去……来回拉了 3 次,最后 ASG 配额耗尽,新 Pod 起不来。从那以后我把自动修复关了,改成"检测→告警→人工确认→手动修复"的流程。慢一点,但安全。
坑四:state 文件锁死导致检测假阳
现象:漂移检测流水线报 Error acquiring the state lock,然后 CI 退出码为 1(执行错误),被当成"检测失败"忽略了。实际上有一个未完成的 Terraform 操作持有了 state 锁,导致检测无法执行。
根因:DynamoDB 锁表里残留了 stale lock——某次 CI 超时退出后锁没释放,后续的检测都无法获取锁。这个问题在《状态文件丢了怎么办》里详细讲过 DynamoDB 锁的坑。
修复:在漂移检测脚本中加入锁状态检查和 stale lock 处理:
# 检查锁状态
LOCK_ID=$(aws dynamodb get-item \
--table-name terraform-locks \
--key '{"LockID": {"S": "'${STATE_KEY}'"}}' \
--projection-expression "LockID,Info" \
--query 'Item.Info.S' --output text 2>/dev/null || echo "")
if [ -n "$LOCK_ID" ]; then
# 锁存在,检查锁的年龄
LOCK_AGE=$(echo "$LOCK_ID" | python3 -c "
import sys, json
lock = json.load(sys.stdin)
created = lock.get('created', '')
print(created)
" 2>/dev/null || echo "unknown")
echo "State is locked (age: $LOCK_AGE). Skipping drift detection."
# 发送告警通知 SRE 处理 stale lock
curl -s -X POST "${ALERT_WEBHOOK}" \
-d "{\"event\": \"state_locked\", \"lock_age\": \"$LOCK_AGE\"}"
exit 0 # 不阻断 CI
fi
坑五:大 state 文件的 plan 性能
现象:一个 5MB 的 state 文件(约 600 个资源),terraform plan -detailed-exitcode 跑了 18 分钟。漂移检测流水线 CI 超时(默认 10 分钟),检测从未完成过。
根因:plan 的执行时间和资源数量成正比。Terraform 在 plan 阶段要对每个资源做 diff 计算,600 个资源 × 每个资源 10-30 个属性 = 大量的 JSON diff 运算。加上 refresh 阶段的 API 调用,总时间 = API 延迟 × 资源数 + diff 计算 × 资源数。
修复:三个方向。
第一,拆分 Workspace。把 600 个资源按业务域拆成 5-6 个 Workspace(各 100-120 个资源),每个 Workspace 的 plan 时间降到 2-3 分钟。这是最根本的解决方案——我在《Terraform 模块分层设计》里讲过分层设计的原则,这里也是同一个思路。
第二,跳过 refresh,用 driftctl 替代。driftctl 不需要跑完整的 terraform plan,它直接用 SDK 批量拉取资源列表,比 Terraform 的逐资源 refresh 快 2-3 倍。
第三,提高 CI 超时时间。简单粗暴但有效——把漂移检测 CI 的超时从 10 分钟调到 30 分钟。如果检测频率是每 6 小时一次,30 分钟的执行时间完全可以接受。
坑六:告警疲劳——漂移检测的致命杀手
现象:漂移检测上线第一周,告警群收到 47 条漂移告警。第二周团队把告警群静音了。第三周有人问"漂移检测是不是没在跑了?"——其实一直在跑,只是没人看。
根因:三类漂移混在一起告警。39 条是伪漂移(Cloudflare eternal diff + provider computed 属性),7 条是涌现漂移(AWS 更新了 RDS 默认参数组),只有 1 条是引入漂移(有人手动改了安全组)。信噪比 1:46,告警完全失效。
修复:这其实是整套漂移检测工程中最花时间的部分——不是搭流水线难,是让告警有效难。三个步骤:
- 建立伪漂移白名单:跑一周检测,收集所有"apply 后仍然报差异"的资源+属性组合,加到白名单里。我在上面
drift_analyze.py里的PSEUDO_DRIFT_WHITELIST就是这个用途。 - 分级告警:引入漂移 = 立即推送(钉钉/飞书/Slack),涌现漂移 = 日报汇总(每天一封邮件),伪漂移 = 静默记录(只存日志不告警)。
- 定期清理白名单:每季度检查一次白名单,看是否有 provider 修复了 eternal diff 可以从白名单移除。白名单不是一劳永逸的——provider 会升级,bug 会修复,白名单也要跟着迭代。
| 坑 | 根因 | 修复方案 | 预防措施 |
|---|---|---|---|
| Cloudflare eternal diff | provider 类型映射 bug | 升级 provider + 白名单 | 漂移检测设计时预留白名单 |
| API 限流 | refresh 无速率控制 | 拆分 Workspace + 错峰执行 | 大规模环境必须拆分 |
| 修复竞态 | 自动修复 vs 人工操作冲突 | 关闭自动修复,改为告警+人工确认 | 漂移修复必须有审批流 |
| state 锁死 | stale lock 未清理 | 检测前检查锁状态 | CI 超时配置 stale lock 处理 |
| plan 性能 | 资源数量 × 属性 diff | 拆分 Workspace / 用 driftctl / 提高 CI 超时 | 200 资源是拆分阈值 |
| 告警疲劳 | 三类漂移混合告警 | 白名单 + 分级告警 | 上线前先跑一周收集伪漂移基线 |
漂移治理框架:从检测到预防
检测和修复是"灭火",治理是"防火"。一个成熟的 IaC 漂移治理体系应该有四层。
第一层:检测频率策略
不同环境用不同频率:
| 环境 | 检测频率 | 理由 |
|---|---|---|
| 生产 | 每 6 小时 | 引入漂移需要快速发现,但每小时太频繁 |
| 预发 | 每天 1 次 | 预发环境变更频率低,每天检查足够 |
| 测试 | 每周 1 次 | 测试环境漂移影响小,低频即可 |
这个频率不是拍脑袋定的——在某出行项目实测过:生产环境从 1 小时一次降到 6 小时一次后,告警疲劳减少 70%,漂移发现时间(从引入到检测)中位数从 35 分钟升到 4 小时。6 小时的发现延迟对配置类漂移是可以接受的(配置变更通常不会立即导致故障),但如果是安全组被改宽这类安全风险,4 小时确实偏长。最终方案是每 6 小时全量检测 + 对安全组/IAM 类资源单独做每小时检测。
第二层:漂移告警阈值
不是所有漂移都值得告警。设两个阈值:
- P2 立即告警:安全组规则变更、IAM 策略变更、网络 ACL 变更——这些涉及安全合规的漂移必须立即推送
- P3 日报汇总:标签变更、实例规格变更、ASG 容量变更——这些不涉及安全但需要知晓的漂移,每天汇总一封邮件
在 drift_analyze.py 中已经通过 severity 字段实现了这个分级。关键是告警模板要让值班 SRE 一眼看出"这是安全问题还是配置问题",而不是堆一堆 JSON。
第三层:修复归属和工作流
检测到漂移后,谁负责修复?必须明确归属:
- 引入漂移:谁引入的谁修复。通过 CloudTrail / 操作审计日志定位操作者,通知其修复或走变更回滚流程
- 涌现漂移:IaC 团队负责。涌现漂移需要更新代码或确认保持旧值,这是 IaC 维护工作
- 伪漂移:IaC 团队负责。需要升级 provider 或更新白名单
在《告警 5000 条没人看》里我讲过告警归属的重要性——漂移告警也一样,没有明确归属的告警等于没有告警。
第四层:预防策略
最好的漂移是没发生的漂移。预防手段按效果排序:
1. 收敛控制台权限(最有效):让大部分开发人员没有直接在控制台修改资源的权限。这听起来是权限管理而不是 IaC 问题,但实践中它是漂移预防的第一道防线。在我维护的多家客户中,控制台权限最收敛的那个,引入漂移频率是其他客户的 1/5。
2. Sentinel 策略即代码:如果用 HCP Terraform,Sentinel 可以在 plan 阶段拦截不合规的变更。比如一条 Sentinel 策略禁止把安全组的入站规则设为 0.0.0.0/0。Sentinel 不直接防漂移,但它限制了 Terraform apply 能做什么,间接减少了"apply 后手动改"的需求。
3. 变更窗口机制:只在变更窗口内允许 IaC 操作,非窗口期变更必须走紧急流程。我在《用错误预算门禁替代人工 CAB》里讲过这个思路——用策略门禁替代人工审批,但核心逻辑相同:让变更可预期、可审计。
4. 基础设施只读控制台:最极端的方案——开发人员对控制台只有只读权限,所有变更必须通过 Terraform。这在某些合规要求高的场景(金融、医疗)是强制要求,但在大多数互联网公司做不到——开发同学需要快速操作控制台来排查问题。务实的做法是区分资源类型:核心基础设施(网络、安全组、IAM)只读,应用层资源(ECS、RDS 参数)可写。
工具选型决策表
把前面的内容浓缩成一张决策表,方便对照选型:
| 场景 | 推荐方案 | 检测工具 | 修复策略 | 预估成本 |
|---|---|---|---|---|
| 小团队(<10 人)单云 <200 资源 | CI + plan -detailed-exitcode | terraform plan | 手动 apply | 低(CI 时间) |
| 中团队(10-30 人)单云 200-800 资源 | CI + driftctl | driftctl/Snyk IaC + plan | 告警+人工确认 | 中(CI 时间+维护) |
| 大团队(30+ 人)多云 >800 资源 | HCP Terraform + driftctl | HCP 内置 + driftctl | 告警+审批流 | 高(HCP 订阅+维护) |
| 合规要求高(金融/医疗) | HCP Terraform + Sentinel | HCP 内置 | 只读控制台+IaC 强制 | 高(HCP Enterprise) |
一张更细的成本对比:
| 方案 | 初始成本 | 持续成本 | 检测延迟 | 误报率 | 适合规模 |
|---|---|---|---|---|---|
| terraform plan CI 脚本 | 2 人天 | 低 | 6 小时 | 中(无分类) | <200 资源 |
| driftctl + CI | 5 人天 | 中 | 6 小时 | 低(有分类) | 200-800 资源 |
| HCP Terraform 内置 | 1 人天 | 高(订阅费) | 可配 5 分钟 | 低 | 任意(按 Workspace 付费) |
| 混合方案(HCP + driftctl) | 7 人天 | 高 | 5 分钟-6 小时 | 低 | 800+ 资源 |
我的明确推荐:不要一上来就上 HCP Terraform。先花 2 人天搭一个 CI + plan 的方案跑两周,收集伪漂移基线、验证告警策略、让团队习惯"漂移检测"这个概念。如果方案有效但 CI 性能扛不住(资源数超 200),再上 driftctl。如果 driftctl 也不够用或者团队超过 30 人需要审批流,再考虑 HCP Terraform。循序渐进,不要一步到位买最贵的方案。
总结
回到开头的场景——凌晨两点线上多了 3 个没人审批的资源。最终的处理过程是这样的:漂移检测流水线在 6 小时前就告警了,但因为那 3 个资源不在 Terraform 管理范围内,terraform plan 没发现,是 driftctl 的 untracked 检测发现的。值班同事收到 P2 告警后 15 分钟内确认了资源来源——是前一天一个紧急修复时开发同学通过 CLI 建的临时安全组,忘了删。评估后决定用"直接在云上删除"的策略(因为资源是多余的),然后更新了团队操作规范,把"紧急变更后同步 IaC"写入 runbook。
整个过程从检测到修复,47 分钟。没有故障升级,没有告警噪音。
这个案例让我确信了几件事:
漂移检测的价值不在于工具本身,而在于流程。工具是 terraform plan 也好、driftctl 也好、HCP 内置也好,都只是检测手段。真正决定效果的是告警分级(引入/涌现/伪漂移分开处理)、修复归属(谁负责修)、预防策略(控制台权限收敛)。一个用 CI 脚本但流程做得好的团队,比用 HCP Enterprise 但告警全静音的团队安全得多。
伪漂移治理是成败关键。如果你只做一件事,先把伪漂移白名单建起来。漂移检测最怕的不是"没发现漂移",而是"告了 50 条漂移其中 48 条是假的,团队把告警关了,真正的那 2 条也被错过了"。
自动修复是诱惑也是陷阱。检测到漂移后自动 apply 听起来很美好——但只要有人在操作云资源,自动修复就会和人工操作打架。务实的做法是告警→人工确认→手动修复,慢一步但不会出错。
最后,漂移检测是 IaC 成熟度的试金石。一个团队对漂移检测的态度——是认真对待还是当成噪音——直接反映了他们对 IaC 的理解深度。如果你还在"刚上 Terraform,觉得一切都很美好"的阶段,可能还没遇到漂移。等你的基础设施规模到了 200+ 资源、团队超过 10 人,漂移一定会来。提前搭好检测流水线,到时才不会手忙脚乱。
参考资料与致谢
本文在撰写过程中参考了以下资料,感谢原作者的贡献:
- Verizon Data Breach Investigations Report (DBIR) — Verizon, 2024-2025 年度数据安全报告,参考了 68% 云安全事件根因为配置错误和漂移的统计数据
- Terraform documentation: plan -detailed-exitcode — HashiCorp 官方文档,参考了 plan 命令退出码机制的说明
- Snyk IaC describe (原 driftctl) — Snyk 官方文档,参考了 driftctl/Snyk IaC 的漂移检测能力和退出码说明
- HCP Terraform: Drift Detection — HashiCorp 官方文档,参考了 HCP Terraform 内置漂移检测功能的配置和触发机制
- Terraform refresh-only mode — HashiCorp 官方文档,参考了
-refresh-only模式的使用方式和注意事项 - Cloudflare Terraform Provider GitHub Issues — Cloudflare provider 开源仓库,参考了 eternal diff 相关 issue 的讨论和修复进展
- Google SRE Book: Change Management — Google SRE 团队,参考了变更管理和配置一致性的方法论