承接《跨洲镜像同步从 37 分钟到 3 分钟——层不可复现的根因》。那次靠「归零文件 mtime +
-trimpath」让镜像层可复现,跨洲同步提速十倍。尝到甜头后,我把同一套手法用到另一个服务上——失败了。本文记录为什么失败,以及为什么查清之后决定不继续。
两个场景的差别
上一篇成功的那个项目,打包方式是「先编译好二进制,再用一个极简 Dockerfile 打包」:
FROM runtime-base:xxx
COPY --chmod=0755 app/ . # 只有一条 COPY,源是构建上下文
ENTRYPOINT ["/app/app"]
这次要改的是另一个服务,用的是多阶段构建:
FROM golang:alpine AS builder
WORKDIR /build
COPY . .
RUN go build -o server .
FROM alpine-runtime:xxx
WORKDIR /go/src/xxx/server # ← 记住这一行
COPY --from=builder /build/server ./
COPY --from=builder /build/resource ./resource/
COPY --from=builder /build/config.yaml ./
COPY --from=builder /build/config.docker.yaml ./
看起来只是写法不同,实际上差别是本质的。
先确认问题存在
对比线上相邻两次构建的层指纹:
层0-2 == == == ← base 镜像,复用正常
层3 != ← Go 二进制(代码确实改了,本该不同)
层4 != ← resource/ 目录
层5 != ← config.yaml
层6 != ← config.docker.yaml
层 4/5/6 是关键证据:这两次构建之间,resource/ 和两个配置文件一个字节都没改过,层指纹却全变了。典型的 mtime 污染。
第一堵墙:COPY 会刷新目标目录自己的时间戳
照搬上次的做法,在 builder 阶段把产物 mtime 全部归零:
RUN go build -trimpath -ldflags="-s -w" -o server .
RUN find /build/server /build/resource /build/config.yaml /build/config.docker.yaml \
-exec touch -h -d @0 {} +
构建两次,结果 3/7 层一致——没有任何改善。
进容器验证,文件本身完全正常:
镜像1: server mtime=0 size=76148888 sha256=e081d955...
镜像2: server mtime=0 size=76148888 sha256=e081d955...
内容一样、mtime 都是 0,层指纹却不同。差异只能在别的元数据上。于是把两个镜像 docker save 出来,逐层解包比对 tar 清单:
镜像1: drwxr-xr-x .../gin-vue-admin/server/ 2026-09-04 19:08
drwxr-xr-x .../gin-vue-admin/server/resource/ 1970-01-01 07:30
镜像2: drwxr-xr-x .../gin-vue-admin/server/ 2026-09-04 19:09
drwxr-xr-x .../gin-vue-admin/server/resource/ 1970-01-01 07:30
差异只有一行:resource/ 是 1970(我 touch 的结果生效了),但它的父目录 —— 也就是 WORKDIR 那个目录 —— 带着构建当时的时间,两次差了一分钟。
原因是 COPY 往目标目录写文件时,会把目标目录自身的 mtime 更新为当下。我归零的是 builder 里的源文件,管不到目标端新建的目录项。而这个目录项就打包在 COPY 层里。
上一篇之所以没撞上,是因为那个 Dockerfile 的目标目录 /app 早就存在于 base 镜像里,COPY 不需要新建目录。
第二堵墙:镜像层是不可变的
既然是目标目录的问题,那在 COPY 之后补一刀不就行了:
COPY --from=builder /build/server ./
...
RUN touch -h -d @0 . resource # ← 补救
再构建两次:4/8 层一致。新增的那个 touch 层自己倒是一致了,前面四个 COPY 层照旧漂移。
这一步是我犯的思维错误:Docker 层是只读快照,写完就固化了。后面的 RUN 只是在新层里覆盖一份新的目录项,前面层里的旧记录原封不动,而层指纹算的正是每一层自己的内容。
想改 COPY 层,只能在生成它的时候就是对的,事后无法补救。
第三堵墙:SOURCE_DATE_EPOCH 在这里不生效
BuildKit 支持 SOURCE_DATE_EPOCH,本意就是把构建产物的时间戳统一 clamp 掉——正好是在「生成时」就解决问题。试了两种调用方式:
SOURCE_DATE_EPOCH=0 DOCKER_BUILDKIT=1 docker build --no-cache ... # 4/8
SOURCE_DATE_EPOCH=0 docker buildx build --no-cache --load ... # 4/8
(环境:Docker 29.1.3,buildx v0.19.3)
两种都是 4/8,业务四层依旧漂移。可能与 exporter 类型有关,我没有继续深挖——因为到这一步,我停下来重新算了一笔账。
停下来:这件事本来能省多少
一直在追「让层可复现」,但可复现之后到底省多少?
这个服务的镜像结构是:
| 层 | 内容 | 大小 |
|---|---|---|
| 3 | Go 二进制 | 37.3 MB |
| 4 | resource/ | 0.8 MB |
| 5、6 | 两个配置文件 | 几 KB |
每次发版真正变化的是那个 37.3MB 的二进制——代码确实改了,这一层本来就该传,可复现救不了它。层复用能省下的,只有 resource/ 加配置文件,不到 1MB。
而上一篇那个场景:一批 19 个服务,通常只改动 2~3 个,其余十几个服务的层完全没变——省下的是 800MB。
同样的技术手段,两个场景的收益差三个数量级。前者值得为它翻三堵墙,后者不值得。
但有一半是白捡的
排查过程中顺手发现,这个 Dockerfile 的 go build 既没有 -trimpath 也没有 -ldflags="-s -w"(同组织另一个项目一直都有,这里是漏了)。补上之后:
| 改前 | 改后 | |
|---|---|---|
| 二进制 | 96,334,126 字节 | 76,148,888 字节(省 21%) |
| 镜像 | 180 MB | 145 MB |
这部分收益和层复用毫无关系,独立成立,跨洲传输量同比例减少。所以最后提交的改动只保留了这一行编译参数,把层可复现的尝试全部回退,并把上面三堵墙写进提交信息备查。
(-s -w 去掉的是符号表和 DWARF 调试信息,panic 栈的函数名与行号不受影响;只有 delve 断点调试需要自己去掉重编。)
教训
1. 同一个手法换个场景,先确认前提是否还成立
上一篇的成功建立在两个隐含前提上:单条 COPY、目标目录已存在于 base 镜像。这两条在多阶段构建里都不成立,而它们从来没被写下来过——因为成功的时候没人会去问「为什么成功」。
2. 元数据差异要靠导出比对,别靠猜
文件内容一致、mtime 一致、层指纹却不同——到这一步继续猜是浪费时间。docker save 出来解包,tar -tvf 打印清单,差异是一行一行摆在那里的。这一步花了几分钟,比前面所有的推测都值。
3. 「层不可变」是个容易忘的硬约束
写 Dockerfile 时习惯把它当脚本读,很自然会想「后面再修一下」。但每条指令都在生成一个只读快照,修不了已经发生的事。凡是要影响某一层的内容,都必须在生成那一层的时刻做。
4. 优化前先算天花板
「让它可复现」听起来永远是对的,但对不对和值不值是两回事。如果一开始就算清楚「最多省 1MB」,我根本不会开始这次尝试。先算天花板,再决定要不要爬。
5. 失败的排查也要留痕
这次的产出是「一行编译参数 + 三条不可行的论证」。论证部分我写进了提交信息和 MR 描述——因为下一个读到上一篇的人,很可能会像我一样想「把这套也用上」,然后重走一遍这三堵墙。把死路标出来,和把活路铺好一样有用。