2025/8/26 zbpack 重新部署时行为异常的事件报告

PanPan

近期 Zeabur 因受到 zbpack v1 既有基础设施淘汰的影响,正在向所有用户推送新一代构建系统(zbpack v2)的更新。升级过程中已知造成了一些兼容性的问题,这里会说明问题的缘由、处理方式,以及如果你遇到这个问题时,可以如何缓解这个问题。

为什么会全量推送新一代的构建系统?

Zeabur 背后的编译基础设施是这样运作的:

zbpack structure

“registry v1”是使用 distribution/distribution 的 registry 构建的,由于其造成的种种问题,促使我们迁移到自行设计的 registry v2 方案:

  • 早期采用跟随编译机器的机制(也就是一台编译机器对应一个 registry)。不过编译机器都比较短期,且没有一个较好的方式限制“某个编译机器的容器,必须只连接属于自己机器的 registry”,因此偶尔会遇到“registry 比编译容器先结束”的问题,导致编译失败(同时重试机制也没有太大效果)
  • 后期我们将 registry 迁移到较长期的机器上,让编译机器中的容器,自行连接到经过负载均衡后的 registry。不过在写入 image manifest 的时候,负责上传 blob 的 registry 可能实际上还没有完成上传,或者是背后的 Cloudflare R2 在更新上有延时,导致 registry 报出 blob unknown 的错误并拒绝上传 manifest,进而导致启动时无法拉取镜像
  • 此外,distribution/distribution 倾向做全局 blob 去重,导致我们无法在不读取所有 manifest 的情况下,了解哪些 blobs 是用不到的。官方提供的垃圾清理(garbage collection)工具要求停机运行(stop-the-world),在 Zeabur 如此大的 registry 规模下无法运作,导致 R2 累积了非常大量的 blobs,并因此造成相当严重的性能问题
  • 另外也有众多用户反馈经由 registry v1 上传的速度非常缓慢

我们将 registry v2 设计成“构建 OCI image”后“直接推送到 R2 bucket”,接着再使用 Cloudflare Workers,编写一个可以将 bucket 中 OCI image 结构转换成符合 OCI Distribution Specification 中 Pull API 的只读 API。这样一来,除了性能可以获得极大提升、可以最大化 multipart 上传的效率,也可以避免因 registry 造成的种种问题。与此同时,我们将 blob 的去重限定在仓库内部,因此可以做到“删除一个仓库就能回收其对应的 blobs”,极大简化内部维护且无需 stop-the-world。

不过从上面的描述中,也能看到 registry v2 的推送流程有着相当大的变化。这部分已经在 zbpack v2 中得到实现,但 zbpack v1 由于其复杂的推送流程,以及高度依赖 buildkit CLI 进行 image 的构建,导致实现这部分较为困难。因此近一个月,我们让属于 zbpack v1 的项目继续使用 zbpack v1(registry v1)编译,仅当用户反馈 zbpack v1 无法启动时,才手动切换到 zbpack v2。

但当 registry v1 越来越不堪重负,出错频率节节攀升,导致类似工单越来越多时,我们不得不考虑将 zbpack v1 负责 image 的部分,转换到 zbpack v2 上。

为什么新的构建系统会有这么多问题?

细心的开发者应该注意到 zbpack (v1) 的 GitHub Repo 从 Archived 转回维护状态,并提交了很多与 Dockerfile 相关的更新。实际上,这部分就是在为 zbpack v1 转接到 zbpack v2 的兼容层做准备。

我们希望维持 zbpack v1 既有的 Dockerfile 生成功能,但在生成 Dockerfile 后不使用 zbpack v1 内置的构建逻辑,而是切换到 zbpack v2。因此,我们将 v1 现有的 Dockerfile 生成功能改为公开函数,并让构建服务使用这个函数产出 Dockerfile,再传递到运行 zbpack v2 的编译机器上。

zbpack migration plan

不过 zbpack v1 毕竟最初是设计在编译机器上运作的,有诸多部分需要进行实现或转接:

  • zbpack v1 依赖很多环境变量,而我们不可能去改变构建服务的环境变量
  • zbpack v1 最初被设计为一次性运行的 CLI,直接接到构建服务可能会触发 zbpack 内部的 panic 逻辑
  • zbpack v1 背后的编译机器也传递了很多特化参数给 zbpack v1,我们需要在兼容层中一个一个实现

因此,我们实现了 zbpack v1 兼容层,移植 zbpack v1 编译机器对 zbpack v1 调用的处理。绝大部分显而易见的问题(如代码读取),我们在全量推送前都已经解决,并在测试机器上进行内部测试时,没有遇到判断失误的状况,同时也有派驻 on-call 工程师持续关注推送带来的影响。然而在实际推送到所有机器上时,却发现了诸多新兼容层没有考虑到的问题。举例来说:

  • zbpack 需要的环境变量没有正确传递给 zbpack,导致以 ZBPACK_ 开头的变量失效
  • 项目的“根目录”没有正确传递给 zbpack v1,导致它一直从根目录的内容进行判断
  • 兼容层使用的 zbpack v1 版本在 Dockerfile 逻辑上有大改动,而测试没有正确覆盖,导致 ZBPACK_DOCKERFILE_NAME 行为与先前不一致
  • 很多用户认为 plan type 和 plan meta 的呈现很重要,但兼容层没有如以往般在构建日志中正确显示
  • 从中国大陆访问 registry v2 非常缓慢

考虑到部分情境没有其对应的测试环境,以及评估回退版本造成的影响可能更大,我们在 on-call 时遇到这些问题,决定将错就错,快速运行“修正”“测试”“推送”的流程,同时也向客户提供 workaround 以缓解问题。我们在 8/26 - 8/27 这段期间很快地实现完整的 zbpack 兼容层、实现了 plan type 和 plan meta 的展示,并为 registry v2 设计了中国大陆的 CDN 来加速下载。

our new ui and infrastructure

非常感谢所有反馈这些问题的客户,让我们发现兼容层没有涵盖到的边缘情况,并促使我们研究并修正。

未来这个问题是否还会发生?

上文提出的问题,兼容层均已实现或修正。如果有其他不完整的部分,也请提工单让我们的工程师处理。

目前已知的问题如下:

  • 新部署的端口可能会被改回默认的 8080,目前仍在调查原因。可以先手动设置 PORT=对外端口(如 PORT=8080)来缓解这个问题
  • 如果您使用 Dockerfile 部署,而 Dockerfile 没有被正确读取,可以到“设置” > “Dockerfile”手动填入您想使用的 Dockerfile 内容。不过考虑到这个问题应当已经修正,也希望您可以提交工单让我们调查原因

概观本次事故,内核原因有几个:

  • 内部测试使用的测试用例太少,缺少根目录、Dockerfile 名称相关的测试,误以为“兼容层已经涵盖一切环境”,并将其视为小改动进行全量推送
  • 任何涉及行为改变的功能(如“zbpack v1 兼容层”),都应当实现并遵循类似于 zbpack v2 的 feature flag 选择性开启机制,而非全量推送
  • 缺少对中国大陆地区网络情况的认知,误将国外的网络情况套用于大陆网络上,而没有提前实现 CDN 机制
  • 应当针对小范围客户进行测试,并提前收集相关反馈。

如果您是此次受影响的客户,我们可以提供按影响时长折算的 credit 进行补偿。未来我们在推送这部分功能时会更加谨慎,也非常感谢所有发现兼容层问题的客户。