还是用系列一直沿用的 FROM scratch 套路——写一个带 3 条 COPY 指令的 Dockerfile,本机真实构建、真实改动其中一个文件重新构建,用 docker history 和真实的构建日志逐条核对"层"到底是怎么划分的、缓存到底在什么粒度失效。
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 列表从后往前的一个条目。
写一个 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 层全部命中缓存——构建引擎不会重新读取、重新打包这几个文件,直接复用上一次构建产生的层。
往 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),演示动画只是把"失效从改动点往后传染"这条规则逐步呈现。