KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · 层、缓存与指令顺序:构建时间的真正变量 — keel 龙骨
这一章回答:层的缓存键由什么构成?为什么同样的指令换个顺序,构建时间能差十倍?
这一章回答:层的缓存键由什么构成?为什么同样的指令换个顺序,构建时间能差十倍?
第 01 章把"上下文"讲清楚了——那是一份要传输的文件集合。这一章接着往下:上下文送到引擎之后,指令会被逐条求值,而求值的顺序决定了有多少工作可以复用。
如果只能从这一章带走一句话,是这句:
Dockerfile 的指令顺序不是风格问题。它决定的是:改一行业务代码时,有多少层必须重做。
现场
一个 CI 流水线,构建耗时稳定在 6 分钟左右。看日志的时间分布:
Step 4/9 : COPY . .
Step 5/9 : RUN npm install
---> Running in 3f1a...
added 412 packages in 4m41s ← 大部分时间在这里
Step 6/9 : RUN npm run build
---> 2.1s
同事的解读是"依赖太多、npm 慢"。但同一份 package.json 在几天前没变过,CPU 也不是瓶颈——这 4 分 41 秒的工作,理论上零次就该被复用。
根因在 Step 4 与 Step 5 的顺序上。要讲清为什么,得先知道缓存键是怎么算的。
一、缓存键:一层由什么决定
每条会改动文件系统的指令(RUN、COPY、ADD)都产出一层。引擎判断"能不能复用已有层",靠的是给它算一个缓存键。缓存键的输入有两部分:
| 输入 | 具体是什么 |
|---|---|
| 父层的缓存键 | 上一层是谁(于是整条链是链式依赖的) |
| 本指令的指纹 | RUN → 命令字符串本身;COPY/ADD → 被复制文件的内容摘要(不是文件名列表,是内容) |
由此得到两条直接推论,它们解释了本章所有现象:
推论一:缓存是链式的,断一环废后续全部。
第 3 层作废,第 4、5、6 层全部作废,即使第 5 层的指令一个字都没改。因为第 5 层的缓存键里含"父层的缓存键",父层已经是新的了。
推论二:COPY 的指纹是内容,所以"改了什么"比"改了哪个文件"更重要。
这是第 01 章那条"内容是变的文件会让 COPY 层每次作废"的机制来源。debug.log 追加一行,COPY 的指纹就变了。
把推论一画出来:
flowchart TD
subgraph CHAIN["镜像层链(自上而下按求值顺序)"]
L0["层 0 · FROM node:22-alpine"]
L1["层 1 · COPY package.json package-lock.json ./"]
L2["层 2 · RUN npm ci"]
L3["层 3 · COPY . ."]
L4["层 4 · RUN npm run build"]
end
CHG["改动:src/index.js 一行"] --> Q1{"层 1 指纹<br/>变了吗?"}
Q1 -->|"没变:清单文件没动"| Q2{"层 2 指纹<br/>变了吗?"}
Q1 -->|"变了:动了依赖清单"| BAD["从层 1 起全部重建<br/>含重新安装依赖"]
Q2 -->|"没变"| Q3{"层 3 指纹<br/>变了吗?"}
Q2 -->|"变了"| BAD
Q3 -->|"变了:源码内容变了"| OK["只重建层 3、层 4<br/>层 0–2 复用"]
Q3 -.->|"没变"| NOOP["全链命中<br/>构建秒级完成"]
图上左侧那个 CHG → Q1 是改进动一行业务代码。注意正确顺序下它走到 OK:层 2(装依赖)被复用,只重做 COPY 与 build。而坏顺序会让它走到 BAD。
二、坏顺序:COPY . . 放在装依赖之前
开头那个现场用的就是这个顺序:
FROM node:22
WORKDIR /app
COPY . . # ← 第 4 步:整份上下文进来(含 src/)
RUN npm install # ← 第 5 步:4 分 41 秒
EXPOSE 3000
CMD ["node", "src/index.js"]
COPY . . 的指纹包含了 src/index.js 的内容。所以:
改 src/index.js 一行
→ 层「COPY . .」指纹变
→ 层「RUN npm install」的父层变了,也作废
→ npm install 重跑 4 分 41 秒
即使 package.json 一个字没改。 依赖不可能变,但引擎没有理由相信这一点——它只看父层的缓存键。
三、好顺序:把"变化频率"当作排序键
修法是把依赖清单单独先 COPY 进来:
COPY package.json package-lock.json ./ # 指纹只含这两个文件
RUN npm ci # 只有清单变了,这层才作废
COPY . . # 源码变化只影响这一层及其后
RUN npm run build
两条判断规则:
- 按变化频率从低到高排:基础镜像 → 系统包 → 依赖清单 → 装依赖 → 业务代码 → 构建。变化越少的东西越靠下(越早),被复用的机会越大。
- 把"装依赖"和"业务代码"隔开。这两件事的变化频率相差一个数量级,放在同一层是浪费——同层的东西共享同一个缓存键。
关于第 2 条有个常见误读值得澄清:"把两条命令写进同一个 RUN" 不等于"它们各自一层"。相反,写进同一个 RUN 会让它们共享一层,从缓存角度看是更粗的粒度:
| 写法 | 层数 | 只改业务代码时,装依赖会重跑吗 |
|---|---|---|
COPY . . → RUN npm install |
2 层,但依赖层的父层是源码 | 会 |
COPY 清单 → RUN npm ci → COPY 源码 |
3 层,粒度对齐了变化频率 | 不会 |
反之,把 apt-get update && apt-get install -y ... && rm -rf /var/lib/apt/lists/* 写在同一条 RUN 里是对的——因为那三条必须共享一层,否则清理不生效(下一节)。
所以"该合并还是该拆开"的判据不是"层数少就好",而是:这些步骤的输入变化频率是否相同、以及是否存在"必须同层才有效"的副作用。
四、层是叠加的:所以清理和产生必须在同一层
镜像的层是只读叠加的。你在后一层删掉前一层里的文件,前一层仍然存在——那条 rm 只是在新的可写层里记了一个"这个路径被删掉了"的遮蔽标记。
这带来一个反直觉但极其常见的现象:
# ❌ 体积不会下降
RUN npm ci # 层 A:拉进了 ~200 MB 的包缓存与依赖
RUN rm -rf /root/.npm # 层 B:只在层 B 里遮蔽了它
层 A 里的 200 MB 依然躺在镜像里。要在同一层里产生并清理:
# ✅ 缓存与清理在同一条 RUN 内,同一层
RUN npm ci --omit=dev && npm cache clean --force
判断方法很简单:"这条清理命令清掉的东西,是同一条 RUN 产生的吗?" 不是,就等于没清。
体积治理的完整清单(精简基础镜像、distroless、静态编译到
scratch)与镜像供应链硬化(按 digest 固定、SBOM、签名)属于发布产物视角,在《构建打包与部署上线》第 02 章 展开。本章只讲缓存与层的机制,不重复那份清单。
五、多阶段:两个阶段,两条不同的边界
多阶段构建解决的问题是:构建需要的东西,和运行需要的东西,不是同一套。 编译器、devDependencies、构建缓存属于前者;产出的文件和运行时依赖属于后者。
# ---- 阶段一:builder,允许臃肿,允许有编译器 ----
FROM node:22-alpine AS builder
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci # 装全部依赖(含 devDependencies)
COPY . .
RUN npm run build # 产出物写进 /src/dist
# ---- 阶段二:runtime,只要产出物 + 运行时依赖 ----
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=builder /src/dist ./dist # ← 唯一跨越阶段边界的通道
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD node -e "require('http').get('http://127.0.0.1:3000/healthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
STOPSIGNAL SIGTERM
CMD ["node", "dist/index.js"]
阶段之间的边界是显式的:只有 COPY --from=builder 会把东西带过去,其他一律不带。这条性质有两个使用后果:
- builder 阶段里的一切都是"没被带过去"的——它的源码、devDependencies、构建缓存都不出现在最终镜像里。所以"最终镜像里有没有编译器"这个问题,答案是由你有没有(以及在哪个阶段)装它决定的,而不是由基础镜像决定的。
STOPSIGNAL/USER/HEALTHCHECK/CMD写在最后那个阶段。写在 builder 阶段是没有意义的——最终镜像的 config 取自最后一个阶段。这是多阶段里很容易写错的一处:把USER写在了 builder,于是运行时还是 root。
两个 Dockerfile 的行级对照
同一个应用,两种写法(本机静态走读,行号取自实验文件):
| 关注点 | Dockerfile.single(4 行有效指令) |
Dockerfile.multi |
差异的后果 |
|---|---|---|---|
| 基础镜像 | L1 FROM node:22 |
L1/L11 node:22-alpine AS builder / AS runtime |
alpine 体积小,但用 musl(见 03 章) |
| 依赖清单 | 无单独 COPY,靠 L5 COPY . . |
L5/L18 COPY package.json package-lock.json ./ |
multi 让依赖层与源码层解耦 |
| 装依赖 | L7 RUN npm install(全量,含 dev) |
L6 npm ci;L19 npm ci --omit=dev |
最终镜像不含 devDependencies |
| 源码进最终镜像 | 是(L5 把 src/ 复制进去了) |
否(只 L17 复制 dist/) |
single 的镜像里带着源码 |
| 用户 | 未设置(root) | L21 USER node |
见下 |
| 健康检查 | 无 | L25–26 HEALTHCHECK |
见 06 章 |
| 停止信号 | 未设置(默认 SIGTERM) | L28 STOPSIGNAL SIGTERM |
显式声明,见 06 章 |
| 启动命令 | L11 CMD ["node","src/index.js"] |
L30 CMD ["node","dist/index.js"] |
两者都是 exec 形式(见 06 章) |
"未设置用户"这一行的后果值得单独说:容器默认以 root 运行。如果这个容器被攻破并逃逸,攻击者拿到的是宿主上的 root。USER node 不能阻止逃逸,但它降低逃逸之后的权限——这是成本几乎为零、收益明确的一项,没有理由不做。
六、缓存带来的假象:它在骗你
缓存有个副作用必须提醒:它会掩盖"你的 Dockerfile 其实缺了一步"这类问题。
典型场景:你在本地反复构建,依赖层一直命中,所以一直没发现 COPY 的清单里漏了 package-lock.json——直到 CI 上全新构建,才在那一次失败。
判断方法只有一个,且必须定期做:
docker build --no-cache -t myapp:probe .
禁用缓存后能构建成功,才说明这份 Dockerfile 是自洽的。 凡是"只在 CI 上炸"或"只在新同事机器上炸"的构建问题,先怀疑缓存掩盖。
(CI 上的缓存怎么接——registry cache 与 --mount=type=cache 的分工——在《构建打包与部署上线》第 02 章 第四节,本章不重复。)
生产边界
| 教学替身 | 真实替换点 | 要注意什么 |
|---|---|---|
单机 docker build 看缓存命中 |
CI 的 registry cache / BuildKit inline cache | CI 每次是新机器,冷启动下命中率天然低于本地;拿本地构建时间评估 CI 时间是常见误判 |
| 手工统计构建耗时分布 | 把每个阶段的耗时做成流水线指标 | 只看总时长会漏掉"哪一层变了",而这是唯一能指出该改哪一行的信息 |
| 示例里 4 分 41 秒的依赖安装 | 你项目实际的依赖树 | 数值随依赖规模变化,要读的是"这段耗时是否本可以被复用",不是这个秒数 |
动手
- 拿一个现有 Dockerfile,把它拆成指令序列(每行一条,展开
RUN里的&&链); - 对每条指令标注:它的输入会不会常常变化(低 / 中 / 高);
- 检查顺序是否单调(从低到高)。若有"高"排在"低"之前,把它标出来——那就是可以被优化掉的重复工作;
- 判断标准:能回答"我改一行业务代码,会重建哪几层",并给出预计被浪费的秒数;
- 完成标志:改完顺序后,用两次构建(先改源码、再改依赖清单)分别记录耗时,能说出两次的差异来自哪一层。
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
把 COPY . . 移到 RUN npm ci 之前 |
改一行源码后的构建耗时 | 依赖被重新安装——这一条如果只在 CI 上测,效果最明显 |
把 rm -rf 清理拆到独立 RUN |
镜像体积 | 不降反增的根源:层是叠加的,后层删除不减小前层体积 |
docker build --no-cache |
是否仍能构建成功 | 缓存会掩盖缺失的文件与隐式的环境依赖 |
把 USER node 写在 builder 阶段 |
最终镜像里 id 的输出 |
最终 config 取自最后一个阶段,写错阶段等于没写 |
| 在上下文里保留不断变化的日志文件后构建两次 | COPY 层的命中情况 |
COPY 的指纹是内容,会变的文件等于缓存杀手(接 01 章) |
自测
- 缓存键的输入有哪两部分?为什么"父层的缓存键"这一项会导致"断一环废后续全部"?
RUN apt-get update && apt-get install -y x && rm -rf /var/lib/apt/lists/*为什么要写成一条RUN?如果拆成三条,"删掉 apt 缓存"这条的收益是多少?- "把两条命令合并进同一个
RUN可以省一层、省体积"——这句话哪一半对、哪一半要看情况?请用"装依赖"和"改代码"这两个例子说明。 - 多阶段构建里,
USER写在哪里才不会失效?为什么? - 一个新同事说"我本地构建一直没问题,你的 Dockerfile 在 CI 上炸了,应该是 CI 环境的问题"。列出你能验证这个说法的一个命令和一条判据。
现在能解释什么
- 缓存是链式的:一层失效,它之后的所有层都重做——这是"顺序比指令内容更重要"的机制根源。
COPY的指纹是文件内容,所以上下文里的易变文件(日志、.git)会持续销毁缓存。- 排序判据是输入的变化频率,且"装依赖"必须与"业务代码"分层。
- 层叠加 ⇒ 清理必须与产生在同一条
RUN内;多阶段的边界由COPY --from显式划定,最终镜像的配置取自最后一个阶段。 - 缓存会掩盖 Dockerfile 的不完整,所以要定期
--no-cache验证。
下一章把这份顺序用到一份真的能跑起来的 Dockerfile 上——包括 npm ci 会在什么情况下直接让构建失败。