先分清两件事
分层只存变化
镜像的文件层一旦生成就保持只读。上一页的镜像与容器关系,在这里细化成了“共享的只读层 + 各自的可写层”。
Dockerfile 是描述如何构建镜像的文本文件。本页只用它辨认变化来源;具体语法留给下一课。
| 指令 | 本页含义 | 对层的影响 |
|---|---|---|
FROM | 选择起点镜像 | 继承它已有的层,不把它们压成一层 |
RUN | 构建时执行命令 | 把命令产生的文件变化记入新层 |
COPY / ADD | 把文件加入镜像;此处 ADD 导入基础文件 | 记录加入的文件 |
CMD / ENV | 设置默认启动命令 / 环境变量 | 只改元数据,即文件以外的配置 |
“每条指令一层”是便于入门的说法。更准确地说:改变文件系统的构建步骤产生文件层;历史中的一行不一定对应一个文件层。 图 1 中的 CMD 没有新增文件。
在图 1 的镜像后加一条 ENV MODE=demo,再加一条 COPY 新文件的指令。它们会各增加一个文件层吗?先写理由。
ENV 只记录环境变量配置,不增加文件层。COPY 加入新文件,会产生文件变化层。因此不能用指令行数直接数出文件层数。
同一个路径在多层里都存在,会看到哪份?
容器看到的是合并后的目录视图:同一路径通常由更上层的版本覆盖显示。底层旧文件仍保留。新增文件会出现;删除标记则会隐藏下层的同名文件。
构建时的 RUN 也遵守这条规则。下一条 RUN 删除上一层加入的大文件,通常不能收回上一层已占的镜像空间。如何安排构建步骤,留到后面的镜像优化课程。
把层栈和终端对上
历史倒着读
docker history 是 docker image history 的别名。默认从最新记录往最旧记录显示,正好对应图 1 从上往下读。
下面原样节选官方文档的输出:只保留表头和 COPY 那一行,其余行省略。镜像必须先存在于本地才能运行此命令;这不是本页实验生成的镜像。
CREATED BY:这次记录来自什么构建操作。SIZE:该步文件差异的大小;这里 COPY 对应图中的222B,不是整个镜像大小。IMAGE:历史关联的镜像 ID。<missing>常见于使用 BuildKit 构建的记录,表示没有可显示的中间镜像 ID,不能据此判断层丢失。BuildKit 是 Docker 的构建后端。CREATED与COMMENT:生成时间与附加说明,不是层的编号。
遇到截断的命令,用 docker history --no-trunc 镜像名。需要和层栈核对时先看来源,再看大小,最后辨认哪些只是配置记录。
只数图 1 选出的五条记录(四个文件变化和顶部 CMD),其中有 个文件系统层。
四条记录写入了文件变化;CMD 只设置默认启动命令。原文还保留了基础镜像继承的 CMD 等记录,图 1 省略了它们。0B 是配置记录的显示值,不表示整个镜像不占空间。
看到上面的 COPY 行,你能得出哪个结论?
missing 在 IMAGE 列;BuildKit 不必为每步保留一个中间镜像 ID。
这是某一步的 SIZE。图 1 下面还有基础文件和 bash 等层。
history 展示各步记录。COPY 的 222B 只描述本步差异;是否有中间镜像 ID 与文件层是否存在是两件事。
容器改变自己的视图
修改先复制
写时复制(copy-on-write,CoW)让容器先共享只读文件,首次修改时才为自己保存变化。下面按 OverlayFS(Linux 上合并显示多层目录的文件系统)的文件级复制过程示意:先复制文件,再改副本;镜像原件保持不变。
- 创建 A:镜像层保持只读,A 获得自己的可写层。
- A 只读 /note:直接使用下层文件,不为这次读取复制它。先猜:A 要修改它时,可以直接覆盖镜像原件吗?
- A 请求写入:找到下层的 /note。为了保留原件,需要先准备副本。
- 把 /note 复制到 A 的可写层。这一步叫 copy-up;此时副本内容仍是 base。
- 把上层副本改成 changed。A 随后读到副本,底层原件仍是 base。先猜:刚从同一镜像启动的 B 会读到哪一份?
- B 仍读到 base。A 的副本、B 的视图和镜像原件彼此没有回写。
新建文件直接写入本容器的可写层。再次修改已经复制上来的文件,继续改这个副本。这里讨论的是容器自身的文件系统;卷和绑定挂载是另一条存储路径,后面的存储模块再讲。
如果 A 执行 rm /note,下层的原件会被删掉吗?随后从同一镜像新建的 B 还能读到吗?
下层原件不会改变。A 的可写层记录删除,把下层路径从 A 的合并视图中隐藏;新建 B 仍能看到原件。OverlayFS 常用 whiteout(删除标记)表达这种隐藏。
Docker 29.x 不一定使用 overlay2
Docker Engine 29.0 起,全新安装默认使用 containerd 镜像存储;它由 snapshotter(快照管理后端)管理层。升级安装可能继续使用原来的存储驱动。
上图是 OverlayFS 的文件级 copy-up 模型;不同后端的复制粒度与实现可能不同。不要据此推断所有后端都按整文件复制,也不要假设本机层目录一定在 /var/lib/docker/overlay2。
在自己的 Docker 上验证
亲手验证
本编写环境没有 Docker。以下是可复制的实验和预期行为,不是实测终端记录。镜像 ID、层哈希、大小和版本输出留给维护者或你本机采集。需要 Linux 容器环境和可用的 Docker 守护进程。
先回顾第一次 docker run。下面会创建一个临时工作目录、拉取 Alpine 并构建两个本地镜像。--rm 在容器退出后删除容器及其可写层;--network none 禁用实验容器的外部网络。命令不挂载宿主目录、不发布端口、不修改宿主网络配置。
1. 做一个含 /note 的镜像
在宿主机的 Linux shell 中执行。FROM 选起点;RUN 写入文件;CMD 设置默认读取命令。-t 给镜像起本地名字;末尾 . 是包含 Dockerfile 的当前构建目录。
lab_dir=$(mktemp -d -t docker-layers-12.XXXXXX)
cd "$lab_dir"
cat > Dockerfile <<'DOCKERFILE'
FROM alpine:3.22
RUN printf 'base\n' > /note
CMD ["cat", "/note"]
DOCKERFILE
docker build -t learning-image-layers:base .
docker history --no-trunc learning-image-layers:base
预期:history 顶部有 CMD 配置记录,再往下有写入 /note 的 RUN。具体文本、时间、大小和基础镜像层数以你的平台为准。alpine:3.22 是标签,内容可能更新;在后续实验中都使用刚构建出的本地镜像。
2. 修改 A,再新建 B
先预测第二个容器会输出什么,再执行。镜像的 CMD 会读 /note;第一条命令额外指定 sh -c,让 shell 执行引号内的修改和读取。
docker run --rm --network none learning-image-layers:base \
sh -c 'printf "changed\n" > /note; cat /note'
docker run --rm --network none learning-image-layers:base
预期输出,未实测:第一条打印 changed,第二条打印 base,各占一行。A 的修改没有进入镜像;B 使用镜像的原件。这个结果验证视图隔离,不能单凭输出测出存储后端的物理复制粒度。
3. 删除也只影响本容器
docker run --rm --network none learning-image-layers:base \
sh -c 'rm /note; if [ ! -e /note ]; then printf "hidden\n"; fi'
docker run --rm --network none learning-image-layers:base
预期输出,未实测:依次打印 hidden 和 base。删除发生在第一个容器自己的视图里;第二个容器仍能读取镜像中的文件。
4. 扩展镜像,比较底层
下面用 base 作为起点,再增加 /extra。-f 选择另一份 Dockerfile。inspect 读取对象信息,--format 只输出指定字段;这里把根文件系统的层列表以 JSON 数组显示出来。
cat > Dockerfile.child <<'DOCKERFILE'
FROM learning-image-layers:base
RUN printf 'extra\n' > /extra
DOCKERFILE
docker build -f Dockerfile.child -t learning-image-layers:child .
docker image inspect --format '{{json .RootFS.Layers}}' learning-image-layers:base
docker image inspect --format '{{json .RootFS.Layers}}' learning-image-layers:child
docker history --no-trunc learning-image-layers:child
预期结构,未实测:child 的层数组以 base 的完整数组开头,并多出写入 /extra 的一层。这些 sha256:… 值是文件系统变化内容的标识,不是 history 的 IMAGE 列;相同前缀说明它们引用相同的底层链。这里不预填哈希值或大小。
你从 child 镜像启动 A,在 A 里把 /note 改为 changed。随后从 base 镜像启动 B。B 应读到什么?
底层是只读的;共享底层不会共享容器的可写层。
--rm 删除的是该容器及其可写层,镜像底层继续存在。
child 继承 base 的底层,但 A 的写入只改变 A 的副本。B 从 base 的只读层开始,所以仍读取 base。
实验结束后
下面只删除本实验的两个镜像标签与临时目录中的两份 Dockerfile。先回到原工作目录;若你在实验目录添加了其他文件,rmdir 会保留该目录并报非空。
docker image rm learning-image-layers:child learning-image-layers:base
rm -- Dockerfile Dockerfile.child
cd -
rmdir -- "$lab_dir"
拉取的 Alpine 镜像和构建缓存可能仍保留。本实验不执行全局清理。
- 一条 history 记录,不一定是一个文件系统层。
- 图自下而上构建,history 默认自上而下显示最新记录。
- 修改先保存自己的副本;删除只隐藏本容器视图中的路径。
- 镜像可共享只读底层,容器的可写层各自独立。
来源与边界
核对于 2026-11-03,适用 Docker Engine 29.x。官方历史示例与本机预期实验已分别标注;本环境没有 Docker,未声称实测 Engine 29.1.3 或 Compose 2.40.3。
- Understanding the image layers:差异层、合并视图、镜像复用。
- Storage drivers:图 1、图 3 的指令与全部大小、history 节选、copy-up 与删除语义。原文中的旧版 image ls 表格没有作为 Docker 29 输出引用。
- docker image history:别名、字段、--no-trunc 与输出格式。
- Dockerfile reference:FROM、RUN、COPY、ADD、ENV、CMD。
- OverlayFS storage driver:文件级复制与删除标记。
- containerd image store:29.0 起新安装的默认后端、快照与磁盘占用口径。
- docker container run、docker image inspect:临时容器与层列表采集。
下一课再系统学习 Dockerfile 指令。本页不要求你预先掌握构建缓存、数据卷或 OverlayFS 的挂载参数。