同事把 CI 那台机器的钥匙交出来时,我手上多了个活儿:以后镜像自己出。 他那边的理由很干脆,构建机上只装了 Docker,没别的可依赖。
那阵子 〈报 no such file or directory,锅在换行符〉 刚过去,我以为能喘口气。 结果这次挡路的是 npm。
代码是我从仓库里自己拉的,落在构建机的项目根目录。 系统还是 CentOS 7.9,Docker 20.10.7,没换机器。
$ docker build -t mall-gateway:2.0 .
Dockerfile 是从上往下一行行读的,每层叠在前一层上面。 前六层刷得飞快,屏幕上全是复用的字样。 卡住的是第 7 层,七分多钟之后整趟挂了。 那七分多钟,大半被 npm 在源那边的反复重试吃掉了。
真正跑起来的只有第 7 层
挂之前那几行长这样:
Sending build context to Docker daemon 412.4MB
Step 1/13 : FROM node:14-buster AS admin-build
---> 5a1c0d2e9f38
Step 2/13 : RUN apt-get update && apt-get install -y tzdata && rm -rf /var/lib/apt/lists/*
---> Using cache
---> 8e73b1c94a02
Step 3/13 : WORKDIR /build
---> Using cache
---> c4d17e0b9a55
Step 4/13 : COPY admin/.npmrc /root/.npmrc
---> Using cache
---> 1f6b93d07c24
Step 5/13 : COPY admin/package.json admin/package-lock.json ./
---> Using cache
---> 7a02c5e8f1b3
Step 6/13 : COPY admin/ .
---> Using cache
---> d3e8b1f70a49
Step 7/13 : RUN npm install
---> Running in 3c9f77bd1a02
这两种字样我起初是混着看的,得拆开说。 Using cache 意思是这步根本没执行,直接接上现成的层。 Running in 后面那串 id 才是新起的临时容器,它装着上一层的结果,这条 RUN 在里头跑。
第 7 层能吃掉七分多钟,正因为它就是当场执行的那一层,缓存插不上手,网络重试全得自己扛。
returned a non-zero code 后面那个 1,说的是 npm install
失败收尾的几行:
npm ERR! code E404
npm ERR! 404 'dayjs@1.11.7' is not in the npm registry.
npm ERR! A complete log of this run can be found in:
npm ERR! /root/.npm/_logs/2026-09-19T02_31_44_721Z-debug.log
The command '/bin/sh -c npm install' returned a non-zero code: 1
最后那行太容易被当成 docker 在喊冤,这么读不对。 它给的只是这条 RUN 里那条命令的退出码,值是 1。
这个数和便签 〈容器退出码那几个数,125、126、127、137 各指啥〉 讲的是同一路事,只是场景挪到了 build:容器压根没起来,退场的是那一层的临时容器。
所以判读顺序得倒过来。 build 失败先别怀疑 docker,顺着 non-zero code 后面那个数字回指命令本身,它的抱怨就堆在这个数字上面几行。
npm 让人去看 /root/.npm/_logs/ 底下那份 debug 日志,这条我觉得不厚道:文件属于临时容器,命令一退容器就没了,根本取不出来。 我的做法是把终端输出整段留档,别指望往上翻页。
再往上那四行 E404 把对象点得很死:dayjs 这个包、1.11.7 这个版本,我配的源上不存在。
$ npm view dayjs versions --registry=https://registry.internal.local
[ '1.8.16', '1.9.0', '1.10.4', '1.10.7' ]
我用的是内网源,运维不会主动替我们同步新包,所以那边最高只到 1.10.7,而 lockfile 钉的是 1.11.7。 选项就两个:请运维补包,或者降版本。
我赶交付,选了后者。 package.json 里把 dayjs 落到 1.10.7,本地重装、刷出新的 lock,推上去再 build。
$ docker build -t mall-gateway:2.0 .
...
Successfully built b2f9c1a74d38
Successfully tagged mall-gateway:2.0
这步过了。 内网源后来补上包,我再升回 1.11.7,那一层单独耗时七十四秒,整趟 build 九分十二秒。
宿主机改了配置,缓存不认
后面有一次为了换回公网源试别的包,我直接在宿主机改了 ~/.npmrc,重跑 build。 十几秒就拿到镜像,屏幕一路复用字样。 我以为那趟配置白改了,跑起来一验,依赖果真是旧的那套。
道理不复杂:这台构建器判定缓存只看两样东西。 一是 Dockerfile 指令行的文字,二是被 COPY 进上下文的内容。
宿主机那份 ~/.npmrc 从来不在上下文里,容器内 npm 读的是 COPY 进去的副本。 指令行一个字没变,它就按"一致"处理,缓存照用。
顺带回填一笔。 开头那行 412.4MB 的 build context,是我把 admin 底下的 node_modules 一并送进去了,这种东西本该写进 .dockerignore。 上下文越胖,传输与比对都是白花的钱。
绕开的办法两条。 一是去动 Dockerfile 里 COPY 那份 .npmrc,内容但凡改一字,该层以下全部重来。 二是图快直接:
$ docker build --no-cache -t mall-gateway:2.0 .
docker builder prune 是手册里给 BuildKit 那套准备的; 这台老构建器积下的是挂靠宿主机的中间层,清它得换一条命令。
顺带说一句输出样式的差异。 这台走的还是老式构建器,Step 逐条往下打印,反而好读。 同事笔记本上 Docker Desktop 缺省是 BuildKit,输出折叠,出事只吐一行 ERROR,日志得另想法子掏。 两种形态不能对着抄。
失败堆上几轮,docker images 里就多出一片没 tag 的:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
mall-gateway 2.0 b2f9c1a74d38 4 minutes ago 1.42GB
<none> <none> 7c1e0f3a9b24 22 minutes ago 1.36GB
<none> <none> 3a92c7b0e15d 38 minutes ago 1.36GB
删容器带不走这些层,docker rmi 又嫌它们有引用不肯删。 我是拿 docker image prune -f 清掉的,清完 df 一瞧,根分区从 78% 落回 61%。
1.42GB 这个镜像体积是另一笔,先按下。 当时更堵心的是第二遍 build:admin 里改动不过一处 JS 拼写笔误,第 7 层那七十四秒的 npm install 又整层重跑了一遍。