先记住这个答案
Docker 将每条能生成层的指令与一个缓存键绑定,键包含指令类型、参数、父层摘要及上下文文件的内容校验和。再次构建时,若当前键与历史记录匹配,则直接使用旧层;否则执行指令并新建层,同时使该层之后的所有指令缓存无效,这些指令必须重新执行,即使文本未变。这种机制保证了镜像一致性,也解释了为何调整 COPY 顺序能显著利用缓存。
- 缓存键由指令+父层+上下文文件内容共同决定
- 某层失效后,所有依赖它的下游层全部失效
- 把变化少的文件放靠前 COPY 可最大化缓存复用
逐层匹配:缓存键如何计算与比对
对于每条会形成镜像层的指令(FROM、RUN、COPY、ADD等),Docker会计算一个缓存键。该键由指令类型和参数、当前父镜像层的ID、以及指令所引用的构建上下文文件的校验和组成。例如,COPY一条文件列表时,其内容哈希会被计算进去,RUN则主要依赖指令字符串与父层状态。
构建器按Dockerfile顺序处理指令,每次先根据当前状态计算键,并在本地缓存历史中寻找具有相同键的层。若找到,则将该层作为新的父层并继续下一条,而不执行实际运算;若找不到,则必须执行该指令以生成新层,同时因为父层ID发生变化,所有后续指令的缓存键也必然改变,所以它们全部需要重新执行——这就是失效的级联效应。
docker build --progress=plain . 2>&1 | grep -E "CACHED|RUN|COPY"在含变更的上下文下构建,会看到失效点之后的RUN、COPY重新执行,失效点前的项显示CACHED。
依赖清单与源码分离的缓存优化实践
以一个Node.js项目为例,Dockerfile片段为:先COPY package.json package-lock.json,再RUN npm ci,随后COPY剩余源码。开发中修改业务代码时,第三个指令涉及的上下文文件内容哈希变化,导致该COPY层失效;但前两个指令的缓存键中,父层(基础镜像)和依赖文件均未改动,因此继续命中,无需重跑npm ci。构建时间因此大幅缩短。
如果把所有文件一次性COPY(如COPY . /app),则任意源码修改都会使这个大COPY层失效,它后面的RUN npm ci也必然重新执行。即使依赖列表没变,也要重新下载安装,白白浪费网络和CPU。这说明分层COPY能让不常变的依赖层保持可复用,并且缓存失效的范围被有意识地限制在真正变化的部分。
缓存失效的边界条件与必须接受的代价
缓存匹配要求构建上下文内相关文件内容完全一致。但如果有文件未通过.dockerignore排除而频繁变动,比如本地调试生成的临时文件,就会意外改变COPY指令的校验和,造成缓存频繁失效。需要注意的是,文件权限和所有权等元数据变化也可能被纳入校验,即使内容相同也可能触发失效,但Docker文档并未将所有元数据列入始终对比项,设计时应优先关注内容。
RUN指令若依赖网络资源,其输出无法被Docker预先感知。若包源更新了,但指令文本和父层未变,Docker会复用旧缓存且不检查远程是否变化。这在获取依赖时可能引入过期版本,需要开发者显式固定版本或改用BuildKit的其他机制。强制重建所有层的--no-cache开关会取消全部层缓存,使每条指令都执行,构建时间线性增长,通常只在确认缓存有语义错误时才使用。
容易答错的地方
- 指令文本不变就认为缓存必命中
- 缓存键还包含父层ID和上下文文件校验和。如果之前某层因源码修改失效,即使这条RUN指令完全没变,它的父层已经变化,所以该层也必须重新执行。
- 合并所有COPY指令能减少失效范围
- 把多个COPY合并成一条会让任何文件变化都污染整层,导致其后的关键RUN(如依赖安装)全部重跑。反而应拆分不同频率的文件组,利用多条COPY让低频变化的部分保留缓存。
面试官还会怎么问?
构建缓存能否跨机器直接复用?
默认本地缓存只存在于单台daemon中。若需在CI间转移,可借助docker buildx --cache-to/from或--cache-from,但与题目描述的纯层匹配机制不同,属于独立的缓存导出功能。
为什么COPY比ADD更利于控制缓存?
ADD除复制外还自动解压tar文件或处理URL,其缓存键受源文件内容和格式更多因素影响;COPY语义简单,校验和计算更直白,因此更易预测失效时机。
固定依赖版本是否影响RUN层缓存?
会。若Dockerfile中RUN npm install未锁版本,远程包变化但指令文本不变,可能错误复用旧缓存导致安装非最新包;固定版本(如package-lock)后,变更需改文件,会自然导致缓存失效。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。