背景脱敏:一个 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 就变。
两处泄漏时间戳的地方:
- 打包上下文的
cp没带-p。构建脚本先把编译产物cp到一个临时上下文目录再docker build,不带-p就用当前时间做 mtime。每次构建 mtime 不同,tar 头不同,digest 不同。 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 服务构建的默认项。 - 修完加门禁。这类修复没有功能表现,不加门禁三个月后一定被改回去。