Docker 工程化实践系列 · 第 3 篇
引言:改了一行代码,为什么要重装整个项目
你刚改完一个 Bug,只动了一行 console.log,重新构建镜像时却发现 Docker 又开始下载几百个 npm 包了。每次构建都要等上好几分钟,明明只是改了点业务逻辑,为什么依赖安装也要重来一遍?
问题出在 Dockerfile 的写法上。很多人能写出”可以工作”的 Dockerfile,但要写出”构建快”的 Dockerfile,就需要理解 Docker Build Cache 的工作机制——它不是一个黑盒魔法,而是严格遵循一套可预测的规则。本文将从缓存的基本原理出发,逐步展开到实际项目中的优化策略。

一、Build Cache 是什么
1. 缓存的来源:镜像分层
上一篇文章讲过,Docker 镜像由多个只读层组成,COPY、RUN 等指令每次执行都可能产生文件系统变更。Docker 在构建镜像时,会按照 Dockerfile 从上到下的顺序逐一执行每条指令,并检查:当前这个构建步骤的输入和结果,是否和之前某次构建完全一样? 如果一样,就直接复用之前的结果,跳过实际的执行过程。这就是 Build Cache。
为了方便理解,本文把 Dockerfile 中每条可能产生缓存的指令称为一个”构建步骤”或”缓存节点”——注意,构建日志中显示的步骤编号与最终镜像的文件系统层数量并不总是严格一一对应(比如 WORKDIR、ENV 等指令通常只记录元数据而不产生文件系统层),但在讨论缓存行为时,按步骤来理解是完全够用的。
举个例子,一个标准的 Node.js Dockerfile:
FROM node:22 # 步骤 1: 基础镜像WORKDIR /app # 步骤 2: 工作目录COPY package.json ./ # 步骤 3: 依赖描述文件RUN pnpm install # 步骤 4: 安装依赖COPY . . # 步骤 5: 业务代码RUN pnpm build # 步骤 6: 构建产物如果两次构建之间 package.json 没有任何变化,那么步骤 1 到步骤 4 都可以直接复用缓存,只有步骤 5 和步骤 6 需要重新执行。反过来,如果 package.json 发生了变化,那么从步骤 3 开始往后的所有步骤都需要重新构建。
2. 缓存如何判断命中
Docker 判断一个步骤能不能复用,不是靠”智能分析”,而是靠比较输入:对于 COPY 指令,Docker 会比较被复制文件的内容(校验和)是否发生变化;对于 RUN 指令,Docker 会比较命令字符串本身是否改变,同时还会综合该步骤之前的构建状态、挂载(mount)和构建参数(--build-arg)等相关输入。 如果指令和所有相关输入都没有变,缓存就命中;变了,缓存就失效。
不过有一个重要的细节需要注意:Docker 不会自动检查 RUN 指令访问的远程资源是否更新。 比如 RUN apt-get update,即使软件仓库中的包版本已经变化,只要缓存仍然有效(命令字符串没变、前面的构建步骤没变),Docker 就可能继续复用之前的结果,而不会主动去拉取最新的软件包索引。这在需要确保依赖为最新版本的生产构建中需要特别留意——必要时可以使用 --no-cache 或在命令中显式打破缓存。

