部署失败。资源仍在运行。

部署命令在配置阶段失败时,网络和虚拟机早已建好并仍在运行;非零退出码只描述那个进程,不是远程系统的状态,所以恢复要靠带状态的工作流,而不是再跑一遍。

中文
复制
题图:一个终端窗口里刷满了红色的 net::ERR_BLOCKED_BY_AD… 报错,像是部署脚本连续失败时的输出

部署走到配置阶段就停了。 网络已经建好,虚拟机已经建好,配置没有跑完,健康检查从未执行。流水线是红的,但云厂商控制台上那些资源仍然活着。 两种说法在技术上都没错。命令失败了。基础设施也确实变了。 问题出在自动化把前一个事实当成后一个事实的反证。非零退出码描述的是进程的结果,不是该进程结束后若干远程系统的完整状态。

命令失败不等于回滚

基础设施工作流往往只向操作者暴露一个动作,底下却发出许多次独立调用。一次部署可能依次创建网络、预留地址、创建虚拟机、等待连通、配置操作系统、跑服务探针。 这些调用不会因为由同一条命令发起就变成一个原子事务。如果第四步失败,前三步可能已经完成。 Microsoft 在补偿事务模式的说明里对分布式操作讲了同一件事:已完成的工作往往需要专门的补偿动作,而这些动作本身也可能失败。恢复是一条带状态的工作流,不是一个自动倒带按钮。 这个区别在 CI 里很容易被忽略,因为流水线天然把结果压成绿或红。绿表示进程走到了预期终点,红表示没有。两种颜色都无法告诉操作者,失败之前哪些云厂商侧的变更已经提交。

盲目重试会让状态更糟

运行失败后,通常的反应是重跑一遍。只有当系统能确定第一次尝试做了什么,这样做才安全。 假设云厂商接受了创建请求,但响应在连接中断时丢了。调用方看到的是错误。重试可能创建出重复资源、返回名称冲突,或者把后续步骤挂到错误的对象上。 这个原则在 API 设计里很常见。HackerNoon 的幂等与重试指南解释了为什么在响应不确定时重复请求并不安全——除非服务端和客户端对该操作共享一个稳定的标识。Google Cloud Workflows 也把这一点讲得很明确:它的重试指引对幂等步骤和非幂等步骤给出了不同的处理方式。 幂等有用,但不是全部答案。它能防止同一个逻辑请求产生两次相同的副作用,却不能告诉编排器哪些前面的步骤已完成、哪些后面的步骤从未开始、某个失败的资源应该续用还是删除。 这需要一份持久化的操作记录。

先写状态转换,再执行变更

最重要的状态写入发生在控制权交给 provider 之前。 校验和预检通过之后,runtime 应当先持久化一个非终态标记,例如 running。这个标记需要包含操作标识、目标、解析后的输入、时间戳,以及上一次运行中已经拿到的输出。做完这一步,才能开始调用 provider 执行变更。 如果状态写入失败——磁盘满了、路径不可用,或者后端无法提交——provider 调用就不该启动。 这条规则听起来很苛刻。它把一个本地存储问题变成了部署被阻断。但反过来做更糟:基础设施已经变了,却没有任何持久记录证明这次变更曾经开始过。 有一个保守的边界情况。进程可能持久化了 running,然后在到达 provider 之前就停了。之后运维人员必须去核对一个不确定的操作——它实际上没有对远端做任何改动。这总好过在 provider 可能已经创建了东西之后,还留着一个旧的 destroyed 标记。

保留 runtime 已经掌握的信息

失败处理应该更新状态,而不是用一个空结果把它覆盖掉。 一份缩减后的状态记录大致长这样:

{
 "module_ref": "platform/example/service",
 "status": "error",
 "failed_command": "apply",
 "last_error": "provider failed after create",
 "outputs": {
 "resource_id": "previously-known-resource"
 },
 "rerun_inputs_file": "/config/rerun-inputs.yml",
 "resolved_inputs_file": "/resolved.inputs.yml",
 "run_id": ""
}

