# 为自主部署设计无冲突的回滚端点

自主部署代理需要的是安全失败的权限，而不是猜测的权限。危险的回滚很少是因为命令执行失败。更危险的情况是，系统环境已经发生变化，命令却仍然成功，把旧制品恢复到一个并非由该代理创建的发布之上。

安全的回滚操作会明确某次部署尝试，指定某个之前的发布，并在它对当前状态的判断已经过时时拒绝执行。应把回滚视为一种受保护的状态转换，而不是通往“最后一个正常版本”的捷径。这个区别决定了代理是在修复自己的工作，还是抹掉别人的变更。

## 回滚必须属于某次部署尝试

回滚端点必须把撤销动作绑定到导致疑似故障的那次部署尝试。单靠版本号无法完成这项工作。

团队经常保存 `1.8.4`、`1.8.5` 和 `1.8.6` 这样的序列，然后提供一个“将 1.8.4 部署到生产环境”的操作。代理部署了 `1.8.5`，收到告警后调用该操作。与此同时，一名工程师已经部署了紧急发布 `1.8.6`。回滚端点因为 `1.8.4` 存在而接受请求。此时生产环境运行的是早于这两次变更的制品。API 完全按照请求执行，而问题正在于此。

请分开保存以下身份：

- **发布**是一个不可变的代码包，包含配置引用和元数据。为它分配持久 ID 和制品摘要。
- **部署尝试**是一次将发布放入指定目标环境的请求。它拥有部署 ID、执行者和生命周期。
- **目标状态**是环境当前运行的内容。它包含一个每次接受状态转换都会变化的修订号或代数。
- **回滚意图**表示：只有在部署尝试 `D` 仍然拥有当前状态时，才允许它将目标恢复为发布 `R`。

人们经常遗漏的是所有权关系。当部署 `dep_842` 推进发布 `rel_105` 时，应记录 `dep_842` 创建了当前目标代数 `gen_913`。绑定到 `dep_842` 的回滚只能在 `gen_913` 仍是当前代数时执行。如果后续部署创建了 `gen_914`，服务就必须拒绝旧请求。

不要通过比较时间戳来推断所有权。重试、排队的工作进程、人工修复，以及允许用户选择旧发布的系统，都会让时序判断失效。在接受推进操作时，直接保存这种关系。

这也回答了一个棘手的运维问题：代理能否回滚另一个代理的部署？通常不能。独立的授权方可以明确批准这种干预，但普通回滚能力应只覆盖调用者自己发起的操作。广泛的权限看起来很方便，直到两个部署循环同时响应同一事故。

## 不可变的来源记录让之前的发布可被确定

服务必须在改变目标之前记录回滚来源，因为推进完成后，“之前”这个词就变得含糊。告警发生后再查询发布历史，很容易从列表中选中碰巧相邻的项目。

部署服务接受推进操作时，应创建一条记录，其中包含当时选定的之前稳定发布。这个发布可能不同于紧邻的上一个事件。例如，金丝雀发布可以在 `rel_103` 仍是稳定基线时推进 `rel_105`，而 `rel_104` 只是一次中止的实验。正确的恢复目标可能是 `rel_103`，而不是 `rel_105` 之前紧邻的那一行。

最小记录可以如下所示：

```json
{
  "deployment_id": "dep_842",
  "environment": "production",
  "release_id": "rel_105",
  "artifact_digest": "sha256:8b2c...",
  "source_revision": "4f1d9c7",
  "config_digest": "sha256:1a06...",
  "prior_release_id": "rel_103",
  "created_target_generation": "gen_913",
  "migration_set_id": "mig_77",
  "actor_id": "agent-run-27"
}
```

`prior_release_id` 是一个决策，不是方便查询的字段。推进控制器应按照运维人员可以检查的规则选择它：例如，选择该目标最近一次经过验证的稳定发布，同时考虑配置和迁移要求是否兼容。回滚操作使用保存下来的决策，不会因为历史表已经变化而重新计算。

制品身份不能只依赖人类可读的发布标签。标签可能被重新指向，构建标签也可能意外复用。回滚应部署原始尝试中记录的内容地址或等效的不可变制品引用。如果制品仓库允许同一标签后来指向不同的字节内容，那么目标发布 ID 必须解析到推进时捕获的摘要。

