在日常开发中,Docker 镜像第一次构建较慢并不奇怪,但修改一行源码后,依赖安装、编译等耗时步骤全部重新执行,就需要检查缓存是否被意外破坏。理解缓存的关键不是记住几个参数,而是弄清楚:每条构建指令依赖哪些输入,以及这些输入是否发生了变化。🔍
一、Docker 构建缓存如何工作
Docker 按照 Dockerfile 中的顺序执行指令。构建器处理当前指令时,会查找可复用的缓存记录;如果指令、父层以及相关输入均能匹配,就直接复用结果。某一步无法匹配后,其后续步骤通常也要重新执行,因为它们所依赖的父层已经改变。
对于普通 RUN 指令,缓存判断主要与指令内容及其父层有关,并不会因为软件仓库出现新版本就自动失效。例如,连续两次执行相同的包安装指令,第二次可能继续命中旧缓存。对于 COPY、ADD 以及使用绑定挂载的 RUN 指令,构建器还会根据相关文件的元数据计算校验信息。Docker 对缓存失效规则的详细说明可参考官方缓存失效文档 citeturn1search2。
二、缓存为何经常“莫名失效”
1. 过早执行 COPY . .
最常见的问题,是在安装依赖之前复制整个项目。只要构建上下文中的任意相关文件发生变化,这个 COPY 层就可能失效,后面的依赖安装和编译步骤也会连锁重建。即使改动的只是说明文档,也可能触发本不必要的耗时操作。
FROM node:22
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
更合理的顺序是先复制依赖清单并安装依赖,再复制频繁变化的源码:
FROM node:22
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
这样修改业务代码时,依赖清单未变化,npm ci 对应的缓存仍有机会被复用。原则可以概括为:稳定、昂贵的步骤靠前,频繁变化的步骤靠后。🚀
2. 构建上下文包含无关文件
如果没有正确配置 .dockerignore,.git、node_modules、测试报告、日志、编辑器配置和本地构建产物都可能进入构建上下文。它们不仅增加传输和扫描开销,还可能导致 COPY 相关缓存频繁变化。
.git
node_modules
dist
coverage
*.log
.env
.idea
.vscode
忽略规则应结合项目实际情况编写,不能机械照抄。例如,多阶段构建需要复制 dist 时,就不应将它排除。需要特别注意,包含凭据的文件不应仅依赖忽略规则保护,而应避免进入镜像构建流程。
3. 动态参数主动破坏缓存
BUILD_DATE、随机数、当前时间、频繁变化的提交标识等 ARG,如果参与某个 RUN 指令,就会改变缓存输入。动态值放置得越靠前,受影响的后续层越多。确实需要写入版本信息时,最好把相关指令放在构建末尾,避免让依赖下载和编译阶段一起失效。
4. 基础镜像或构建平台发生变化
修改 FROM 标签、拉取到不同的基础镜像内容,或者在 amd64 与 arm64 等平台之间切换,都可能无法复用原有缓存。为了提升可重复性,可以采用明确的版本标签;对供应链一致性要求更高时,还可按团队策略使用镜像摘要固定基础镜像。
5. CI 环境没有保存缓存
本地构建缓存通常保存在当前构建器中,而许多 CI Runner 用完即销毁。新任务启动后看不到上一轮缓存,即使 Dockerfile 完全没变,也只能重新构建。此时问题不是“缓存键失效”,而是缓存根本不可访问。🧩
三、提高缓存复用率的实践
1. 使用 BuildKit 缓存挂载
镜像层缓存失效后,并不意味着包管理器必须重新下载所有内容。BuildKit 的缓存挂载可以为 npm、Go、Maven、pip 或 apt 等工具保留下载缓存。例如:
RUN --mount=type=cache,target=/root/.npm npm ci
即使 package-lock.json 改变导致 npm ci 重新运行,已经下载且仍可用的软件包也可能继续复用。缓存挂载主要用于加速构建过程,其内容不应被当作最终镜像必须依赖的数据。缓存挂载、绑定挂载及指令排序的建议可参考Docker 缓存优化指南 citeturn1search1。
2. 在 CI 中导入和导出外部缓存
对于无状态 CI,应使用 buildx 将缓存保存到镜像仓库或平台支持的缓存后端。一个通用的仓库缓存思路如下:
docker buildx build \
--cache-from type=registry,ref=registry.example.com/team/app:buildcache \
--cache-to type=registry,ref=registry.example.com/team/app:buildcache,mode=max \
--tag registry.example.com/team/app:latest \
--push .
cache-from 负责导入历史缓存,cache-to 负责导出本次结果,两者缺一都可能造成下一次构建无法复用。不同项目或镜像应使用独立缓存引用,避免多个流水线互相覆盖;并发任务还应关注缓存写入冲突和仓库权限。
3. 通过多阶段构建缩小影响范围
多阶段构建能把依赖下载、源码编译和运行环境拆开。运行镜像只复制最终产物,不必携带编译器、源码及包管理器缓存。它不能自动解决所有缓存问题,但有助于明确阶段边界,并减少无关变化对最终镜像的影响。
4. 有针对性地刷新缓存
排查问题时,不建议习惯性使用 --no-cache,因为它会放弃所有可复用结果。若只是某个阶段需要更新,可通过调整相关输入或使用阶段级缓存控制完成。清理缓存前,也应确认问题究竟是缓存错误、磁盘空间不足,还是 CI 没有正确导入缓存。
四、缓存未命中时的排查清单
- 查看构建日志,定位第一个未命中缓存的步骤,而不是只观察最后失败或最慢的步骤。
- 检查该步骤的父层、指令文本、ARG、环境变量及复制文件是否变化。
- 确认 .dockerignore 是否排除了无关文件和本地产物。
- 核对依赖清单是否先于业务源码复制,避免 COPY . . 提前执行。
- 确认构建平台、基础镜像、BuildKit 构建器及构建上下文保持一致。
- 在 CI 中检查外部缓存是否成功导入和导出,并确认缓存引用及权限正确。
- 对包管理器使用缓存挂载,降低镜像层重建时的重复下载成本。
总结
Docker 缓存失效通常可以归结为三类原因:输入发生变化、指令顺序扩大了失效范围,或者当前构建器无法访问历史缓存。实践中,应先缩小构建上下文,再按“低频变化在前、高频变化在后”的原则组织 Dockerfile,并结合 BuildKit 缓存挂载、多阶段构建和外部缓存提升复用率。缓存优化的目标并非让所有步骤永远命中,而是让真正发生变化的部分被准确重建,同时让稳定且昂贵的步骤尽可能复用。✅