跳到主要内容
← 返回全部文章

工程博客

“已部署”不是健康检查

一次部署需要证明 artifact、进程、服务和公网路由都属于目标版本。

Appaloft
Deployment controlRuntime verificationDockerAI

披露:本文由 Appaloft 团队撰写,介绍的是我们自己的实现与审计结果。

部署流程可以顺利结束,用户拿到的却仍是错误版本。任务是绿色,进程还活着,健康检查也返回 HTTP 200,但旧容器或陈旧代理目标仍在对外提供服务。

最近几份公开报告把这些边界呈现得很清楚:7 月 10 日的 Dokploy Swarm 报告称,固定镜像只修改配置后,界面显示 “Deployed”,task 却没有重建;7 月 12 日的 Dokploy 报告称 Compose 容器始终健康,但 Traefik 选中了陈旧或错误的网络地址并返回 502;7 月 11 日的 Coolify 报告称自托管生产服务达到重启上限后停止;7 月 15 日的 Dokploy 报告称平台自身更新后在多个 readiness 状态间循环,用户看到的是 Bad Gateway。

这些是用户报告,不是我们独立完成的根因审计,而且分属不同产品与层级。我们没有把它们当成竞品比较材料,而是把它们变成了 Appaloft 的审计清单。

“已部署”至少要证明四件事

  1. Artifact 已应用。 目标镜像、revision 或配置进入了新的 workload generation。
  2. 进程持续存活。 进程不会刚启动就进入重启循环,或达到上限后停止。
  3. 服务健康。 当前 workload 通过内部健康策略。
  4. 路由可达且属于当前部署。 真实域名、TLS 和代理路径服务的是这个 deployment,而不只是“某个能返回 200 的服务”。

第四件事不能从容器 label 推断。容器可以正确标识自己,edge proxy 却仍然指向旧 workload。

我们在 Appaloft 审计里发现了什么

配置变更未生效的 Swarm 问题,在 Appaloft 没有出现相同的 no-op 路径。Appaloft 会创建带独立 deployment identity 与安全配置 fingerprint 的候选 stack/service,验证后再提升路由。Deployment Proof 会把实际 generation 和 fingerprint 与已接受的计划对照,因此健康的旧 task 会得到 stale,而不是 verified

已经停止或不健康的 workload 原本就会产生 failed health evidence,覆盖了 restart-limit 报告里的 workload 侧症状。平台自身更新后控制面起不来,则是另一个 platform-readiness 边界;workload Deployment Proof 不宣称解决它。

路由问题确实暴露了 Appaloft 的一个缺陷。原先 Docker readback 会把当前容器的 appaloft.deployment-id label 当作“路由指向这个 deployment”的证据。它其实只是 workload identity,不是 route evidence。Traefik 或 Caddy 如果仍指向旧目标,这个结论就是错的。

我们修改了契约:

  • Appaloft 管理的 Caddy 与 Traefik serve route 会在响应中写入 X-Appaloft-Deployment-Id
  • deployments.proof 会通过真实 managed domain 请求配置的公网 health path;
  • 即便 HTTP 状态是 200,只要 deployment identity 缺失或不一致,access evidence 就失败;
  • 只做跳转的 alias 不会冒充 workload identity。

这个 header 只包含不透明的 deployment id,不包含配置或 secret。配置证据仍然只使用 SHA-256 fingerprint,原始值不会进入 proof response。

一个查询,五种结论

Deployment Proof 和其他 Appaloft 能力共用 operation catalog:

appaloft deployments proof <deploymentId>

HTTP 入口是 GET /api/deployments/{deploymentId}/proof。Generated SDK 和 MCP tool 暴露同一个 deployments.proof operation,Web Console 的 deployment detail 页面也渲染同一份 DTO。

这五个 verdict 刻意把“缺证据”和“已验证”分开:

  • verified:artifact、workload、配置、health、access 和 recovery 等必要证据与部署意图一致;
  • partially-verified:已有证据能对上,但某项必要 readback 暂时拿不到;
  • unverified:runtime 证据不足,无法作出判断;
  • stale:runtime 可能仍然健康,但已经不是这次 deployment 对应的 workload;
  • failed:观测结果和部署目标直接冲突,例如 health 失败,或 access route 指向了错误 workload。

“读不到 artifact digest”和“读到了另一个 digest”不是同一种故障,处理方式也不该一样。

测试专门覆盖那些“不舒服”的场景

已有的真实 Docker smoke 会先部署 v1,通过正式 Resource 配置 operation 修改 APP_VERSION,重新部署 v2,再从外部把当前容器替换成一个仍然健康、但携带旧 deployment/config label 的 v1 容器。Health 依旧通过,proof 仍会把 workload 判为 stale。

我们又增加了一个使用真实 Traefik 容器的 smoke。路由返回 HTTP 200,并标记自己服务的是 dep_v1;proof 核验的是 dep_v2。结果明确失败:

{
  "httpStatus": 200,
  "expectedDeployment": "dep_v2",
  "observedDeployment": "dep_v1",
  "accessStatus": "failed",
  "reasonCode": "public_route_deployment_identity_mismatch"
}

Provider tests 还会确认 Caddy 与 Traefik 只给 serve route 加标记;adapter tests 则覆盖 identity 一致、缺失、不一致、不可访问和非 2xx 响应。

证据从哪里来

Local Docker 和 Docker Compose adapter 会按 Resource label 找到容器,然后读取 image identity、workload label、启动时间、health 和脱敏后的 configuration fingerprint。Docker Swarm 则读取对应 service identity 与 update generation。Managed public-route evidence 现在独立来自代理响应,不再来自容器 inspect。

Application layer 不知道 Docker command。它只向 runtime evidence port 请求标准化后的观测数据,再把这些数据与 deployment state 对照。Verdict 逻辑因此可以独立测试,其他 runtime adapter 以后也能提供同等证据,不必改 query contract。

Generic SSH 暂时没有足够 readback 能力时,会明确返回 evidence gap。Direct-port workload 也不会冒充自己完成了 managed-proxy identity check。我们宁愿把边界写出来,也不愿从 execution record 猜一个成功。

AI 和 GitHub check 也得遵守这条规则

Appaloft skill 现在要求 agent 在观察完 deployment progress 后读取 deployments.proof。Agent 可以描述 terminal state,但只有 verdict 为 verified 时,才能说部署成功。

Cloud 的 GitHub check summary 也用同一映射。绿色 workflow 是一份输入证据,不是最终结论。Control plane 仍需核对“请求部署什么”和“runtime 正在跑什么”。

CLI、Web Console、MCP client 和自动化 check 因此得到的是同一个答案,不再各自解释一遍状态。

命令和 verdict 的完整说明见部署生命周期文档。如果想继续了解 provenance,可以读一次 GitHub Actions 部署应该留下哪些证据

后续 runtime adapter 想拿到 verified,也得交出自己的 readback 证据。暂不支持的部分会继续显示为缺口,不会藏在一个绿色状态后面。