配置也需要同样严谨。只恢复应用字节，却保留已经变化的功能开关、运行时密钥引用、镜像拉取策略或资源设置，可能会创建一个测试中从未存在过的系统。你不必在部署记录中复制每个值，但应记录不可变的配置版本或摘要，并明确回滚是否会恢复它。

数据库状态是另一个边界。只新增可为空列的发布，通常允许应用回滚。删除列、重写值或改变语义的发布则可能不允许。若发布规划器无法证明兼容性，就不要承诺通用的“完整回滚”。应将部署标记为可回滚应用、可回滚流量，或需要修复。拒绝执行总比让旧代码面对无法理解的架构更好。

## 端点需要预期的当前发布

回滚请求必须同时携带目标发布，以及它预期要替换的实时状态。没有这个前置条件，端点就无法区分有效恢复和过时指令。

请求契约可以设计成这样：

```http
POST /v1/environments/production/rollbacks
Idempotency-Key: 7e4cd1ee-62cb-4efa-985f-4ee0b77d577b
Content-Type: application/json

{
  "origin_deployment_id": "dep_842",
  "expected_current_release_id": "rel_105",
  "expected_target_generation": "gen_913",
  "restore_release_id": "rel_103",
  "reason": "error rate exceeded release threshold",
  "approval_id": "apr_551"
}
```

服务应尽可能根据 `origin_deployment_id` 推导 `restore_release_id`，然后将请求中的值与保存的 `prior_release_id` 比较。请求中同时保留这两个字段，有助于审计人员了解代理声称的意图，但最终以服务器记录为准。绝不能让调用者把自己发起的部署变成任意选择历史制品的许可证。

成功时，返回新创建的部署尝试和新的目标代数。如果系统能够同步预留该状态转换，就不要返回含糊的 `accepted` 响应。

```json
{
  "rollback_deployment_id": "dep_849",
  "reverted_deployment_id": "dep_842",
  "previous_release_id": "rel_105",
  "current_release_id": "rel_103",
  "target_generation": "gen_914",
  "status": "running"
}
```

如果实时目标已经不匹配，应返回冲突响应。响应正文应包含足够的信息，让代理报告发生了什么，但不能提供足够的权限让它自行设计新动作。

```http
HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "error": "stale_rollback",
  "origin_deployment_id": "dep_842",
  "expected_target_generation": "gen_913",
  "observed_target_generation": "gen_914",
  "observed_release_id": "rel_106"
}
```

409 是一次成功的安全结果。应让代理知道收到这个结果后必须停止，把响应附加到事故记录中，并请求新的决策。不要给它“去掉预期代数后再试一次”这样的备用指令。这样的备用路径会把你的防护变成表面功夫。

有些团队使用携带 ETag 的 `If-Match` HTTP 请求头，而不是 JSON 字段。只要 ETag 代表目标状态，并且每次状态转换都会变化，这种方式就可行。具体机制不如这个不变量重要：命令必须明确它可以替换的状态。

## 序列化可以阻止两个有效请求发生冲突

如果两个工作进程可以在任何一个进程提交之前都通过检查，那么单独的前置条件检查无法保护目标。部署服务必须串行处理同一环境的变更，并在状态存储中使用原子比较并交换。

假设目标当前为 `(rel_105, gen_913)`。代理提交回滚，操作员提交 `rel_106`。两个请求都读取到 `gen_913`。如果服务只在应用内存中检查，之后再无条件写入，那么两个调用都可能声称成功。后写入的值会生效，而审计记录可能报告一个用户从未实际看到过的状态。

应把比较和修改放在同一个事务或条件写入中。关系数据库实现可能使用这样的模式：

```sql
UPDATE environment_targets
SET release_id = :restore_release_id,
    generation = generation + 1,
    active_deployment_id = :rollback_deployment_id,
    updated_at = CURRENT_TIMESTAMP
WHERE environment = :environment
  AND generation = :expected_generation
  AND release_id = :expected_release_id;
```

服务应检查受影响的行数。一行发生变化，表示状态转换已被预留。零行表示冲突。随后应读取当前目标，并在 409 响应中返回实际观测到的值。

队列不能替代这个条件。队列可以降低并发工作的概率，但重复投递、队列之外的人工路径，或工作进程重试，仍可能产生相互竞争的命令。条件写入必须保留在状态实际存储的位置。

