跳到正文

Blog

回滚按钮不是恢复方案

单机 Docker Compose 更新要守住五条约束:镜像预检、候选版本验证、证据保留、定向清理,以及明确的数据恢复边界。

AppaloftDocker ComposeDeploymentRollback自托管

单机 Docker Compose 更新可以只有一行:

docker compose pull && docker compose up -d

这行命令很好用,但它不是恢复方案。

恢复方案需要回答更麻烦的问题:正在运行的服务发生变化以前,新镜像是否已经可用?多 service 的 Compose 文件里,谁才是应用入口?真实请求通过了吗,还是只有 compose up 返回了 0?上一个经过验证的版本还能不能被准确定位?如果更新失败,平台能自动恢复什么,数据库、volume、DNS 和外部 provider 又该由谁处理?

我们在做 Appaloft,一个开源部署控制面。梳理这些问题时,我们发现自己的 Compose runtime 也有缺口。Appaloft 已经会保留成功部署的 snapshot,也能从它创建 rollback attempt;但 local 和 SSH Compose adapter 会把 compose up 成功直接记成部署成功,普通更新也不会显式预拉取所有 image-backed services。我们先补了这两条边界,再写这篇文章。

这里实现的不是“所有东西都能自动恢复”,而是一份范围更小、可以测试的单机应用更新契约。

这些信号很普通,所以更值得看

7 月 9 日的一条 ALM System 部署需求把期望写得很完整:数据放在 image 之外,更新前备份 SQLite,执行启动和 migration,检查 health,失败时恢复旧 image,并保留失败详情。这显然不只是一个回滚按钮。

7 月 11 日,一位用户记录了更新 Pi-hole 时遇到的 DNS 中断。被更新的服务本身就在主机的 DNS 路径里;如果先停掉它,再去拉 replacement image,拉取动作可能恰好依赖刚刚被移除的服务。帖子里对具体 Compose 行为有不同解释,但操作层面的结论很稳定:拿到依赖的 artifact 以前,不要先拆掉获取它所需要的依赖。

7 月 13 日,一份 rss.chat 自托管部署报告提到,作者没有只用浏览器验收,而是像脚本和其他应用一样直接调用 API。这个路径发现了一个浏览器流程没有触发的进程级崩溃。container 正在运行,不代表操作者真正关心的请求路径可用。

约束一:replacement 开始以前,先拿到 candidate

对于使用 registry image 的 Compose service,pull 应该是一项 preflight。拉取失败时,候选 project 不应该启动,当前运行版本也不应该被碰到。

Appaloft 现在按这个顺序生成 Compose 命令:

docker compose pull --ignore-buildable &&
docker compose build --pull &&
docker compose up -d --build

--ignore-buildable 用来处理 registry image 与本地 build service 混合的 Compose 文件。Docker 的 docker compose pull 文档说明了这项行为。

这条链路依赖受支持的 docker compose v2/v5 CLI。如果目标机只有旧的 docker-compose v1,Appaloft 会在候选版本发生 mutation 以前停止。Docker 已经把 Compose v1 列入退役产品,静默 fallback 会削弱 preflight 契约。

预拉取不等于原子更新。后续 build 仍然可能失败,固定 host port 也可能让两个版本无法同时存在。这条约束只承诺一件事:不要等到 replacement 已经开始,才发现新 image 根本拿不到。

约束二:image 和可变数据属于不同的恢复域

真正有价值的状态可能在数据库、volume、bind mount、secret reference、上传文件、DNS、TLS、queue、object storage 和其他外部依赖里。

把应用 image 换回旧版本,不会逆转 schema migration,不会恢复被删掉的数据,不会把 volume 倒回过去,也不会撤销 DNS record 或外部 API 调用。如果 migration 不向后兼容,保留旧 image 还不够。操作者需要测试过的 backup/restore 路径,或者采用 forward-compatible migration。

Appaloft 把应用 rollback 和 storage backup/restore 建模成不同操作。应用 rollback 会从保留的成功 snapshot 创建一次新的 deployment attempt,但不会声称自动回滚数据库、volume、DNS、TLS 或外部依赖。

