bigywater.tech
Docker 系列 · 第三篇

Dockerfile 里一条 COPY 指令,真的会变成一个独立的层吗?改了中间一条,后面的层会不会跟着重建?

还是用系列一直沿用的 FROM scratch 套路——写一个带 3 条 COPY 指令的 Dockerfile,本机真实构建、真实改动其中一个文件重新构建,用 docker history 和真实的构建日志逐条核对"层"到底是怎么划分的、缓存到底在什么粒度失效。

3 个独立层
本机真实实测:3 条 COPY 指令,docker history 里是 3 个独立的、各自 2.35MB 的层
改一处,后面全炸
本机真实实测:改中间一条 COPY 的源文件,它自己和它之后的每一层全部重建,前面的层完全不受影响

真实源码:镜像规范里,"层"就是按顺序叠加的一份列表

OCI 镜像规范对"层"的定义非常朴素——一个有序列表,从下标 0 开始依次叠加到一个空目录上。

OCI Image Spec · manifest.md · image-spec @ v1.1.0 The final filesystem layout MUST match the result of applying the layers to an empty directory. Layers MUST then follow in stack order (i.e. from layers[0] to layers[len(layers)-1]), with the base layer at index 0.

没有什么复杂的合并算法——按顺序,一层一层地把每一层记录的文件变化(新增/覆盖/删除)应用到上一层的结果上。docker history 从上到下列出的每一行,对应的就是这份 layers 列表从后往前的一个条目。

真实实测:3 条 COPY,3 个真实的独立层

写一个 Dockerfile,把同一个静态二进制(内容完全相同,只是文件名不同)COPY 三次到不同路径。

真实实测
FROM scratch
COPY binA /binA
COPY binB /binB
COPY binC /binC
ENTRYPOINT ["/binA"]

$ docker build -t layertest:v1 .
$ docker history --no-trunc --format '{{.Size}}  {{.CreatedBy}}' layertest:v1
0B ENTRYPOINT ["/binA"] 2.35MB COPY binC /binC # buildkit 2.35MB COPY binB /binB # buildkit 2.35MB COPY binA /binA # buildkit

3 条 COPY 指令,真实产生了 3 个独立的层,每层大小精确等于被拷贝文件的大小——即便三个文件内容一模一样,目标路径不同也会让它们成为 3 个不同的层(层的内容是"这一层相对上一层新增/修改了哪些路径",路径本身就是内容的一部分)。ENTRYPOINT 这种纯元数据指令,真实产生的是 0B 的层——不改文件系统,只改镜像配置(ENV/LABEL 试过也是同样结果,真实验证过)。docker inspect 里 RootFS.Layers 字段能看到真实对应的 3 个层摘要,跟上面 OCI 规范说的"有序列表"完全对得上。

真实实测:原样重新构建,全部命中缓存

真实实测
$ docker build -t layertest:v1b .   # Dockerfile 和源文件都没动
[1/3] COPY binA /binA CACHED [2/3] COPY binB /binB CACHED [3/3] COPY binC /binC CACHED

没有任何改动,3 层全部命中缓存——构建引擎不会重新读取、重新打包这几个文件,直接复用上一次构建产生的层。

真实实测:改中间一条 COPY 的源文件,后面的层全部失效

往 binB(第二条 COPY 的源文件)里追加一行内容,重新构建。

真实实测
$ echo "modified" >> binB
$ docker build -t layertest:v2 .
[1/3] COPY binA /binA CACHED [2/3] COPY binB /binB (重新执行,没有 CACHED 标记) [3/3] COPY binC /binC (重新执行,没有 CACHED 标记)

binC 这个文件本身一个字节都没有变,但它对应的第 3 层还是被真实重新构建了——构建缓存是一条链,只要链上某一环的输入变了,它自己和它之后的每一环都要重新算,不会跳过中间去单独判断"我自己的文件没变所以我不用重建"。这也是为什么 Dockerfile 里常见的建议是"把最少变化的指令放在最前面(比如安装依赖),把最常变化的指令放在最后面(比如拷贝业务代码)"——放前面的部分能一直吃到缓存,不会被后面频繁变化的部分连累。

真实实测:反过来,只改最后一条,前面的层完全不受影响

把 binB 还原,改成只动最后一条 COPY 的源文件 binC。

真实实测
$ echo "modified-c" >> binC   # 这次只改最后一条
$ docker build -t layertest:v3 .
[1/3] COPY binA /binA CACHED [2/3] COPY binB /binB CACHED [3/3] COPY binC /binC (重新执行)

这次前两层都真实命中了缓存,只有 binC 自己这一层重建——失效方向是单向的:从第一处发生改动的指令开始,往后传染,不会往前传染。这两组实验合起来,就是"指令顺序决定缓存粒度"这句话背后真实发生的事情。

演示:同一条缓存链,"全部命中"和"从中间失效"两种真实场景

对应上面 3 层的真实实验,切换看"原样重建"和"改了中间一条"两种真实发生过的场景。

构建缓存链 · 真实场景对照
全部命中
层来源指令状态

数据直接取自上面真实实测的两次构建日志(原样重建 3 层全 CACHED;改 binB 后 binB/binC 两层重建、binA 仍 CACHED),演示动画只是把"失效从改动点往后传染"这条规则逐步呈现。

参考与说明

  • 本文全部真实实验(3 条 COPY 指令产生 3 个独立层、ENV/LABEL/ENTRYPOINT 是 0 字节元数据层、原样重建全部命中缓存、改中间一条源文件导致其自身及后续所有层重建而更早的层不受影响、只改最后一条时前面的层保持缓存)均在本机真实运行的 Docker Desktop(BuildKit 构建引擎)上完成,用的是跟前两篇一样的 FROM scratch + 本机交叉编译静态二进制技巧,全程零网络镜像拉取,数据未做删改。
  • 源码引用(层按顺序叠加到空目录的规则)取自 opencontainers/image-spec 仓库 v1.1.0 标签的 manifest.md——这是 OCI 镜像格式的官方规范文档,Docker/containerd/BuildKit 对镜像层的实现都要遵守这份规范。
  • 演示动画的缓存链状态直接取自真实实测的两次构建日志,没有额外模拟或编造的层状态。
  • 没有涉及:BuildKit 具体怎么计算一条 COPY 指令的缓存键(源文件内容的 checksum 树,细节在 BuildKit 自己的 contenthash 包里,比这里展示的"要么全命中要么从某处开始全部重建"模型更精细,比如同一目录下只有部分文件变化时的处理)、.dockerignore 怎么影响进入构建上下文的文件集合、层的实际存储格式(tar diff + gzip/zstd 压缩)、镜像 push/pull 时层的去重传输机制。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号