幂等性解决的是另一类故障。代理可能在服务接受回滚后丢失响应。如果它使用相同的幂等键重试，服务应返回原来的回滚部署和状态，不得再次预留一个代数或启动第二次执行。

幂等性应限定在调用者和端点范围内，保存请求正文的摘要，并在同一幂等键被不同正文复用时拒绝请求。否则，有缺陷的客户端可能通过复用标识符，把新的意图附加到早先的请求上。

## 回滚可能保留有问题的依赖状态

应用回滚和环境恢复是两个不同的操作。一个负责部署旧代码的端点，无法自动让所有依赖再次兼容。

我见过一种很典型的故障：发布 `rel_105` 引入了写入新枚举值的代码。之后的迁移收紧了数据库约束，只允许这些新值。该发布因无关原因失败，操作员恢复了 `rel_103`。旧代码写入之前的值，数据库拒绝了它，事故因此扩大，因为部署面板看起来显示回滚已经完成。

端点没有造成架构变更，但它的成功响应作出了错误声明。应要求发布元数据以具体方式描述兼容性，避免这种误导。至少要记录恢复发布能否读取当前数据、写入当前数据，以及能否与目标的配置版本一起运行。

流量管理也有自己的陷阱。金丝雀回滚通常只应改变该金丝雀拥有的流量分配。如果另一个发布调整了稳定流量池，或另一个控制器改变了路由规则，那么回滚端点若写入整份路由文档，就可能抹掉这些变更。应使用资源级版本，或者只修改部署预留的分配字段。

基础设施也遵循同一原则。如果某个发布创建了队列、存储桶、角色或防火墙规则，而后续工作已经采用了这些资源，那么在撤销过程中删除它们可能会伤害另一个服务。清理前需要有资源所有权记录，并确认没有后续部署认领该资源。如果无法确认所有权，就保留资源并创建修复任务。

对于不可逆操作，应选择正向修复。代理可以关闭功能开关、转移流量，或部署纠正性发布。操作员不喜欢这个答案，因为“回滚”听起来更快，但一次看似干净、实际破坏了后续数据的撤销，耗时往往超过制定修复计划。

## 代理需要狭窄的权限和明确的停止点

自主代理应获得完成指定部署所需的最小操作权限。它不需要原始云凭据、拥有生产访问权限的通用 shell，也不需要一个接受任意发布 ID 的端点。

代理开始发布时，为它分配一个部署句柄。该句柄可以授权状态读取、健康检查、部署分配范围内的流量变更，以及一次明确指向原始部署的回滚。当部署进入终止状态，或人工撤销运行时，使句柄失效。部署服务仍必须在服务器端执行所有权检查，因为句柄可能被复制，客户端也可能发生故障。

人工审批应发生在不可逆边界之前，而不是代理已经准备好不可逆命令之后。合理的策略是在代理开始生产部署时请求审批，然后只要所有权前置条件仍然成立，就允许它撤销这次确切的部署。如果代理遇到后续发布，那么它对任何干预都需要新的审批。这是一个适合放慢速度的节点，因为有人已经改变了环境。

Sallyport 可以让 HTTP 和 SSH 凭据留在代理进程之外，同时由人员审批代理运行，或将凭据设置为每次使用都需要审批。这种控制有助于保护操作路径，但回滚服务仍需执行自身的发布和代数检查。凭据由谁保管，不能定义部署所有权。

避免使用 `production:rollback:any` 这样的能力名称。它会引导调用者在运行时选择范围。更好的做法是使用由服务器签发、绑定到 `dep_842`、`production` 环境和具体回滚路由的能力。如果代理请求无关目标，授权层应在部署控制器评估请求之前拒绝它。

记录每次请求对应的代理进程或工作负载身份。人工人员应能回答：谁发起了 `dep_842`，调用者由什么代码签名或身份验证，哪项审批覆盖了该操作，以及是否有人在执行结束前撤销了访问权限。匿名自动化账户会让每次事故都变成考古工作。

## 验证必须测试恢复后的发布，而不是测试请求

只有在目标运行预期发布，并且服务验证了触发恢复的条件后，回滚才算完成。HTTP 202、命令成功退出，或控制器发出“已应用”事件，都不能证明旧发布正确地为流量提供服务。