二、缓存失效的连锁反应
1. 链式依赖规则
Build Cache 最重要的规则只有一条:一旦某一层失效,它后面的所有层都必须重新执行。 因为后面的每一层都以前一层的输出为基础——前一层变了,后续层的”地基”就不同了,即使后续层的指令本身没有改,也不能直接用以前的缓存。
这就是为什么把 COPY . . 放在 RUN pnpm install 前面是一个非常糟糕的做法:
# ❌ 糟糕的写法COPY . .RUN pnpm install这样一来,你改一行日志代码 → COPY . . 检测到文件变化失效 → RUN pnpm install 也必须重新执行 → 每次构建都重装几百个依赖包,即使依赖本身完全没变。
2. 正确写法:稳定内容在前,变化内容在后
正确的做法是把不容易变的内容放前面,容易变的内容放后面:
# ✅ 好的写法COPY package.json pnpm-lock.yaml ./RUN pnpm install --frozen-lockfileCOPY . .RUN pnpm buildpackage.json 和 pnpm-lock.yaml 只在添加或更新依赖时才会变化,平时非常稳定。把它们单独复制并安装依赖放在前面,意味着日常的业务代码修改最多只会让最后两层失效——依赖安装那一步永远命中缓存。构建时间从每次几分钟缩短到十几秒,就是靠这一条简单的顺序调整。
3. .dockerignore 保护缓存
有一个容易被忽略的细节:COPY . . 会复制构建上下文中的所有文件,包括那些跟构建根本无关的内容——node_modules、.git、.env、日志文件、测试覆盖率报告等等。这些文件一旦发生变化(比如跑了测试生成了新的 coverage 报告),也会导致 COPY . . 的缓存失效。
.dockerignore 就是为了解决这个问题:
node_modules.git.envdistcoverage*.log# 如果构建过程不需要文档类文件,再考虑排除# README.md# docs/告诉 Docker 这些文件不要进入构建上下文,它们的变化就不会影响缓存。但需要注意,.dockerignore 的配置要根据项目的实际情况来定——比如有些项目会在构建时使用 README、将其打进 npm 包、或者在 monorepo 工具链中被读取,这时就不能一概而论地把它排除。一个典型的例子:你更新了 README,如果它不在 .dockerignore 中,每次改 README 都会导致 COPY . . 的缓存失效;如果你的构建过程确实不需要 README,排除它可以避免无意义的缓存失效。
三、pnpm 项目中的缓存进阶
1. 利用 BuildKit 缓存挂载
除了 Dockerfile 层面的缓存优化,BuildKit(Docker 新一代构建引擎,现代 Docker 默认启用)还提供了更精细的缓存控制。以 pnpm 为例,它的包存储(store)默认在 ~/.local/share/pnpm/store,每次 pnpm install 都要从网络下载包——即使同一个包在不同的项目间反复安装。
BuildKit 的 --mount=type=cache 可以把这个目录挂载为持久缓存。需要注意的是,pnpm store 的实际路径可能受环境变量和 pnpm 版本影响,更稳妥的做法是显式指定 store 目录:
ENV PNPM_HOME="/pnpm"ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm config set store-dir /pnpm/store \ && pnpm install --frozen-lockfile通过 pnpm config set store-dir 显式指定 store 路径,再让 BuildKit 的 cache mount 挂载同一个路径,可以确保 pnpm 和 BuildKit 操作的是同一个目录。如果你能确认镜像中 pnpm 的默认 store 路径,也可以简化写法,但核心原则不变:target 必须与当前镜像中 pnpm 实际使用的 store 路径一致。
2. 查看缓存是否生效
构建时如果看到步骤输出中有 CACHED 标记,说明该步骤使用了缓存。如果想确认缓存是否真的在工作,可以用 docker build --no-cache . 来完全禁用缓存重新构建一次,对比两种构建的耗时差异。
四、实际项目优化示例
1. 优化前
FROM node:22WORKDIR /appCOPY . .RUN pnpm installRUN pnpm buildCMD ["pnpm", "start"]问题:每次修改代码 → COPY . . 失效 → pnpm install 重新执行 → 每次构建都要跑完所有步骤,即使你只改了一个字符串。
2. 优化后
FROM node:22WORKDIR /appENV PNPM_HOME="/pnpm"ENV PATH="$PNPM_HOME:$PATH"COPY package.json pnpm-lock.yaml ./RUN corepack enableRUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm config set store-dir /pnpm/store \ && pnpm install --frozen-lockfileCOPY . .RUN pnpm buildCMD ["pnpm", "start"]效果:修改业务代码 → COPY package.json 和 pnpm install 仍然命中缓存 → 只重新执行 COPY . . 和 pnpm build。每次构建节省的时间取决于依赖数量——依赖越多,效果越明显。
对比图

五、常见误区
误区一:Docker 会自动判断代码有没有变化。 实际上 Docker 判断的是构建输入(文件内容、命令字符串)有没有变化,它不”理解”你的代码逻辑。
误区二:RUN 指令合并得越少越好,或者拆得越多越好。 比如把 RUN npm install 和 RUN npm build 合并成 RUN npm install && npm build。这样确实减少了步骤数,但也降低了缓存粒度——如果依赖没变但代码变了,合并之后整条命令都要重跑。反过来,完全不合并、每个操作都单独一个 RUN,虽然缓存粒度细,但会增加最终镜像的层数。
其实真正的关键不在于”RUN 多还是少”,而在于输入是否被合理拆分。下面两种写法虽然 RUN 数量一样,但缓存效果天差地别:
# ❌ 缓存差:依赖文件和源码混在一起复制COPY . .RUN pnpm install && pnpm build
# ✅ 缓存好:依赖文件和源码分开复制,各自独立缓存COPY package.json pnpm-lock.yaml ./RUN pnpm installCOPY . .RUN pnpm build第一种写法缓存差的根本原因不是 && 合并了两个命令,而是 COPY . . 把变化频率完全不同的依赖文件和源码混在了一起——改一行代码就让整个步骤失效,后面不管是一个 RUN 还是两个 RUN 都必须全部重来。第二种写法缓存好的核心原因也不是”拆成了两个 RUN”,而是依赖描述文件和源码被分开复制,各自拥有了独立的缓存节点。缓存优化追求的是合理拆分输入,而不是机械地数 RUN 的个数。
误区三:镜像越小越好。 镜像大小是重要的,但不是唯一的优化维度。构建速度、安全性、可维护性同样重要。工程优化追求的是整体最优,而不是某个单一指标的极致。
六、总结
Docker Build Cache 的核心思想很简单:Docker 会复用没有发生变化的镜像层,避免重复执行构建步骤。影响缓存效率的关键因素有三个:Dockerfile 中指令的顺序(不变的内容在前,变化的内容在后)、Layer 的粒度设计(在减少层数和保持缓存粒度之间权衡)、以及 .dockerignore 的正确配置(排除无关文件,减少不必要的缓存失效)。
掌握了 Build Cache,你的 Dockerfile 就从”能构建”升级到了”高效构建”。但还有另一个重要问题没有解决:镜像构建完了,体积太大怎么办?下一篇将介绍多阶段构建——把编译环境和运行环境分离,让生产镜像只包含真正需要的东西。
Some information may be outdated