输出被刻意标注为「此前已知」。首次创建时,provider 可能已经创建了对象,却在返回标识符之前失败了。自动化没法保留一个它从未收到的值。但它仍然可以保留输入、操作标识、错误、时间戳和运行记录——这些正是之后查询 provider、核对结果所需要的。 这件事和基础设施引擎自身的状态有关,但两者是分开的。HackerNoon 那篇关于远程 Terraform 状态后端的讲解说明了,为什么资源绑定和锁必须能跨单台工作站存续。HashiCorp 自己的状态文档则把状态描述为配置中的资源实例与远端对象之间的映射。 编排 runtime 的任务不一样。它要记住的是:跨引擎尝试了哪个生命周期操作、在哪一步失败、接下来哪些动作是安全的。provider 状态和操作状态互为补充,谁都不该冒充另一个。

未执行的工作不等于失败

部分失败也会改变依赖图的呈现方式。考虑四个步骤:

network ok
virtual machine error
configuration pending
health check pending

配置和健康检查并没有失败。它们根本没有运行。把这三个步骤统统称为失败,会掩盖真实的边界,还可能诱导出一条错误的恢复路径:重复已经完成的工作,或者在虚拟机状态尚未稳定时就去执行健康检查。

依赖图需要足够的词汇来区分 complete、error、pending、retained 和 destroyed 这几种状态。内部工作流引擎可能把未运行的步骤称为 deferred。对运维人员来说,含义更简单:它在等待前一个状态转换完成。

这正是 reconciliation 比 retry 更有用的地方。最近一篇 HackerNoon 关于大规模 reconciliation 的文章介绍了稳定的操作标识符、条件式状态变更,以及对外部系统的校验。同样的原则也适用于基础设施。恢复要基于可以核实的事实,而不是基于“失败的进程什么都没改”这一假设。

销毁是一项需要验证的操作

错误状态必须被视为可能仍然存活。

如果销毁逻辑跳过所有未标记为 ok 的步骤,它就会跳过那些在 apply 失败后最需要清理的资源。更安全的规则是:只跳过已确认处于 absentdestroyed 的状态。带有 error 标记的资源仍然可以被 provider 检查并拆除。

在这个过程中,归属关系很重要。父资源被移除时,子资源可能随之消失。例如,删除一台虚拟机可能同时移除一个 guest 级别的状态,而一旦虚拟机不存在,这个状态就无法再单独访问。

父资源开始删除时,不应把子资源标记为 destroyed。只有当父资源确认不存在后,子资源才应进入终态。如果父资源删除失败,子资源就保持未解决状态。

Kubernetes 用 finalizers 表达了类似的想法。删除请求会把对象置入 terminating 状态,而对象会一直保留到所需的清理完成。API 请求并不被当作资源已经消失的证明。

基础设施的拆除同样应该如此诚实。

这是对我在 Disaster Recovery as a Governance System 中所持论点的延伸:恢复需要明确的决策、经过验证的结果,以及一份关于实际发生了什么的记录。而这样的治理,前提是运行时首先保留了对失败操作的准确记录。

把失败场景变成一份契约

我维护 HybridOps Core,一个开源的基础设施运行时。在加固它的失败行为时,我加了一条回归路径:先写入一个旧的 destroyed 标记,再发起一次新的 apply,然后返回一个部分失败的 provider 错误。

预期的契约是严格的。在调用 provider 之前,状态必须读到 running。失败之后,状态必须读到 error,保留此前的输出,指出失败的是哪条命令,并且仍然可以被 destroy。另有一个单独的测试,用来确保在变更前的状态写入失败时,provider 不会被执行。

实现和测试都在 partial-mutation change 里。这个项目只是其中一种实现。这份契约适用于任何在远程系统之间协调有状态变更的自动化层。

五条值得保留的规则

  1. 在第一次 provider 变更之前,先持久化非终态。
  2. 运行时无法持久化该状态时,不要执行变更。
  3. 失败之后,保留已知的输出和已确定的恢复输入。
  4. 把未执行的依赖工作显示为 pending,而不是 failed。
  5. 只有在确认资源确实不存在之后,才宣告拆除完成。

这些规则并不承诺基础设施具备原子性。它们做的是更实际的事:在进程尝试过什么、基础设施现在可能包含什么之间,保留一条诚实的边界。

一条红色的流水线只能证明执行以失败告终,不能证明环境已经回滚。下一位操作者应该从持久化的状态和一条有效的恢复路径开始,而不是从 provider 控制台和一次重建练习开始。

来源: HackerNoon← 返回首页