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

两条判断规则:

  1. 按变化频率从低到高排:基础镜像 → 系统包 → 依赖清单 → 装依赖 → 业务代码 → 构建。变化越少的东西越靠下(越早),被复用的机会越大。
  2. 把"装依赖"和"业务代码"隔开。这两件事的变化频率相差一个数量级,放在同一层是浪费——同层的东西共享同一个缓存键。

关于第 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 会把东西带过去,其他一律不带。这条性质有两个使用后果:

两个 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 秒的依赖安装 你项目实际的依赖树 数值随依赖规模变化,要读的是"这段耗时是否本可以被复用",不是这个秒数

动手

  1. 拿一个现有 Dockerfile,把它拆成指令序列(每行一条,展开 RUN 里的 && 链);
  2. 对每条指令标注:它的输入会不会常常变化(低 / 中 / 高);
  3. 检查顺序是否单调(从低到高)。若有"高"排在"低"之前,把它标出来——那就是可以被优化掉的重复工作;
  4. 判断标准:能回答"我改一行业务代码,会重建哪几层",并给出预计被浪费的秒数;
  5. 完成标志:改完顺序后,用两次构建(先改源码、再改依赖清单)分别记录耗时,能说出两次的差异来自哪一层。

故障注入

注入方式 观察什么 说明的现象
把 COPY . . 移到 RUN npm ci 之前 改一行源码后的构建耗时 依赖被重新安装——这一条如果只在 CI 上测,效果最明显
把 rm -rf 清理拆到独立 RUN 镜像体积 不降反增的根源:层是叠加的,后层删除不减小前层体积
docker build --no-cache 是否仍能构建成功 缓存会掩盖缺失的文件与隐式的环境依赖
把 USER node 写在 builder 阶段 最终镜像里 id 的输出 最终 config 取自最后一个阶段,写错阶段等于没写
在上下文里保留不断变化的日志文件后构建两次 COPY 层的命中情况 COPY 的指纹是内容,会变的文件等于缓存杀手(接 01 章)

自测

  1. 缓存键的输入有哪两部分?为什么"父层的缓存键"这一项会导致"断一环废后续全部"?
  2. RUN apt-get update && apt-get install -y x && rm -rf /var/lib/apt/lists/* 为什么要写成一条 RUN?如果拆成三条,"删掉 apt 缓存"这条的收益是多少?
  3. "把两条命令合并进同一个 RUN 可以省一层、省体积"——这句话哪一半对、哪一半要看情况?请用"装依赖"和"改代码"这两个例子说明。
  4. 多阶段构建里,USER 写在哪里才不会失效?为什么?
  5. 一个新同事说"我本地构建一直没问题,你的 Dockerfile 在 CI 上炸了,应该是 CI 环境的问题"。列出你能验证这个说法的一个命令和一条判据。

现在能解释什么

下一章把这份顺序用到一份真的能跑起来的 Dockerfile 上——包括 npm ci 会在什么情况下直接让构建失败。

进入 keel 阅读