跨洲镜像同步从 37 分钟到 3 分钟

一个 cp 参数引发的层不可复现

Posted by walikrence on September 8, 2026

背景脱敏:一个 Go 微服务项目,国内 CI 构建镜像,需要同步到海外集群的镜像仓库。约 20 个服务,每批次发布同步耗时 30~37 分钟。本文记录怎么把它压到 3 分钟,以及为什么根因是一个 cp 参数。

现象

跨洲同步用的是 skopeo copy。它的机制很简单:按层(layer)的 blob digest 判断目标仓库有没有这一层,有就跳过,没有才传。理论上一批次只改了两三个服务,其余十几个服务的层应该全部命中跳过。

实际是每次全量重传约 850MB,跨洲上行带宽实测只有 0.03MB/s 左右,所以 30 多分钟。

第一反应是「带宽问题」,于是去测带宽、试压缩、考虑换传输方向。但先停下来问了一句:层复用到底有没有发生过?

排查

拿同一个 commit 连续构建两次,对比每个服务镜像的层 digest。结果:没有一个层的 digest 相同。二进制内容逐字节一样,层 digest 却不同。

层 digest 是对层 tar 包的哈希。tar 包里除了文件内容,还有每个文件的头:路径、权限、mtime、uid/gid。任何一项变了,digest 就变。

两处泄漏时间戳的地方:

  1. 打包上下文的 cp 没带 -p。构建脚本先把编译产物 cp 到一个临时上下文目录再 docker build,不带 -p 就用当前时间做 mtime。每次构建 mtime 不同,tar 头不同,digest 不同。
  2. go build 没加 -trimpath。多台构建机轮转,工作目录路径不一样,编译进二进制的路径字符串就不一样,二进制本身都不同了。

第二点更隐蔽:单机上复现不出来,因为路径相同;只有在多机 runner 轮转时才出现,表现为「有时候能复用有时候不能」,很容易被当成网络抖动。

修复

三处改动:

# 1. 编译去掉路径
go build -trimpath ...

# 2. 上下文文件 mtime 归零
find "$ctx" -exec touch -d @0 {} +

# 3. 让镜像元数据也不带时间
export SOURCE_DATE_EPOCH=0

修完用同一 commit 连发两批做对照:同步时间 2254 秒 → 175 秒,17 个未改动服务的层全部 0~17 秒跳过。

同步成本从此有了公式:≈ 175s + 改动服务数 × 400s

门禁

修完之后最怕的是被人不知情改回去。加了一条 CI 门禁:用固定内容的假二进制跑两遍打包,中间刻意 touch 一次改 mtime,两次层 digest 不一致就判红。只在打包脚本变更时触发,跑在冷缓存下(DOCKER_BUILD_NO_CACHE=1),避免 build cache 掩盖问题。

后续:方向反转

层可复现修好后,剩下的 175 秒基线仍然卡在国内 → 海外的上行带宽。实测发现反过来从海外集群拉国内仓库有 4~5MB/s,快 130 倍。于是把同步做成海外侧管理平台的一个按钮:进程内直连两端 registry 复制,不需要 runner,不需要 K8s Job,不需要 clone 代码仓。端到端一批次 16 个服务 208 秒,含 3.5 分钟的构建。

这一步有个小坑:候选批次列表读的是目标仓库(已经拉过的),而要拉的恰恰是源仓库有、目标仓库没有的那些。要用两个仓库 tag 列表的差集,不能逐个探测。

教训

  • 先验证机制是否在工作,再优化机制的参数。「带宽慢」是真的,但如果层复用从未发生,带宽再快也是全量传。
  • 可复现构建不只是安全议题。mtime、路径、时间戳这些「无害」的元数据,会让所有基于内容寻址的缓存(Docker 层、Nix、Bazel)失效。
  • 多机 runner 下的间歇性问题先怀疑路径-trimpath 应该是 Go 服务构建的默认项。
  • 修完加门禁。这类修复没有功能表现,不加门禁三个月后一定被改回去。