State、漂移与销毁防护:让 IaC 变更可证明、可接管、可恢复
一次数据库扩容变更通过了代码评审,流水线中的计划却显示 1 to add, 0 to change, 1 to destroy。被销毁的不是测试实例,而是仍承载订单数据的数据库。继续追查才发现,三件事叠在了一起:平台团队把资源从根模块搬进子模块却没有声明地址迁移;故障期间有人在控制台扩大了磁盘;另一名工程师又从旧分支生成计划并准备执行。代码描述的是“想要什么”,远端系统保存的是“现在有什么”,state 或 checkpoint 记录的是“自动化认为自己管理着什么”。三者一旦失去可证明的对应关系,一次看似普通的重构就会变成删除指令。
这类事故不能靠一句“禁止手工修改”解决。故障处置、供应商支持、紧急扩容和组织迁移都会产生带外变更。真正需要建立的是一套控制回路:每次变更先确认操作上下文和权威记录,再读取真实资源,分类漂移,生成不可变计划,经过策略与人工审批后执行;执行后重新核对状态,并保存足够的证据,让下一位接手者能判断资源是否仍受管理、由谁管理、怎样安全退出。
先认清三种状态模型
Terraform、OpenTofu 与 Pulumi 都是有状态 IaC,但它们保存的对象和生命周期并不完全相同;Ansible 则通常没有一份中央资源 state。把四者都说成“声明式,所以重复执行一定安全”,会掩盖最关键的边界。
Terraform 和 OpenTofu 的 state 保存资源地址与远端对象身份之间的映射,以及 provider 返回的属性、依赖元数据和输出。配置中的 module.data.aws_db_instance.primary 只是地址;云端的实例 ID 才是对象身份;state 把两者连起来。计划阶段会综合配置、既有 state 和刷新得到的远端事实,决定原地更新、创建、替换、删除或只更新记录。没有这份映射,工具无法知道改名后的资源是不是原对象,也无法仅凭相似属性可靠地“猜中”。
Pulumi 为每个 stack 保存 checkpoint。checkpoint 除资源 URN、provider ID、输入输出和依赖关系外,还承载 protect、secret 元数据等引擎完成后续更新所需的信息。Pulumi 程序是期望模型,checkpoint 是上次已知的部署快照;pulumi preview 或 pulumi up 默认不会先逐个查询所有远端资源,因此 checkpoint 过旧时,预览可能看不到控制台手改带来的差异。需要显式 pulumi refresh,或为预览、更新增加 --refresh,再决定接受还是覆盖带外变化。
Ansible 的 inventory 回答“对哪些主机或端点执行”,playbook 和 module 参数回答“希望任务把目标变成什么样”。控制节点通常没有一份等同 Terraform state 或 Pulumi checkpoint 的中央资源身份映射;真实结果留在受管节点、外部 API、facts、缓存和执行日志里。多数 module 会比较目标当前值并尝试幂等收敛,但 command、shell、不支持 check mode 的 module、依赖时间或外部副作用的任务都可能破坏幂等。一次 changed=0 只证明这次任务没有报告变化,不证明未来不会漂移,也不证明另一套自动化没有在管理同一对象。
| 问题 | Terraform / OpenTofu | Pulumi | Ansible |
|---|---|---|---|
| 管理身份 | resource address ↔ remote object ID | resource URN ↔ provider ID | inventory target + task 选择,无统一资源映射 |
| 权威执行记录 | state | stack checkpoint | 目标现场、inventory、facts 与执行证据组合 |
| 默认比较入口 | plan/apply 会按相应选项刷新 | preview/up 默认不隐式刷新所有资源 | module 执行时读取目标,能力依 module 而异 |
| 并发风险 | 同一 state 多写者 | 同一 stack 多个 update | 多控制节点同时改同一目标 |
| 漂移收敛 | refresh-only 后选择改代码或回写远端 | refresh 后选择改程序或回写远端 | check/diff、事实采集和业务探针后再执行 |
所以“权威”不是说 state 比云端更真实。远端对象是运行事实,代码是期望事实,state/checkpoint 是管理关系事实。三份事实承担不同职责,任何一份都不能擅自覆盖另外两份。
把权威记录放进可恢复的远端后端
个人电脑上的 terraform.tfstate 或 file:// Pulumi backend 适合隔离实验,不适合多人共享环境。远端 backend 至少要同时满足统一访问、并发互斥、传输与静态加密、版本历史、备份恢复、访问审计和生命周期保护。对象存储只是存放位置;是否提供锁、事务更新、历史、RBAC 和审计,要逐项确认,不能从“远端”两个字推导出来。
Terraform 和 OpenTofu 的 backend 负责保存 state,并在 backend 支持时提供锁。锁会在可能写 state 的操作上自动获取;并非所有 backend 都支持锁,-lock=false 也不是解决等待的常规办法。等待超时应先识别锁 owner、操作 ID、工作目录和流水线,再确认原操作已经退出。force-unlock 只用于自动释放失败且能够证明没有活跃写者的锁;锁 ID 是防止解错锁的 nonce,不是免责确认码。
Pulumi Cloud 提供并发锁、事务 checkpoint、部署历史和托管密钥能力;DIY backend 也有默认启用的基础文件锁和 checkpoint 历史,但对象存储权限、可用性、备份和恢复由团队承担。选择 DIY 不是“没有治理”,而是治理责任回到自己手里。对同一 stack,应禁止两个流水线同时 update;对共享底层资源,即使它们位于不同 stack/state,单栈锁也无法阻止逻辑冲突,还需要所有权和跨栈变更策略。
远端后端配置应把地址与凭据分开。下面的模型强调字段责任,不放真实 bucket、租户或密钥:
terraform {
backend "s3" {
bucket = "iac-state-example"
key = "team-a/demo/terraform.tfstate"
region = "example-region-1"
use_lockfile = true
encrypt = true
}
}bucket 和 key 决定状态隔离边界,配置错误会让两个环境读写同一份 state;use_lockfile 是否可用及其迁移路径取决于当前 backend 实现;encrypt 只描述存储侧要求,不代替最小 IAM、TLS、版本控制和独立备份。backend 凭据不要写进配置或 -backend-config 文件后提交,优先使用短期工作负载身份。初始化日志、崩溃后落地的本地 state 和 shell history 也要纳入泄漏扫描。
OpenTofu 还支持对 state 与 plan 做应用层静态加密。启用前必须备份 state 和密钥并演练恢复;加密防止无密钥读取,不防数据损坏、旧 state 重放,也不防正在执行 tofu 且持有密钥的人读取敏感值。密钥轮换需要显式 fallback 迁移,不能直接替换密钥后期待旧 state 自动可读。
State、计划文件和日志都可能含敏感信息
把变量标成 sensitive,通常只是阻止终端或界面直接展示,不等于值不会进入 state 或 plan。资源属性中的数据库初始密码、连接串、私网地址、证书材料和用户数据都可能被 provider 返回并持久化。Terraform 支持在适用版本和位置使用 ephemeral 值或 write-only 参数避免持久化,但是否可用取决于语言与 provider schema;Pulumi secret 会按 stack 的 secrets provider 加密,但导出的 checkpoint、密钥配置和能够解密的身份仍是高敏资产。
保存的 tfplan、OpenTofu plan、terraform show -json 输出和 Pulumi deployment/checkpoint 不应作为普通构建日志。Terraform 官方明确提醒 plan 文件可能包含敏感数据;OpenTofu 在没有启用 plan encryption 时,即使终端已隐藏,敏感值仍可能明文进入计划文件。CI 的 artifact 应做到:仅受保护分支生成、短保留、加密、限制下载、不可跨上下文复用、到期删除,并记录摘要、提交 SHA、state/stack 身份、provider lock 摘要和审批人。
plan_evidence:
tool: opentofu
operation: normal-plan
source_revision: "<commit-sha>"
state_identity: "team-a/demo"
state_serial_before: 42
provider_lock_sha256: "<lockfile-digest>"
plan_sha256: "<binary-plan-digest>"
destructive_actions: 0
created_by_run: "<ci-run-id>"
expires_after: "short-retention-policy"
approval_required: true摘要只能证明“审批的是这个字节序列”,不能证明执行时外部世界没变。apply 应在同一 state 身份、同一 CLI 版本和受控凭据下消费保存计划,并让 backend 锁与 provider 的并发校验继续发挥作用。保存计划还封装了生成时的配置快照、provider 选择和 backend 配置;把它作为参数传入 apply 会直接执行而不再请求确认。计划过期、state serial 改变、源码或 lockfile 摘要改变、backend 身份不一致时重新生成;不要为赶窗口强行复用旧 artifact。backend token 不写入计划,短期身份在执行时重新获取;若配置使用 ephemeral 输入,则按引擎规则重新注入,不能假定它们已保存在 plan 中。
用合成资源模型观察映射、锁与销毁
真实云账号不适合作为第一轮练习。下面用一个 Node.js 脚本模拟四个对象:期望配置、管理状态、远端事实和互斥锁。它不会连接云,也不会读凭据,但能稳定复现“地址改名产生删除动作”“陈旧序列覆盖”“漂移识别”和“并发锁阻断”。它没有实现真实 provider、backend 或策略引擎,因此不能证明某个产品的删除防护已经生效。准备 Node.js 18 或更高版本,在空目录创建 state-lab.mjs:
import assert from "node:assert/strict";
function plan({ desired, state, remote, lockOwner, actor }) {
if (lockOwner && lockOwner !== actor) {
throw new Error(`LOCKED owner=${lockOwner} actor=${actor}`);
}
const actions = [];
for (const [address, objectId] of Object.entries(state.bindings)) {
if (!(address in desired)) {
actions.push({ action: "delete", address, objectId });
continue;
}
const wanted = desired[address];
const actual = remote[objectId];
if (!actual) {
actions.push({ action: "recreate", address, objectId });
} else if (JSON.stringify(wanted) !== JSON.stringify(actual)) {
actions.push({ action: "update", address, objectId, wanted, actual });
}
}
for (const address of Object.keys(desired)) {
if (!(address in state.bindings)) actions.push({ action: "create", address });
}
return actions;
}
const remote = {
"db-001": { size: 200, encrypted: true },
};
const state = {
lineage: "demo-lineage",
serial: 42,
bindings: { "db.primary": "db-001" },
};
// 正向:地址和远端对象映射稳定,读取到控制台扩容后的漂移。
const drift = plan({
desired: { "db.primary": { size: 100, encrypted: true } },
state,
remote,
actor: "ci-43",
});
assert.equal(drift[0].action, "update");
assert.equal(drift[0].actual.size, 200);
console.log("DRIFT", JSON.stringify(drift[0]));
// 反向:只改地址而不迁移映射,会同时计划删除旧地址、创建新地址。
const unsafeRename = plan({
desired: { "module.data.db.primary": { size: 200, encrypted: true } },
state,
remote,
actor: "ci-43",
});
assert.deepEqual(unsafeRename.map(x => x.action), ["delete", "create"]);
console.log("UNSAFE_RENAME", JSON.stringify(unsafeRename));
// 并发:另一个执行者持锁时必须失败,而不是关闭锁继续。
assert.throws(
() => plan({ desired: {}, state, remote, lockOwner: "ci-42", actor: "ci-43" }),
/LOCKED owner=ci-42 actor=ci-43/,
);
console.log("LOCK_GUARD", "PASS");
// 陈旧写入:serial 较低的快照不得覆盖新状态。
const incoming = { ...state, serial: 41 };
assert.ok(incoming.serial < state.serial);
console.log("STALE_STATE_REJECTED", `${incoming.serial}<${state.serial}`);运行:
node state-lab.mjs预期输出中的 ID 都是合成值:
DRIFT {"action":"update","address":"db.primary","objectId":"db-001",...}
UNSAFE_RENAME [{"action":"delete",...},{"action":"create",...}]
LOCK_GUARD PASS
STALE_STATE_REJECTED 41<42这个实验刻意没有“自动修复”危险重命名。正确动作是先为地址关系声明迁移,再确认计划只显示地址移动而没有远端 create/delete。Terraform/OpenTofu 使用 moved block 或经过评审的 state mv;Pulumi 使用 resource alias。脚本还省略了 provider 的 optimistic concurrency、云 API 最终一致性和依赖图,因此它证明的是治理不变量,不是某个 backend 的实现兼容性。
实验结束删除 state-lab.mjs 即可;没有远端资源、锁或凭据需要清理。若把实验扩展到真实 CLI,应使用独立目录、独立 state/stack 和无云资源,退出前先检查计划,再执行 destroy,并确认 state 为空、远端探针无残留、backend 锁已释放。
漂移先分类,再决定谁向谁收敛
漂移不是一个布尔值。发现 diff 后立刻 apply,可能把正确的故障修复覆盖掉;立刻 refresh,则可能把未经批准的手改写进管理记录。先根据来源与处置意图分类:
期望漂移:代码已经改变,远端尚未执行。证据是提交差异与计划动作一致,通常走审批后 apply。带外配置漂移:控制台、CLI 或另一套自动化修改了受管属性。先判断变更是否授权;保留时把期望代码改到相同值,撤销时由 IaC 恢复。对象身份漂移:资源被手工删除、重建或换了 ID。仅改属性无法修复,需要 import、替换或明确放弃管理。
地址漂移:模块搬迁、重命名、count/for_each 键变化让代码地址改变,实物未变。用 moved 或 alias 迁移管理身份。Schema 漂移:provider 升级改变默认值、归一化或 ForceNew 判断。固定版本和 lockfile,在隔离环境升级并解释计划变化。不可观测漂移:provider 不读取该属性、API 返回脱敏值、Ansible module 不支持 check/diff。需要业务探针、云审计或专门核对工具补足。
Terraform 与 OpenTofu 的 plan -refresh-only 会生成只更新 state 和 root output 以匹配远端对象的计划,适合先审查“管理记录准备怎样变化”。旧的 refresh 子命令本质上自动批准 refresh-only apply,缺少审查窗口,不应成为共享环境的默认路径。refresh-only 只更新记录,不会把配置自动改成远端值;下一次普通计划仍可能提出把远端恢复到代码值。
terraform plan -refresh-only -out=refresh.tfplan
terraform show -no-color refresh.tfplan
# 评审确认带外变化应成为新的管理事实后再执行
terraform apply refresh.tfplanOpenTofu 的入口对应为 tofu plan -refresh-only。计划文件按高敏 artifact 处理,执行后删除本地副本。Pulumi 把“只检测”与“写入 checkpoint”分成两个动作。先使用官方漂移检测入口,不要用会写 state 的 refresh 冒充只读检查:
pulumi refresh --preview-only --expect-no-changes --diff
pulumi refresh --yes
pulumi preview --diff第一步查询 provider、展示漂移且不修改 checkpoint;发现差异时,--expect-no-changes 让命令以非零状态退出,适合 CI 告警。第二步才把远端事实写入 checkpoint,第三步验证程序是否会覆盖刚接受的变化。Refresh 可能把 provider 报告为已不存在的资源从 checkpoint 移除,因此执行前要核对身份、权限和预览中的删除标记。若希望保留带外变更,必须同时修改 Pulumi 程序;只 refresh 会让下一次 pulumi up 再次提出收敛。
Ansible 没有中央 refresh。用 --check --diff、facts、服务健康检查和外部 API 查询构建漂移证据:
ansible-playbook site.yml \
--inventory inventories/lab/hosts.yml \
--limit demo-host \
--check --diffcheck mode 是模拟,module 不支持时可能不执行也不报告;依赖前序 registered value 的条件也可能得不到真实结果。diff 可能打印模板中的密钥,对敏感任务设置 diff: false 和 no_log: true,但同时用脱敏校验和证明结果。对 shell 或外部 API 副作用任务,应补 changed_when、check_mode 行为、幂等探针和显式回滚,不能用 recap 的 failed=0 代替状态验证。
Import 是建立管理关系,不是复制资源
当实物先存在、state 不认识它时,import 把远端 ID 绑定到代码地址。它不会自动证明配置完整,也不会保证导入后零 diff。安全顺序是:冻结目标变更,备份 state/checkpoint,写出最小资源声明,读取实物身份,生成 import 计划,执行导入,再运行普通 plan/preview;只有后续计划符合预期,接管才完成。
Terraform/OpenTofu 优先使用可评审的 import block:
import {
to = example_service.primary
id = "synthetic-service-001"
}
resource "example_service" "primary" {
name = "demo-service"
protection = true
}不要凭名称猜 ID,也不要把同一实物导入多个 state。导入后若计划立即替换资源,先查 provider schema、不可变属性、默认值和对象类型,不要通过 ignore_changes 把所有差异静音。Pulumi 可以使用 CLI import 或资源 option 的 import;生成的代码仍要评审,尤其是 secret、默认 provider 和父子 URN。导入只建立当前 stack 的管理记录,Policy Pack 在 pulumi stack import 这类 state 导入动作上并不会替你验证真实资源,后续 update 仍需策略与预览。
Ansible 的“接管”不是 import。把主机加入 inventory 后,先运行只读 facts、--check --diff 和服务探针,确认现状;再把 playbook 写成能够从当前状态渐进收敛的任务。对于无法安全逆向的配置,先备份原文件、记录 checksum 和 owner,再切换。inventory 中存在主机并不等于 Ansible 拥有该主机上的每个文件和服务。
重构地址时保住对象身份
Terraform/OpenTofu 从根模块移动到子模块时,声明迁移关系:
moved {
from = example_service.primary
to = module.runtime.example_service.primary
}moved block 让计划在资源地址层迁移 state,而不是删除实物再创建。它应随兼容窗口保留,让尚未跨过该版本的 workspace 也能迁移;过早删除会让旧 state 再次出现 create/delete。复杂批量迁移可使用 state mv,但它是受控维护动作:先拉取只读备份,冻结写入,核对 lineage/serial,执行后立即 plan。不要直接用文本编辑器改 state JSON,因为内部格式和 provider 私有字段不是稳定 API。
Pulumi 重命名逻辑名、改变 parent 或 type 会改变 URN。使用 alias 告诉引擎“新 URN 与旧资源相同”:
const service = new ExampleService("runtime", args, {
aliases: [{ name: "primary" }],
});preview 应显示同一资源被采用,而不是 delete 加 create。alias 解决 Pulumi 身份迁移,不改变云端 ID;若 provider ID 本身变化,则可能仍需 replacement。跨 stack 接管还涉及 export/import、依赖和 stack reference,不能靠 alias 把两个 backend 的管理权自动转移。
四种“保护”不是同一个开关
删除防护至少分三层:IaC 引擎阻断、策略/审批阻断、云端服务自身 deletion protection。任何单层都可能被有权人员关闭,关键资源应组合使用。
Terraform 的 lifecycle { prevent_destroy = true } 会拒绝包含删除或替换的计划,但它必须仍存在于配置中才能发挥作用。直接删除整个 resource block 时,配置也同时删除了保护规则,不能把它当成不可绕过的保险丝。适合数据库、密钥和共享网络的做法是同时启用云端 deletion protection、策略检查和审批;移除 prevent_destroy 必须单独变更,先完成备份、依赖确认与恢复演练。
resource "example_database" "orders" {
name = "orders-demo"
deletion_protection = true
lifecycle {
prevent_destroy = true
}
}OpenTofu 同样支持 prevent_destroy。从 OpenTofu 1.10 起,resource lifecycle 还提供 destroy = false 的保留语义,并把该决定持久化进 state:即使随后从配置删除 resource block,或执行 tofu destroy,引擎也会忘记管理记录而保留实物,直到显式改回 true 或用相应 removed block 覆盖。它解决的是生命周期脱钩,不是“继续管理但永不删除”。tofu destroy 因忘记资源而未完整销毁时默认返回非零,自动化不能把它误判成普通失败后再次强制删除;只有明确接受这一结果的流程才考虑 -suppress-forget-errors。被忘记后,漂移监控、后续更新和成本归属都不会自动继续,必须同步登记新 owner 和删除责任。
Terraform 与 OpenTofu 都可用 removed block 表达停止管理而保留实物:
removed {
from = example_database.orders
lifecycle {
destroy = false
}
}这比直接 state rm 更可评审,因为 plan 能显示资源将从 state 移除但不会调用远端删除。执行前确认所有引用已迁移,执行后用新 owner 的探针验证实物存在,并检查旧 state 已不再跟踪。资源无人接管时,这个动作会制造“云端仍收费、IaC 已看不见”的孤儿资产。
Pulumi 的 protect: true 会把保护标记写入 checkpoint。任何因删除、替换或从程序移除而触发的 delete 都会失败;需要先在程序中设为 false 并运行一次 update,或显式使用 pulumi state unprotect,随后再执行删除。retainOnDelete: true 则是在 custom resource 被删除或替换时从 checkpoint 移除,但不调用 provider 的 Delete;它更接近“保留实物并停止管理”。它对 component 本身没有直接云端删除效果,但会作为默认值传播给子 custom resource。
两者同时为 true 时,protect 先阻止删除,retainOnDelete 不能绕过保护。必须先解除 protect,下一次删除或替换才会按 retain 语义保留实物并从 checkpoint 移除。这个组合仍要在隔离 stack 中预览和演练,因为 provider 的替换顺序、父子资源传播和外部 deletion protection 会决定最终证据,但引擎层的先后关系不应留给猜测。
const database = new ExampleDatabase("orders", args, {
protect: true,
retainOnDelete: true,
});Ansible 没有与 checkpoint 绑定的通用 protect。保护必须落在 playbook 条件、tag、环境变量、审批和目标系统 API 上。不要让 state: absent 任务由默认 tag 触发:
- name: Remove demo service only through approved teardown
ansible.builtin.service:
name: demo-service
state: absent
when:
- teardown_authorized | bool
- inventory_environment == "lab"
- teardown_ticket | length > 0
tags: [never, teardown]tags: never 只是降低误触概率,有权执行者仍可显式选择。真正的删除 API 还要用最小权限身份、目标 allowlist 和外部审批约束;任务执行后必须验证服务停止是否符合目标,而不是假设 playbook 条件等于云端保护。
危险销毁要有机器可判定的阻断
人工在几百行 plan 里找红色减号并不可靠。流水线应先把计划转换为机器可读动作,计算 create/update/replace/delete 数量和关键资源集合,再执行策略。Terraform/OpenTofu 可从 show -json 读取 resource_changes[*].change.actions;Pulumi 可用 preview JSON 或事件流构建同类证据。策略至少拒绝:未声明的删除、关键资源 replacement、删除数量超出本次工单预算、资源上下文不匹配、计划与申请的 state/stack 不一致。
ALLOW when:
source_revision == reviewed_revision
state_identity == requested_state
destructive_actions == approved_destructive_budget
protected_resource_deletes == 0
plan_age_within_policy == true
state_serial_unchanged == true
DENY when:
delete or replace touches data-bearing/shared/identity resources
context cannot prove account + region + workspace/stack
plan artifact or provider lock digest changed after approval“输入 yes”不是生产审批。Terraform/OpenTofu 的 -auto-approve、Pulumi 的 --yes 只关闭交互确认,不能代替代码评审和策略门禁。destroy 应先生成独立销毁计划并展示对象清单、数据备份状态、依赖、成本停止点和恢复路径。Pulumi Policy 的 mandatory resource policy 能在资源注册前阻断违规资源;stack policy 在 preview 和 update 的时序不同,不能假设所有策略都一定在任何远端动作之前执行。策略本身也要版本化、测试、固定依赖并记录豁免。
Break-glass 是缩短事故时间,不是跳过证据
当错误配置正在扩大事故时,可能来不及等正常窗口。break-glass 需要在平时准备:独立高权限角色、短时凭据、双人授权、限定 state/stack/资源、会话审计、自动到期和事后复盘。紧急处置仍按最小证据顺序执行:冻结常规流水线,保存 state/checkpoint 与计划摘要,确认当前锁 owner,读取目标实物,实施最小动作,记录远端响应,再用 refresh-only/refresh 建立新基线。
不要把以下动作自动化为“失败就强制执行”:
force-unlock:只用于自己那次运行自动解锁失败的残留锁;必须核对报错给出的 lock ID,并证明对应执行已终止,不能替仍在运行的其他写者解锁。state push -force:会绕过 lineage 与 serial 两项防护,只能在另存只读备份、核对目标 backend 并完成双人复核后用于已演练的灾难恢复。直接编辑 state/checkpoint:内部结构并非稳定用户接口,常规重构应使用 moved、alias、import、removed 或官方 state 子命令。
destroy -auto-approve:交互减少不等于风险减少。全局关闭云端 deletion protection:应只对批准对象、在短窗口内解除并自动复查。
一个可执行的 break-glass 记录应包含 incident_id、操作者和复核者、凭据到期、目标 backend/state/stack、锁 ID、操作前 serial/version、资源 ID、允许动作、实际命令摘要、远端结果、操作后 serial/version、残余漂移和恢复 owner。敏感 token 与完整 state 不进入工单正文,只保存受控引用和哈希。
接管、迁移与退出都要双向证明
从 Terraform 迁到 OpenTofu、从自管 backend 迁到托管平台、从 Terraform/OpenTofu 迁到 Pulumi,或者把配置责任交给 Ansible,都不能只证明新工具“能看到资源”。迁移窗口内必须保证同一对象只有一个写 owner。
建议把过程拆成六个可回退阶段:
冻结源管理面写入,记录源 state lineage、serial/version、对象清单和 provider 版本。备份 state/checkpoint、加密密钥与 backend 配置,在隔离位置验证可读取。用目标工具只读发现或 import,保持源工具仍是唯一写 owner。
生成目标工具零变更或预期差异的 plan/preview,逐项解释无法归一化的属性。切换 owner,撤销源工具写权限,再执行一次受控更新和漂移检测。源工具通过 removed { destroy = false }、retainOnDelete 或受控 state 移除停止管理;验证实物仍在、目标工具能更新、旧工具计划不再触碰。
Terraform 与 OpenTofu 语法兼容不代表 backend、provider source、state encryption、许可证和自动化集成可以无条件互换。迁移前在副本上验证 state 读写与回退,禁止两个二进制轮流写同一份生产 state。Pulumi checkpoint 导出导入可用于 backend 迁移和灾难恢复,但导出文件是高敏数据;导入后要检查 stack identity、secrets provider、URN、provider 资源和依赖。Ansible 接管配置时,保留原系统快照和配置 checksum,先 check/diff,再对单一 canary 主机执行,避免一上来全量覆盖。
退出也要指定孤儿资源 owner、账单归属、审计保留和最终删除日期。仅把对象从 state 移除会让 IaC 成本报表“归零”,云账单却继续增长。团队应定期比对云资产清单与 state/stack/inventory 所有权目录,发现无 owner、无管理记录、无近期变更证据的资源立即进入隔离和认领流程。
恢复演练要验证可写,不只验证有备份
state 备份存在不等于能恢复。至少在隔离 backend 演练四种故障:活动 state 丢失、最新版本损坏、锁残留、加密密钥不可用。恢复时先停止所有写入,复制而不是覆盖原始故障现场,校验 lineage/serial/version 和文件摘要,再在隔离凭据下运行只读 state list、plan/preview。只有计划与远端对象对应关系正确,才能考虑恢复写入。
recovery_drill:
backup_object_readable: true
decryption_key_available: true
lineage_or_stack_identity_matches: true
restored_resource_count_matches_inventory: true
read_only_plan_has_unexplained_destroy: false
lock_acquire_and_release_verified: true
destructive_cloud_credentials_used: false
rollback_to_pre_drill_state_verified: trueTerraform/OpenTofu 在 backend 写入失败时可能把 state 落到本地以避免丢失。看到这类文件后,先保护现场并停止新的 apply;比较远端 serial 与本地 serial,确认 lineage,评审差异。state push 的 lineage 与 serial 防护会拒绝部分错误覆盖,-force 可绕过这些保护,所以不能成为“推不上去就加”的参数。
Pulumi 文件或对象存储 DIY backend 要同时保护 .pulumi/stacks/、.pulumi/history/、.pulumi/locks/、backend 元数据及 secrets provider 所需材料;PostgreSQL 等其他 DIY backend 应按其存储模型做一致性备份,不能套用对象路径。locks/ 是运行时互斥记录,不应从旧备份机械覆盖到活动 backend;恢复时先证明没有活跃 update,再由 backend/CLI 重新建立锁。Pulumi Cloud 或其他托管 backend 则要确认版本历史、导出权限和组织恢复流程。Ansible 没有中央 state 可恢复,但 inventory、Vault 身份、role/collection lock、目标配置备份和执行日志缺一不可;恢复结果由目标节点 checksum、服务探针和业务请求证明。
把状态治理接入项目与 CI
项目仓库里保留能复核的配置和规则,不保留运行 state、计划二进制或长期凭据:
infra/
modules/
stacks/
lab/
production/
policies/
migrations/
moved-and-import.md
scripts/
summarize-plan.mjs
verify-context.ps1
.terraform.lock.hcl
README.mdCI 分成发现、审批和执行三种身份。发现身份只读远端资源和 state,生成漂移证据;计划身份可读取 state 但不能修改云资源;执行身份只在受保护环境和短窗口内获得最小写权限。若工具无法真正分离 plan/apply 权限,至少用环境审批、短期 OIDC、受控 runner 和 backend 审计缩小风险。
一次合格的运行链应留下:源码提交、工具和 provider/module/collection 版本、依赖锁摘要、state/stack/inventory 身份、计划摘要与 artifact 哈希、策略结果、审批、执行日志、操作前后 state version、远端探针、成本标签和清理结果。模块/provider/collection 是供应链执行入口,应固定来源与版本,验证 lockfile 和签名/校验和,不允许未评审的远端脚本或浮动主分支进入高权限执行环境。
容量和成本也属于状态治理。大 state 会放大刷新 API 调用、序列化、锁等待和故障半径;过细拆分则增加跨 state 依赖、权限和一致性协调。按团队所有权、变更频率、故障域和权限边界拆 state/stack,而不是按资源数量机械切分。记录计划耗时、锁等待、state 大小、资源数、refresh API 错误、漂移数量、孤儿资源和未按时销毁成本,用趋势决定是否拆分。
现场排查从第一份证据开始
计划突然大量删除:先停止 apply,核对 workspace/stack/backend key、账号、区域和源码提交;再看 state 中资源地址是否与代码一致。若删除与创建成对出现,优先怀疑地址重构、for_each key 或 provider schema;若 state 资源大量缺失,怀疑读错 backend 或恢复了旧版本。不要用 -target 或 import 批量掩盖,先证明对象身份。
持续拿不到锁:从报错提取 lock ID、owner、operation 和时间,定位相应 CI run/进程。活跃执行仍在时等待或终止它;确认进程已死且 backend 没有新的版本写入后才 force-unlock。持续锁冲突说明流水线调度或 state 边界有问题,不是把 -lock=false 写进脚本的理由。
刷新后差异越来越多:检查 provider 版本和 lockfile、默认值归一化、忽略字段、权限不足导致的读失败,以及 API 最终一致性。对单个资源做只读查询并和 state 属性逐项比对。refresh-only/refresh 成功只说明记录更新成功,不说明新记录符合代码期望。
删除保护没有挡住:确认保护位于哪一层。Terraform resource block 是否已被整个删除,Pulumi checkpoint 是否已被 unprotect,OpenTofu 是否配置了保留而非阻断,云服务 deletion protection 是否被另一身份关闭,Ansible 任务是否显式选择了 teardown tag。用审计日志还原每一层由谁、何时、以什么身份解除。
恢复后 plan 要重建所有资源:立即停止,检查 lineage/stack identity、backend key、provider 配置和 secrets provider。空 state、错误 workspace 或无法解密的 checkpoint 都可能让工具失去映射。不要让工具“重建试试看”,先在只读凭据下恢复对象清单和身份关系。
团队运行基线
每个 state/stack/inventory 边界都要登记 owner 与 backup owner、backend、环境、账号/区域、加密密钥 owner、锁机制、备份和恢复入口、允许的执行身份、关键资源、成本中心和退出方式。权限分离到读取 state、生成计划、审批、apply、force-unlock、state 维护、解除保护和删除云端对象;不能让日常开发身份默认拥有全部能力。
变更评审不接受一句“plan 看起来没问题”。评审者至少回答:计划来自哪份 state,是否刷新,哪些动作是 delete/replace,为什么对象身份未变化,保护如何生效,计划是否含敏感值,apply 使用什么短期身份,失败后怎样恢复,执行后由什么探针证明结果。break-glass 与 state 维护操作单独审计,定期复盘是否能用 moved/import/removed/alias 或 backend 能力消除人工步骤。
漂移治理不要自动把所有差异都改回代码。安全违规和未授权变更可自动告警,低风险且可逆的属性可以在策略批准后自动收敛;数据资源、身份、网络边界和删除动作仍需人工判断。持续观察以下趋势:未解释漂移存活时间、锁冲突次数、计划与 apply 间 state 变化、保护解除次数、state 手工维护次数、恢复演练成功率、孤儿资源数量和计划 artifact 越权下载事件。
最终验收不看“工具命令是否执行成功”,而看不变量是否成立:同一资源只有一个写 owner;地址迁移不触发实物重建;并发写入被锁或调度阻止;敏感 state/plan 不进入仓库和普通日志;删除必须跨过引擎、策略、审批与云端保护;接管和退出后仍能证明实物、管理记录、owner 与账单一致;备份能够在隔离环境恢复并生成无未解释销毁的只读计划。
延伸阅读
Terraform State 的用途。Terraform backend 的状态存储与锁
Terraform 敏感数据与 state/plan。Terraform refresh-only 工作流
Terraform removed block。OpenTofu state 与 plan 加密。OpenTofu state 锁
OpenTofu plan 模式。OpenTofu destroy 的忘记资源与退出状态。Pulumi state 与 backend
Pulumi protect 资源选项。Pulumi retainOnDelete 资源选项。Pulumi 漂移检测与收敛