约束三:compose up 只是 apply 证据

docker compose up -d 返回 0,只能说明 Compose 接受并执行了请求。它不能证明目标 service 确实存在、container 没有马上退出、原生 health check 已通过、应用在配置的 port 上响应,或者公网 route 真的到达这个 service。

Appaloft 现在用 deployment ownership labels 定位候选 container。对于有公网 route 的多 service stack,还必须显式配置 target service。Verify 阶段会检查 container state、Docker health、配置过的 target-service HTTP policy,以及需要公网验证时经过 edge proxy 的 route。

面对 webworkerdbredis,猜谁应该接收 proxy labels 不是便利功能,而是一次含糊的 mutation。只有 Compose model 里恰好只有一个 service 时,Appaloft 才允许推断。

Route check 和证书 ready 也不是一回事。本地代理路径验证可以证明 Host rule 能到达候选应用,但不能证明公网 DNS 已经完成传播,也不能证明 ACME provider 已签发受信任证书。

约束四:验证通过以前,保留旧目标和证据

可恢复更新不能只留下一个旧 tag。它需要准确的 previous target,还要保留 source revision、image digest、脱敏配置、target identity、health 与 route observation、失败阶段、error kind 和时间。

Appaloft 会把 candidate 记录为新的 deployment attempt。验证失败时,cleanup 按失败 deployment identity 选择 container,不执行宽泛的 project 或 host prune。只有 candidate 通过要求的验证链以后,上一个成功 runtime 才会被释放。

这里仍然有边界。候选 container 可以按 identity 移除,但用户自定义的 Compose resource 和外部副作用可能需要自己的 cleanup rule。“定向清理”不等于“主机每一个字节都恢复原样”。

约束五:rollback 也要走同一套 verify

只改 image reference 的 rollback,可能用旧版本重新制造同一种坏状态。恢复目标仍然需要启动、通过原生 health、响应配置的请求,并满足普通部署要求的 route check。

在 Appaloft 里,rollback 会从保留的 successful deployment snapshot 创建一次新 attempt,然后交给普通 execution backend。因此 Compose rollback 和向前部署使用同一条 image preflight 与 candidate verification 链。“已经请求回滚”不等于“服务已经恢复”。只有恢复后的应用留下了新的验证证据,recovery 才算完成。

我们在 Appaloft 里补了什么

公开实现位于 appaloft/appaloft#682,包括:

  • Compose build 和 up 以前的 preflight pull;
  • 对旧 Compose v1 target fail safe;
  • 候选 container 与 target-service container 观测;
  • native health、internal HTTP 和 public route 验证;
  • 只把 proxy labels 与 edge network 注入 target service;
  • 按 deployment identity 清理失败候选版本;
  • rollback attempt 复用同一条 execution path;
  • lifecycle 文档和 Test Matrix 覆盖。

我们用定向测试、完整 adapter suite 和真实本地 Docker smoke 验证了 Dockerfile、Compose 与 prebuilt image 三种路径。过程中发现了三个问题:本地 fixture image 被 pull policy 误当成 registry image,Docker inspect template 假设每个 container 都有 Health 字段,以及环境里的 HTTP proxy 干扰本地 route check。验证代码本身也有失败模式。

下次更新 Compose stack 以前

先把下面五个答案写下来:

  1. 当前版本变化以前,所有可拉取 image 是否已经可用?
  2. 谁是请求目标 service,哪个真实请求能证明它工作?
  3. 上一个 image 或 artifact 是否有 immutable identity?
  4. 哪些 database、volume、secret、DNS、TLS 和外部副作用不属于应用 rollback?
  5. 什么证据可以区分“已请求回滚”和“已验证恢复”?

如果某一项的答案只是“按钮会处理”,恢复方案还没有写完。

Appaloft 的部署生命周期文档说明了 plan、execute、verify 和 recovery 的边界。更完整的失败状态设计可以继续读部署失败以后,平台应该留下什么