应根据部署实际的故障模式定义验证方式。如果延迟或错误触发了回滚，就在流量切换到恢复服务后，通过同一测量路径观察它。如果工作进程发布消耗了格式错误的任务，就验证工作进程版本和受控工作负载。如果配置错误导致启动失败，就检查已就绪实例以及它们加载的配置版本。

设置有界的观察窗口，并记录结果。端点可以先报告 `verifying`，之后报告 `succeeded`、`failed` 或 `needs_operator`。不要无限等待可能不可用的指标。达到时间限制后，应生成明确的“无法判断”结果，并交由操作员决定。

审计事件应连接每个阶段：请求撤销的告警或规则、原始部署、预期状态、条件预留、执行事件、健康证据、最终状态以及任何撤销操作。事件需要排序和完整性控制，因为一条看起来友好的部署时间线，在发生争议时远远不够。

Sallyport 会通过加密哈希链审计日志记录代理运行和单独的操作，`sp audit verify` 无需保险库密钥即可在离线环境中检查这条链。可以使用这类证据证明代理请求过某项操作，同时保留部署服务自身的状态转换和验证记录，作为实际变更内容的权威记录。

## 熟悉的回滚命令需要更安全的包装层

对于直接操作 Kubernetes Deployment 的人员来说，`kubectl rollout undo` 很有用，但它不是自主恢复的完整契约。Kubernetes 文档说明，除非调用者提供 `--to-revision`，否则 `kubectl rollout undo` 会回滚到之前的部署版本。这种默认行为适合人工排查，却不能证明之前的版本属于发生故障的代理运行。

Kubernetes Deployment 会在 ReplicaSet 中跟踪版本历史，而 `revisionHistoryLimit` 控制 Kubernetes 保留多少历史。那段历史是控制器的产物，不是你关于发布批准基线、配置兼容性或代理所有权的业务记录。历史被清理后，“撤销”也可能找不到外部部署流程所期待的版本。

不要给代理一份通用的 `kubectl` 凭据，然后把这个命令称为回滚端点。应在代理和集群之间放置控制器或部署服务。服务应解析原始部署记录，比较目标的实时代数，预留状态变更，并且只有通过这些检查后才调用底层平台。

对于声称“部署版本 X”的云厂商命令，或声称“重新运行之前发布”的 CI 控件，也有同样的问题。它们操作的是平台资源。除非你的控制平面提供上下文，否则它们不知道某个发布是否与代理当前的事故有关。

平台命令本身也应保持狭窄。如果服务只需修改指定工作负载的版本，就不要授予它集群范围的修改权限。在保留广泛凭据的情况下，包装层只是把危险藏到了另一个 API 后面。

## 在自动化恢复前建立发布台账

无需替换所有部署系统，也可以引入受保护的回滚。先让发布台账成为一个生产目标的权威记录，然后要求人工和代理路径都通过同一个条件转换。

实际的落地步骤可以分为五部分：

1. 为发布和部署尝试分配不可变 ID，并在推进时记录之前批准的发布和目标代数。
2. 增加一个端点，要求提供 `origin_deployment_id`、预期发布、预期代数和幂等键。
3. 在数据库或控制存储中让目标更新遵循条件，并对每次不匹配都返回 409。
4. 在推进前，分别评估每个发布对应用、配置、数据和流量的可回滚性。
5. 要求控制器在将回滚标记为完成前提供验证证据。

在允许代理执行之前，先以报告模式运行这套契约。让服务计算它会恢复什么，以及它是否会拒绝请求。将这些决策与实际事故处理进行比较。这样可以在不给自动化覆盖生产环境机会的情况下，发现缺失的来源记录和隐藏的人工路径。

然后让拒绝变得平常。过时的回滚应创建一条易于理解的事故事项，其中包含实际观测到的发布和代数，而不是产生一个神秘的失败，诱使人绕过端点。端点只有在始终如一地拒绝不安全请求时才会赢得信任，包括拒绝构建它的人发出的请求。

首先要添加的字段不是 `force`，而是 `expected_target_generation`。一旦部署携带这个事实，并保存原始基线，代理就能在清晰的边界内撤销自己的发布。在此之前，自主回滚只是把旧的部署命令指向一个不断变化的目标。
