2022 年我把一个 Node.js 后端服务容器化,写人生第一个正经的 Dockerfile。怎么写?这件事我压根没多想。第一版我做得很顺手:FROM node,设好工作目录,COPY . . 把整个项目拷进去,RUN npm install 装依赖,RUN npm run build 打包,最后 CMD 启动。就完事了。本地构建一次——真不错:虽然 npm install 跑了四五分钟,但毕竟装这么多包,慢点正常,镜像也确实出来了。我心里很踏实:"Dockerfile 嘛,把构建步骤一条条列出来不就行了?"可等我真正开始日常开发、一天构建几十次,一串问题冒了出来。第一种最先把我打懵:我只改了一行业务代码,重新构建,npm install 居然又从头跑了整整四分半——明明我一个依赖都没动。第二种最难缠:这事每次都发生,改注释要重装、改个文案要重装,每天几十次、每次几分钟全耗在等 npm install 上。第三种最头疼:我把项目接到 CI,发现 CI 上构建更慢,好像缓存完全没起作用,每次都从最底层从头来。第四种最莫名其妙:同事说他本地构建挺快的,可换台机器、或者清一下 Docker,又变回几分钟——快慢毫无规律。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"Dockerfile 就是一串构建步骤,顺序无所谓,反正最后构建出来的镜像是一样的"。这句话把 Dockerfile 当成了一个从头跑到尾的、一次性的 shell 脚本。可它不是。我脑子里,docker build 就是把那几条命令依次执行一遍,执行完得到一个镜像,指令的先后只要不影响最终结果,怎么排都行。可 Docker 根本不是这么工作的。Docker 镜像不是一个整体,它是一摞只读的层叠起来的;Dockerfile 里每一条指令,执行完都会产生一个新的层,摞在上一层之上。而 docker build 真正聪明、也真正容易坑人的地方在于:它会缓存这些层。下一次构建,它从第一条指令开始逐层比对——如果这条指令本身没改、并且它依赖的输入也没变,Docker 就直接拿出上次那个层来复用,这条指令根本不重新执行,这叫缓存命中。这本该让构建快如闪电。可一旦某一层没命中、需要重建,事情就来了:它后面的每一层,哪怕指令一个字都没改,也全部强制失效、必须跟着重建。缓存失效是会向下传染的,而且是单向的——上游污染下游,下游救不了上游。我那个第一版,把 COPY . . 写在了 npm install 前面。COPY . . 这条指令,只要项目里任何一个文件变了,它的输入就变了,这一层立刻失效。于是我每改一行代码,COPY . . 失效,紧跟其后的 npm install 层被传染、也失效,只能重装。我以为指令顺序无所谓,可顺序恰恰决定了缓存能不能命中。真正写好 Dockerfile,核心不是"把步骤列全",而是理解镜像是分层的、层是带缓存的、缓存失效会单向向下传染,于是刻意地安排指令顺序:把几乎不变的东西(基础镜像、系统依赖、依赖清单)放在最前面,把天天都变的东西(业务源代码)放在最后面,让那个最慢、最该被缓存的依赖安装层,稳稳地待在易变内容的上游。这篇文章就把 Docker 分层缓存这个坑梳理一遍:为什么"改一行就重装"是错的、镜像分层和层缓存到底怎么回事、缓存失效为什么会传染、怎么把依赖安装和代码拷贝拆开、怎么用 .dockerignore 和 BuildKit 榨干缓存,以及 CI 缓存这些把构建提速做扎实要避开的坑。
问题背景
这个坑普遍,是因为"脚本顺序无所谓"在纯 shell 的世界里大体成立——一个脚本只要逻辑对,几条独立命令换个次序结果一样。把这个直觉带进 Dockerfile,就翻了车。它错得隐蔽,是因为第一版功能上完全正确:镜像能构建、容器能跑、服务正常。它只在"构建速度"这个维度上烂得彻底,而功能测试根本不会发现这一点。它又只在反复构建时才让人痛——你构建第一次,慢是应该的;你构建第二十次还是一样慢,才意识到不对劲。
把这个现象拆开,错误认知和真相是这样对应的:
- 现象:改一行代码就重跑数分钟的依赖安装;每天几十次构建全耗在等待上;CI 上缓存仿佛失灵;构建快慢在不同机器上毫无规律。
- 错误认知一:以为 Docker 镜像是一个整体,
docker build就是从头跑一遍脚本。真相是镜像是一摞只读层,每条指令产生一层。 - 错误认知二:以为指令顺序无所谓,只要不影响最终镜像。真相是顺序直接决定缓存能否命中,是 Dockerfile 性能的命脉。
- 错误认知三:以为缓存失效只影响那一层。真相是某层失效会让其下游所有层强制重建,失效单向向下传染。
- 真相:层缓存的命中条件是"指令及其输入都未变化"。把稳定的指令放前、易变的放后,让昂贵的依赖安装层处在易变内容的上游,缓存才能稳稳命中。
一、为什么"改一行代码就重跑 install"
先把第一版那个 Dockerfile 摆出来。
FROM node:20
WORKDIR /app
# 第一版:先把整个项目拷进去,再装依赖
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
这个 Dockerfile 功能上挑不出毛病。它的问题,要看两次构建的对比才显形。第一次构建,所有层都没有缓存,老老实实从头跑;之后我只改了一行业务代码,再构建一次:
# 第一次构建:每一层都老老实实从头跑
$ docker build -t myapp .
=> [2/5] WORKDIR /app
=> [3/5] COPY . .
=> [4/5] RUN npm install # 跑了 4 分 30 秒
=> [5/5] RUN npm run build
# 只改了一行业务代码,再次构建:
$ docker build -t myapp .
=> CACHED [2/5] WORKDIR /app
=> [3/5] COPY . . # 源文件变了,这层失效
=> [4/5] RUN npm install # 又跑了 4 分 30 秒!
=> [5/5] RUN npm run build
关键就在这第二次构建的输出上。WORKDIR 那层显示 CACHED——命中了缓存。可 COPY . . 这层没有 CACHED:因为 COPY . . 的缓存键,取决于它拷贝的所有文件的内容,我改了一行代码,文件内容就变了,这层的输入变了,缓存失效。然后致命的一步来了:RUN npm install 这条指令我一个字都没改,可它紧跟在已经失效的 COPY . . 之后——它的"上游"脏了,它就必须跟着重建。于是依赖被整整重装了一遍。RUN npm run build 同理,继续被传染。
这里要建立的第一个、也是最重要的认知是:docker build 不是一个从头跑到尾的脚本,而是一条带缓存的、逐层比对的流水线。镜像是一摞只读层,Dockerfile 每条指令产出一层。构建时 Docker 从第一条指令开始,逐层问同一个问题:"这条指令、以及它依赖的输入,和上次相比变了没有?"没变,就直接把上次的层拿来用,指令根本不执行,这就是 CACHED;变了,这一层就重新执行。而最关键、也最反直觉的一点是:层之间是有上下游依赖的,一个层的"输入"里,包含了它上面那一层。所以一旦某层失效,它就成了一个"脏"的上游,它下面的每一层——哪怕指令一字未改——都会被判定为"输入变了",统统强制重建。缓存失效像污水一样,只会从上游往下游流,且没有任何下游手段能挡住它。理解了这条,第一版的病因就一清二楚:COPY . . 是个极其敏感的层,项目里任何一个文件的任何一点改动都会让它失效;而我偏偏把它放在了昂贵的 npm install 上游。我每改一行代码,污水就从 COPY . . 这层灌下去,把下面的 npm install、npm build 全淹了。病根不在 npm install 慢,而在它站错了位置。
二、镜像是分层的:每条指令一个层
把上一节的原理再夯实一下。一个 Docker 镜像,物理上就是若干个只读层叠在一起:最底下是基础镜像(FROM node:20 本身就是一摞层),往上每执行一条 RUN、COPY、WORKDIR 指令,就在顶上加一个新层,这个层只记录"这条指令带来的文件系统变化"。最终的镜像,就是这些层从下到上叠加后看到的样子。
这个"层"不是抽象概念,你可以直接把它们看出来。docker history 能列出一个镜像由哪些层构成、每条指令产生的层多大:
# 查看镜像每一层是哪条指令产生的、各占多大
$ docker history myapp:latest --no-trunc --format \
"table {{.CreatedBy}}\t{{.Size}}"
# 输出能让你一眼看出:
# - 哪一层最大(通常是 RUN npm install 那层)
# - 哪些层因为指令顺序不好而频繁重建
# 一条经验:让"大且稳定"的层尽量靠前,把缓存价值最大化
层的缓存键怎么算,不同指令不一样,这点必须分清:对 RUN 指令,缓存键基本就是那条命令的字符串本身加上它的上游层——命令文本没变、上游没变,就命中;对 COPY 和 ADD 指令,缓存键还要加上被拷贝文件的实际内容——文件内容一变,即使 COPY 那行字没改,也失效。这就解释了为什么 COPY . . 如此"脆弱":它的缓存键绑定了项目里每一个文件的内容,几乎你做任何改动都会动到它。
这里要建立的认知是:把镜像理解成"一摞层"而不是"一个整体",是写好 Dockerfile 的地基。一旦你脑子里有了"层"这个颗粒度,你看 Dockerfile 的眼光就变了——你不再读它为"五条命令",而读它为"五个层,自下而上摞起来,每个层有自己的缓存键和复用条件"。你会本能地对每条指令多问一句:这一层的缓存键是什么?它多容易失效?它失效了会连累下面几层?docker history 的价值就在这里,它把抽象的"层"变成了你能逐行看见、能量出大小的东西,让你能定位到底是哪个大层在被反复重建、白白烧时间。还有一个常被忽略的点:RUN 的缓存键只看命令字符串,不看命令的实际效果——这意味着 RUN npm install 这行字没变、上游也没变时,哪怕远端的包其实出了新版本,Docker 也照样命中旧缓存、不会重装。缓存命中判断的是"指令和输入变没变",不是"结果应不应该变",这把双刃剑你得心里有数。
三、缓存失效会向下传染:指令顺序决定一切
现在把"失效传染"这件事完整走一遍。设想 Dockerfile 的层从上到下排成一列,构建时 Docker 逐层判断,它的决策是这样流动的:
这张图的要点是那条 变了 的分支:一旦某层失效,后面的层就不再逐个判断了,而是无条件全部重建。Docker 不会在下游帮你"捡回"缓存——它没法捡,因为下游每一层的输入都包含了上游,上游脏了,下游的输入定义上就是脏的。所以一条 Dockerfile 的缓存效率,几乎完全由"第一个会失效的层出现在多靠下的位置"决定:它越靠下,它上面能稳稳命中的层就越多,被它连累重建的层就越少。
这条规律直接给出了写 Dockerfile 的黄金法则:把指令按"变化频率从低到高"的顺序,自上而下排列。最不常变的(基础镜像、系统级依赖)放最上面,中等频率的(项目依赖清单、依赖安装)放中间,最常变的(业务源代码)放最下面。第一版之所以错,就是把"天天变"的 COPY . . 放到了"几乎不变"的 npm install 上面,彻底违反了这条法则。
这里要建立的认知是:Dockerfile 的指令顺序,不是风格问题,是性能架构问题。在普通脚本里,两条独立命令谁先谁后无所谓;但在 Dockerfile 里,顺序定义了"谁是谁的上游",而上游能污染下游、下游救不了上游——这个不对称,让顺序变成了一个有方向、有后果的决策。写每一条指令时,你都该想清楚它的"变化频率"在整条流水线里处于什么档位,然后把它放到对应的高度:稳定的沉到底、易变的浮到顶。这背后其实是一个很通用的优化思想——把昂贵且稳定的计算,挪到易变输入的上游去,这样易变输入再怎么频繁改动,都冲不到那个昂贵计算,它的结果就能被一直复用。npm install 又慢又稳定,它就该尽量往上游沉;源代码又轻又易变,它就该往下游浮。你把这两者的相对位置摆对了,80% 的构建提速就已经到手了。下一节就把这个"摆对位置"落成具体的 Dockerfile。
四、把依赖安装和代码拷贝分开
落到代码上,关键动作只有一个:不要用一条 COPY . . 把所有东西一次拷进去,而是先单独拷贝"依赖清单",装完依赖,再拷贝其余源代码。对 Node 项目,依赖清单就是 package.json 和 package-lock.json 这两个文件。
FROM node:20
WORKDIR /app
# 先只拷贝依赖清单 —— 这两个文件很少变
COPY package.json package-lock.json ./
# 装依赖:只要上面两个文件没变,这一层就一直命中缓存
RUN npm install
# 再拷贝其余源代码 —— 这部分天天变,但它在 install 之后
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
这个改动看着小,效果是质变。现在 npm install 这层的上游,是 COPY package.json package-lock.json——而这两个文件,只有你真正增删改依赖时才会变。你改业务代码、改注释、改文案,统统不碰它们,所以 npm install 层稳稳命中缓存。失效的只剩下游的 COPY . . 和 npm run build。再构建一次看看:
# 改一行业务代码后再次构建优化版的 Dockerfile:
$ docker build -t myapp .
=> CACHED [2/6] WORKDIR /app
=> CACHED [3/6] COPY package.json package-lock.json ./
=> CACHED [4/6] RUN npm install # 命中缓存,0 秒
=> [5/6] COPY . . # 只有这层和之后重建
=> [6/6] RUN npm run build # 构建从 4 分钟降到几秒
同样的道理适用于任何语言,变的只是"依赖清单"叫什么名字。Python 项目里它是 requirements.txt:
# Python 应用同理:requirements.txt 是"依赖清单",先单独拷
FROM python:3.12-slim
WORKDIR /app
# 先拷依赖清单并安装 —— 只要 requirements.txt 没变就命中缓存
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# 再拷应用代码 —— 改代码不会触发上面的 pip install 重跑
COPY . .
CMD ["python", "-m", "app"]
这里要建立的认知是:"先拷依赖清单、再拷源代码"这个看似琐碎的两步走,本质是在一条流水线上,人为地切出一道"稳定/易变"的分界线。在你眼里 package.json 和你的业务源码都是"项目文件",但从缓存的视角它们是两种截然不同的东西:依赖清单是低频变更的,源代码是高频变更的。一条 COPY . . 把它俩混在一个层里,等于强行让低频的跟着高频的一起失效——你把一个本可以长期命中的稳定资产,亲手绑在了一个天天爆炸的易变资产上。拆成两个 COPY,就是把这两类东西分进了不同的层,让它们各自按各自的频率失效、互不拖累。这是一个值得记住的通法:当一批东西里混着"稳定的"和"易变的",而它们又会被同一个缓存键覆盖时,就把它们拆开,让稳定的单独成一层。Dockerfile 里你能这么拆,别处的缓存设计也一样。记住分界线的位置:依赖安装必须在分界线之上,源代码拷贝必须在分界线之下。
五、用 .dockerignore 和 BuildKit 榨干缓存
分好层之后,还有两件事能把缓存利用率再往上抬。第一件是 .dockerignore。COPY . . 拷的是"构建上下文"里的所有文件,如果你不排除,node_modules、.git、日志、构建产物全都会被算进 COPY . . 的缓存键——这些目录变动极其频繁(尤其 .git),会让 COPY . . 层无谓地、频繁地失效。一个 .dockerignore 文件就能把它们挡在门外:
# .dockerignore —— 这些目录不该进入构建上下文
node_modules
dist
.git
*.log
.env
coverage
第二件是 BuildKit 的缓存挂载。前面我们让 npm install 层尽量命中缓存,但总有命中不了的时候——你确实改了依赖,package-lock.json 变了,这层只能重建。可"重建这一层"不代表"每个包都得重新从网上下载":那些没变的包,上次下载的文件其实还能用。RUN --mount=type=cache 就是干这个的,它把包管理器的下载目录挂成一个跨构建持久存在的缓存:
# syntax=docker/dockerfile:1
FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
# 把 npm 的下载缓存挂成一个持久化的 cache 目录
# 即使这一层因 lockfile 变更而失效,已下载的包仍可复用
RUN --mount=type=cache,target=/root/.npm \
npm install
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
这里要建立的认知是:层缓存是"全有或全无"的——一个层要么整个命中、要么整个重建,没有中间状态;而 .dockerignore 和缓存挂载,正是用来对冲这种"全有或全无"的粗粒度的。.dockerignore 对冲的是"输入被污染":它让 COPY . . 的缓存键只包含真正属于源代码的文件,把那些和你的代码无关、却变得飞快的目录(.git、日志、本地 node_modules)挡在缓存键之外,从而让这个层"该命中的时候真能命中"。缓存挂载对冲的是另一面——"层一旦失效,损失太惨重":即使 npm install 这个层整个重建了,挂载进去的下载缓存目录不属于任何层、不受层失效影响,于是重建时大量没变的包是从本地缓存秒装的,而不是重新走网络。把这两件事和前面的分层放在一起看,你就有了一个三段式的缓存策略:用分层让昂贵的层尽量不失效,用 .dockerignore 防止它被无关变更误伤,用缓存挂载兜住它万一失效时的损失。三层防护叠起来,构建速度才算真正榨干了。
六、CI 里让缓存生效,以及工程坑
把本地构建调快之后,接 CI 时常会发现缓存"又没了"。这不是 bug:CI 每次大多在一台全新的、干净的机器上跑,本地积累的那些层缓存,在那台机器上根本不存在。解法是把缓存"显式地"喂给 CI——用上一次构建推送到镜像仓库的镜像,作为这一次构建的缓存来源:
# CI 上每次都是全新机器,本地层缓存根本不存在
# 解法:把上一次构建推送的镜像,当作这次的缓存来源
# 1) 先拉取上一次推送的镜像(拉不到也不要紧)
docker pull registry.example.com/myapp:latest || true
# 2) 构建时用它做缓存来源,并打开 inline 缓存元数据
docker build \
--cache-from registry.example.com/myapp:latest \
--build-arg BUILDKIT_INLINE_CACHE=1 \
-t registry.example.com/myapp:latest .
# 3) 推回去,给下一次 CI 当缓存
docker push registry.example.com/myapp:latest
另外几个真实项目里的坑。一是基础镜像标签是浮动的:FROM node:20 里的 node:20 不是一个固定不变的镜像,官方会不断给它推安全更新——某天它悄悄变了,你的 FROM 层就失效,后面所有层跟着全部重建。要构建可复现、缓存稳定,就把基础镜像用更精确的版本、甚至 digest 钉死:
# 基础镜像用精确版本钉死,避免 node:20 标签悄悄更新导致缓存全失效
FROM node:20.11.1-bookworm-slim
WORKDIR /app
# 系统级依赖几乎从不变化,放在最顶层,缓存几乎永久命中
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build
二是系统依赖也要按"变化频率"归位:像上面这种 apt-get install 系统包的指令,变化频率比项目依赖还低,就该放在 COPY package.json 之上、紧贴 FROM。三是 RUN 指令链里清理要和安装写在同一条 RUN 里(用 && 连起来):如果你 apt-get install 是一层、rm 清理缓存是另一层,被删的文件其实已经留在了下层、镜像并不会变小——这点虽然偏体积,但和"一条指令一个层"是同一个原理。
这里要建立的认知是:缓存这东西,在哪台机器上、它就只在哪台机器上——它不是镜像的一部分,不会跟着 docker push 一起走。这是 CI 缓存失灵的全部根源:你本地辛苦攒下的层缓存,是你这台机器的本地状态,CI 那台一次性的干净机器对它一无所知。想让 CI 也快,你必须把"缓存"这件事从"机器的隐式本地状态"升级成"一份可以显式传递的产物"——而镜像仓库恰好是那个公共中转站:上一次 CI 把带缓存元数据的镜像推上去,下一次 CI 用 --cache-from 把它当缓存拉下来。本质上你是在用一个共享的远端,模拟出"机器之间共享缓存"的效果。再加上把基础镜像钉死——浮动标签等于给你的整条缓存链埋了颗会随机引爆的雷,FROM 一变,上面苦心经营的分层全白搭。把这些合起来,一个工程上靠谱的 Dockerfile,是同时在三个层面被设计过的:指令顺序按变化频率排好、构建上下文用 .dockerignore 收干净、CI 缓存用镜像仓库显式接力,而这一切的最底层,是基础镜像被一个确定的版本牢牢钉住。
关键概念速查
| 概念 | 说明 | 关键点 |
|---|---|---|
| 镜像分层 | Dockerfile 每条指令产生一个只读层 | 镜像是层的叠加,层可被独立缓存与复用 |
| 构建缓存 | Docker 复用未变化的层,跳过指令执行 | 命中显示 CACHED,命中条件是指令与输入都没变 |
| 缓存失效传染 | 某层失效后,其下游所有层强制重建 | 失效单向向下,上游污染下游,下游救不回 |
| 指令顺序 | 按变化频率从低到高自上而下排列 | 稳定的沉底、易变的浮顶,是性能命脉 |
| RUN 的缓存键 | 取决于命令字符串本身与上游层 | 命令文本不变即命中,不看命令实际效果 |
| COPY 的缓存键 | 额外取决于被拷贝文件的内容 | 任一源文件变化即令该 COPY 层失效 |
| 依赖清单先行 | 先单独 COPY package.json / requirements.txt | 让昂贵的依赖安装层独立、长期命中缓存 |
| .dockerignore | 排除文件不进入构建上下文 | 避免 .git、日志等无关变更误伤 COPY 缓存 |
| BuildKit 缓存挂载 | RUN --mount=type=cache 持久化下载目录 | 层即使失效,已下载的包仍可复用 |
| CI 缓存 --cache-from | 用远端镜像作为构建缓存来源 | CI 机器无本地缓存,须经镜像仓库显式接力 |
避坑清单
- 不要用一条 COPY . . 把所有文件一次拷进去再装依赖。源代码任何改动都会让它失效,并把下游的依赖安装层一起冲掉。
- 先单独 COPY 依赖清单(package.json / requirements.txt),装完依赖,再 COPY 源代码。让昂贵的安装层处于易变源码的上游。
- 按变化频率从低到高排列指令:基础镜像、系统依赖在最上,依赖安装在中间,业务源码在最下。
- 记住缓存失效会向下传染。判断一条指令该放哪,就看它失效时会连累下面多少层。
- 务必写 .dockerignore,排除 node_modules、.git、日志、构建产物,避免它们污染 COPY 的缓存键。
- 用 BuildKit 的 RUN --mount=type=cache 挂载包下载目录,让安装层即便失效也不必重新下载所有包。
- 基础镜像不要用浮动标签。用精确版本或 digest 钉死 FROM,否则 base 悄悄更新会让全部层失效。
- CI 上要显式注入缓存:docker pull 上次镜像、构建时 --cache-from 指向它,CI 机器没有本地层缓存。
- 安装与清理写在同一条 RUN 里。分成两层的话,被删文件仍留在下层,镜像并不会变小。
- 别只测"镜像能不能构建",要测"改一行代码后重建快不快"。功能正常不代表缓存设计正确。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我以为 Dockerfile 是一串从头跑到尾的脚本,指令顺序无所谓。可它不是——Docker 镜像是一摞带缓存的只读层,每条指令是一层,而缓存失效会从上游单向传染到下游。我把天天都变的 COPY . .,放在了又慢又稳定的 npm install 上游,于是每改一行代码,失效就从 COPY 层灌下去,把依赖安装整个淹掉。问题从来不在 npm install 慢,而在它站错了位置。
真正写好 Dockerfile,工作量不在"把构建步骤列全",而在一次视角的转变:从"列步骤"变成"排层"。一旦你脑子里有了"层"和"失效会向下传染"这两个概念,该怎么写就自然浮现了——按变化频率把指令从低到高排好、把依赖清单和源代码拆成两个 COPY、写好 .dockerignore、用缓存挂载兜底、CI 上经镜像仓库接力缓存。每一步都不难,难的是先承认:Dockerfile 不是脚本,是一条流水线的布局图;指令的先后,不是风格,是性能架构。
我后来常拿砌墙来想这件事。盖一面墙,最底下是地基,往上是承重的砖,最上面才是那层会经常想换花样的装饰面砖。聪明的盖法,是地基和承重墙一次砌好、几乎再也不动,装饰砖放最上面——哪天想换装饰,把最上一层敲掉重贴就行,底下纹丝不动。而我的第一版,等于把"装饰砖"砌进了地基的位置:我每次想换一下最表面的装饰(改一行代码),都得把压在它上面的整面承重墙(npm install)全部拆掉重砌。Dockerfile 的指令顺序,就是在决定每一块"砖"砌在第几层:稳定的沉到地基去,易变的浮到墙顶来,你才不用为了换一块面砖去拆一堵墙。
这类问题最咬人的地方,在于它在"功能"这个维度上永远是"对"的:镜像能构建、容器能跑、测试全过,你拿不到任何一个它在报错的信号。它烂在"构建速度"这个维度,而这个维度不会有红色的失败提示,只会在你日复一日、一天几十次的等待里,悄悄偷走你的时间。所以别等到"怎么构建又是好几分钟"成了每日的麻木背景音才去查:写 Dockerfile 的第一天,就该把"镜像是分层的、缓存会向下传染"刻进设计里——先拷依赖清单再拷代码、按变化频率排指令、配齐 .dockerignore。把这套布局在写第一行 FROM 时就想清楚,你才算真正跳出了那条人人都会写、却让人人天天空等的 COPY . .。
—— 别看了 · 2026