概述

某周三凌晨两点,我在某出行项目值班手机响了。告警群里弹出一条 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-exitcodedriftctl / Snyk IaCHCP 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,告警完全失效。

修复:这其实是整套漂移检测工程中最花时间的部分——不是搭流水线难,是让告警有效难。三个步骤:

  1. 建立伪漂移白名单:跑一周检测,收集所有"apply 后仍然报差异"的资源+属性组合,加到白名单里。我在上面 drift_analyze.py 里的 PSEUDO_DRIFT_WHITELIST 就是这个用途。
  2. 分级告警:引入漂移 = 立即推送(钉钉/飞书/Slack),涌现漂移 = 日报汇总(每天一封邮件),伪漂移 = 静默记录(只存日志不告警)。
  3. 定期清理白名单:每季度检查一次白名单,看是否有 provider 修复了 eternal diff 可以从白名单移除。白名单不是一劳永逸的——provider 会升级,bug 会修复,白名单也要跟着迭代。
坑根因修复方案预防措施
Cloudflare eternal diffprovider 类型映射 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-exitcodeterraform plan手动 apply低(CI 时间)
中团队(10-30 人)单云 200-800 资源CI + driftctldriftctl/Snyk IaC + plan告警+人工确认中(CI 时间+维护)
大团队(30+ 人)多云 >800 资源HCP Terraform + driftctlHCP 内置 + driftctl告警+审批流高(HCP 订阅+维护)
合规要求高(金融/医疗)HCP Terraform + SentinelHCP 内置只读控制台+IaC 强制高(HCP Enterprise)

一张更细的成本对比:

方案初始成本持续成本检测延迟误报率适合规模
terraform plan CI 脚本2 人天低6 小时中(无分类)<200 资源
driftctl + CI5 人天中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 人,漂移一定会来。提前搭好检测流水线,到时才不会手忙脚乱。

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. Verizon Data Breach Investigations Report (DBIR) — Verizon, 2024-2025 年度数据安全报告,参考了 68% 云安全事件根因为配置错误和漂移的统计数据
  2. Terraform documentation: plan -detailed-exitcode — HashiCorp 官方文档,参考了 plan 命令退出码机制的说明
  3. Snyk IaC describe (原 driftctl) — Snyk 官方文档,参考了 driftctl/Snyk IaC 的漂移检测能力和退出码说明
  4. HCP Terraform: Drift Detection — HashiCorp 官方文档,参考了 HCP Terraform 内置漂移检测功能的配置和触发机制
  5. Terraform refresh-only mode — HashiCorp 官方文档,参考了 -refresh-only 模式的使用方式和注意事项
  6. Cloudflare Terraform Provider GitHub Issues — Cloudflare provider 开源仓库,参考了 eternal diff 相关 issue 的讨论和修复进展
  7. Google SRE Book: Change Management — Google SRE 团队,参考了变更管理和配置一致性的方法论