先记住这个答案
因为Dockerfile的每条指令都会生成一个缓存层,构建时指令及其依赖的文件完全没变才会复用缓存。如果把源码和依赖清单一起COPY,任何源码改动都会使该层以及后续安装依赖的RUN层全部失效,需要重新下载安装。先单独COPY依赖清单并执行安装,再COPY源码,则依赖安装层只以清单文件为缓存键,源码变化不会影响它。这样依赖安装被复用,构建更快。
- 先COPY依赖清单,让依赖层独立于源码。
- 源码改动只使后续层失效,依赖层可复用。
- 依赖清单不变才能命中缓存,否则仍会重建。
缓存按层匹配与失效传播
Docker构建时每条指令对应一个缓存节点,若当前指令文本和它读取的构建上下文文件(对COPY是源文件)与上次完全一致,则该层直接复用。对RUN指令,其输入包括上一层的输出和指令字符串,因此若前一层发生变化,后续层必然失效。
这意味着,如果把源码和依赖清单一起COPY,源码文件任何一个字节变化都会使该COPY层变化,紧接着的依赖安装RUN层因上层变化而失效,缓存全部作废。而若先COPY依赖清单,只要清单不变,RUN的输入(该COPY层和指令)相同,缓存就能命中。
Node.js项目:依赖稳定只改业务代码
以npm为例,项目含package.json与package-lock.json,依赖固定,源码src目录频繁改动。推荐写法:COPY package*.json ./,RUN npm ci --production,再COPY src ./,RUN npm run build。每次迭代只改src,前一COPY和RUN层保持缓存命中,npm ci不重新下载。
若先COPY . .再RUN npm ci,则任何src变动都会使COPY层变化,导致npm ci层重建,每次构建都要重复下载和整理node_modules,构建时长可能从几秒增到几分钟。实际表现是依赖层重建,且网络耗时主导。
此顺序优化的应用边界
该策略的前提是依赖清单文件本身不常变化,且源码与清单在构建上下文中可分离。若依赖也频繁变更,或清单文件被错误地包含在宽泛的COPY . .中,则依赖层同样频繁失效。对源码极大而依赖很小的编译项目,保存依赖缓存仍是收益显著。
另一个限制:COPY的源文件集合若包含了无关内容(如.git或node_modules),它们与依赖清单一起复制也会使层变化。必须用.dockerignore剔除不稳定且无关的文件。若需要严格可再现构建,还应固定基础镜像摘要,避免FROM层漂移影响后续缓存。
容易答错的地方
- 认为只要分开写COPY就能保证缓存命中
- 实际只有依赖清单未变时才会命中,如果每次npm install前都改了package.json,则依然要重建。顺序优化只减小了触发失效的范围,不能消除依赖本身变更的开销。
- 所有文件先COPY再安装更简单,性能不重要
- 对小项目可能无所谓,但大型项目依赖安装可能占几分钟甚至更久。当构建频率高,每次全量重装导致CI慢,浪费计算资源,先复制依赖清单是低成本高性能写法。
面试官还会怎么问?
如果源码和依赖放在不同子目录,是否必须分开COPY?
不一定。只要COPY的源集合彼此独立,先COPY依赖目录再RUN安装也可以。关键是让依赖安装指令的输入不包含高频变化的源码文件,划分依据以缓存命中为准。
BuildKit的缓存挂载能替代调整COPY顺序吗?
不能完全替代。缓存挂载可缓存包管理器内容,但失效的RUN层仍需重新执行指令;先COPY依赖清单则能跳过整个RUN。两者可组合,先排序再用--mount提高重建效率。
如何确认当前构建命中了哪些缓存?
构建时观察输出,命中层显示CACHED字样,未命中则显示具体的执行步骤。也可用docker buildx build --progress=plain查看到层日志,或结合BuildKit的缓存分析工具。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。