docker compose build 跑了 92 分钟,最后死在一个 short read 上。
我没重试,而是用 7 个 md5 证明了旧镜像可以直接用。
打包阶段要重建三个业务镜像。镜像就是打包好的只读文件模板,容器是它跑起来的实例。
命令很简单。
docker compose 是 Docker 官方的多容器编排工具,一份 yaml 管好几个容器。
bash
docker compose build gin admin
然后它跑了起来,跑了一小时三十二分钟。结果一半成功,一半失败。
#17 DONE 5542.6s ← 拉一个层花了 92 分钟
#25 [admin 2/6] WORKDIR /app
#25 ERROR: short read: expected 14450867 bytes but got 10679994: unexpected EOF
target admin: failed to solve: failed to compute cache key
gin 成功了。admin 挂在了 WORKDIR /app 这一步。
挂掉的不是编译,也不是写代码。它死在第 2 步,连代码都还没开始拷。
报错原文里两个数是关键:expected 14450867 bytes but got 10679994。
它说的是,这一层该收到 14450867 字节,实际只收到 10679994 字节。
差了约 3.7MB,连接就断了。
这叫 short read,也就是下载被截断了。
原因是拉基础镜像 python:3.11-slim 的时候,本地镜像源极不稳定。
所以第一件事确定了:这次失败和代码一点关系都没有。
它在下载阶段就死了,压根没轮到源码出场。
看到构建失败,我当时的肌肉记忆是再跑一遍。但先算一下这笔账。
直接重试,代价是再来一次 92 分钟,而且可能再一次被截断。
成不成功,取决于镜像源的心情。
换成证明「这个镜像不需要重建」,代价只有几条命令、几分钟。
成不成功,取决于镜像里装的到底是不是当前代码。
第一条路的坏处不只是慢。更要命的是,92 分钟之后的那个结果,我没法提前知道。
它可能成功,也可能在第 80 分钟又断一次。
真正该想的问题不是「怎么让它成功」。
我一开始想的也是这个。
该想的是:这个镜像,真的需要重建吗?
如果镜像里装的代码就是当前代码,这次失败根本不用修,直接用现成的镜像就行。
那就先取证,再定结论。取证的顺序是先筛时间线,再逐个算 md5。
镜像的构建时间和源码的最后修改时间,都是可以查的。三个镜像挨个对一遍。
app-admin 的镜像构建时间是 2026-08-23 12:06。
源码最后修改是 2026-08-23 11:15,镜像比源码晚了 51 分钟。
app-gin 的镜像构建时间是 2026-09-21 20:05。源码最后修改是 2026-08-26 07:43,它已经重建过了。
app-h5 的镜像构建时间是 2026-08-23 12:06。
源码最后修改是 2026-08-26 08:31,镜像比源码旧,需要重建。
判据很简单。镜像比源码新,大概率包含了最新代码;镜像比源码旧,就一定漏了东西。
app-admin 落在第一种情况。但时间线只是旁证。
它能证明上次构建发生在最后一次改代码之后,却证明不了构建进去的就是这些文件。
万一当时构建的是另一个分支,万一改完之后文件被回滚过,时间线都看不出来。
所以真正做的,是把镜像里的文件算一遍 md5,和本地逐个对。
在容器里算,用镜像自带的 python,不依赖宿主机装任何东西。
bash
docker run --rm app-admin:latest python -c \
"import hashlib;print(hashlib.md5(open(chr(47)+'app'+chr(47)+'config.py','rb').read()).hexdigest())"
在本地算。
bash
python -c "import hashlib;print(hashlib.md5(open('config.py','rb').read()).hexdigest())"
两个值一比,就知道镜像里那份和本地这份是不是同一个字节序列。
七个文件跑下来。
√ config.py √ wsgi.py √ app.py
√ models/admin.py √ routes/auth.py √ routes/admin_user.py
√ templates/base.html
一致 7 / 不同 0
一致 7,不同 0。
结论直接落下来:app-admin:latest 里装的就是当前代码,复用,不重建。
这里顺带踩到一个环境坑。
上面那条命令里,为什么写 chr(47)+'app'+chr(47),而不直接写 /app/config.py?
因为这是在 Windows 的 MSYS 环境里跑的。
MSYS 会自作主张,把命令行里的 /app/config.py 改写成 Windows 路径。
实测变成了 F:/app/install/Git/app/config.py。
容器里自然找不到这个文件,于是每一行都返回「取不到哈希」。
看起来就像镜像里的文件全都不对。
这个假象排查了两轮才定位到。绕法有两个。
bash
# 绕法一:让命令行里不出现 /app 这个字面量
python -c "import hashlib;print(hashlib.md5(open(chr(47)+'app'+chr(47)+'config.py','rb').read()).hexdigest())"
# 绕法二:用双斜杠,MSYS 就不转换了
docker run --rm app-admin:latest md5sum //app/config.py
这类坑的共同特征是,它不会报错,只会给你一个看起来很有意义的错误答案。
「取不到哈希」和「哈希不一致」,是两种完全不同的结论,但输出长得差不多。
回头看那三行报错,信息量比想象中大。
#25 [admin 2/6] WORKDIR /app
#25 ERROR: short read: expected 14450867 bytes but got 10679994: unexpected EOF
target admin: failed to solve: failed to compute cache key
admin 2/6 说明整个 Dockerfile 一共 6 步,死在第 2 步。
short read 加 unexpected EOF 是网络层的症状。
failed to compute cache key 说的是前面没做成功、后面算不出缓存键。
它是结果,不是原因。
把网络失败和代码失败分开,是这一步唯一重要的事。
分开了,网络失败就去查源、查代理,或者干脆绕过去。
分不开,就会对着自己没问题的代码改上两个小时。
而「绕过去」的前提,是我手上有别的证据证明产物可用。
这就是那七个 md5 的价值。
三个镜像的处置落下来是这样。
app-gin 重建,镜像 ID 从 5090ee632c85 变成 f6f16e877dc6。
依据是源码 8-26 晚于镜像 8-23。
app-h5 也重建,从 f2ed0db556cd 到 c9f55183cb3f,耗时 1.1 秒。
依据是源码更新,加上静态产物今日重建。纯 COPY,所以极快。
app-admin 复用,镜像 ID 是 cab9af444506,依据是 md5 逐文件比对一致。
app-h5 那条 1.1 秒值得单独看一眼。
它的 Dockerfile 只有一句 COPY,把本地构建好的静态产物拷进 nginx 镜像。
没有编译,就没有编译时间。
以后再遇到这个情况,就按照今天这种方式来排查。
报错里带 short read、unexpected EOF,先把失败定位到网络层,先看它死在第几步。
x/y 里的 y 很小,就说明大概率还没碰到你的代码。
想判断旧镜像能不能复用,先比时间线,也就是镜像构建时间对源码最后修改时间。
镜像晚于源码,再进入下一步验证。
用镜像自带的运行时算 md5,和本地逐文件比对,全部一致就直接复用。
记下镜像 ID,跳过这次 build,后续打包直接 docker save 它。
在 Windows 或者 MSYS 下跑容器命令,避免命令行里出现 /app 这个字面量。
用 chr(47) 拼路径,或者写成 //app/...