Docker 基础体系 · 第 3/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。
Docker 镜像与分层:内容寻址、OverlayFS、缓存和镜像体积
Docker 镜像并不是一个单独的“压缩包”,而是一组由内容寻址对象组成的文件系统变更集合。镜像运行时通常还会被挂载为 OverlayFS 的只读层,并叠加一个容器专属的可写层。构建时,BuildKit 又会把 Dockerfile 指令及其输入转换为可复用的缓存结果。
因此,“镜像为什么能复用”“删除文件后体积为什么不降”“容器写入的数据在哪里”“docker images 显示的大小到底是什么”这几个问题,实际上属于同一条数据链路:
Dockerfile / 构建上下文
│
▼
BuildKit 生成文件系统变更
│
▼
OCI config + manifest + layer blobs
│
▼
镜像存储与内容寻址
│
▼
OverlayFS lower layers + container upper layer
│
▼
容器进程看到的 merged root filesystem
本文讨论 Linux 容器边界,示例以现代 Docker Engine、BuildKit 和 OCI 镜像模型为基础。OCI 规定了镜像和运行时对象的格式与语义,但没有规定 Docker 必须使用 OverlayFS;OverlayFS 是 Linux 上 Docker 常见的存储驱动实现。
1. 先区分四个对象:镜像引用、镜像清单、配置和层
1.1 镜像引用不是镜像内容
下面这些写法表达的是不同强度的身份:
alpine
alpine:3.20
alpine@sha256:...
alpine:3.20 是一个标签引用。标签通常指向某个镜像清单,但标签本身可以在仓库中被重新指向另一个摘要。
摘要引用,例如:
alpine@sha256:abcdef...
指向的是内容摘要对应的对象。只要摘要算法和内容保持不变,该对象的身份就不变。生产环境常用摘要锁定,是为了避免同一个标签在不同时间解析出不同内容。
不过,摘要锁定也不能自动保证整个软件供应链绝对可信:
- 需要信任获取摘要的来源;
- 需要验证镜像签名或供应链证明时,还要使用相应的签名和证明机制;
- 基础镜像中的软件包仓库若没有版本锁定,构建结果仍可能变化。
因此,“标签可变、摘要基于内容”是镜像寻址的基本区别,而不是完整的可重复构建保证。
1.2 OCI 镜像由 manifest、config 和 layer descriptors 连接起来
一个典型的 OCI 镜像索引或镜像清单会引用:
- 一个配置对象
config; - 若干文件系统层
layers; - 每个对象的媒体类型、字节大小和摘要。
逻辑结构可以简化为:
manifest
├── config descriptor ──> config JSON
└── layer descriptor ──> compressed layer blob
├── layer 1
├── layer 2
└── layer 3
配置对象通常包含:
- 架构和操作系统;
rootfs.diff_ids;history;- 默认启动命令、环境变量、工作目录、用户等运行配置。
镜像清单主要负责“有哪些对象以及对象在哪里”,配置对象负责“怎样运行以及层的逻辑关系”。实际存储中的层是 blob,清单通过摘要引用它们。
OCI Image Specification 规定了这类对象及其描述符的格式;Docker Engine、containerd 等组件负责拉取、存储和使用这些对象。
2. 内容寻址:为什么层可以复用
2.1 摘要是对字节内容的身份计算
设一个对象的字节序列为 ,内容摘要为:
内容寻址存储使用 作为对象的逻辑键:
digest -> blob bytes
当两个镜像引用同一个完全相同的 blob 时,存储系统不需要保存两份字节。镜像之间的复用不是因为它们“看起来相似”,而是因为它们引用了相同的内容摘要。
这里的“相同”必须精确到参与摘要的字节。对于层归档来说,以下差异都可能导致摘要变化:
- 文件内容不同;
- 文件权限、属主或时间元数据不同;
- tar 归档顺序不同;
- 压缩算法或压缩参数不同;
- 归档中的白化文件不同。
所以,内容寻址不是“按目录语义去重”,而是“按对象字节去重”。
2.2 压缩层摘要和解压后差分摘要不是同一个概念
Docker 镜像中常见两个容易混淆的摘要:
- blob digest:对传输和存储的压缩层字节计算摘要;
- diff ID:对解压后的层内容计算摘要。
可以形式化为:
其中:
- 是层的未压缩 tar 字节;
compress是压缩过程;blobDigest用于校验和获取存储中的层 blob;diffID用于表示未压缩文件系统差分的身份。
如果同一个未压缩 tar 使用两种不同压缩参数:
T ──compress A──> C1 ──SHA256──> digest1
T ──compress B──> C2 ──SHA256──> digest2
那么通常:
digest1 != digest2
diffID(T) 相同
这说明“传输层身份”和“解压后的根文件系统差分身份”承担不同职责。OCI manifest 的 layer descriptor 通常引用压缩 blob,而配置对象中的 rootfs.diff_ids 表示解压后层的差分摘要。
2.3 ChainID 表示层叠加后的链身份
单个 diffID 只描述一个文件系统差分。要描述“前面已经叠加了哪些层,再加上当前层”的身份,Docker 使用链身份概念。
对于第一层:
对于后续层:
其中:
- 是摘要函数;
++表示字符串连接;ChainID表示从根到当前层的完整顺序;diffID表示当前这一层自身的内容。
推导一个三层镜像:
d1 = diffID(layer1)
d2 = diffID(layer2)
d3 = diffID(layer3)
c1 = d1
c2 = SHA256(c1 + " " + d2)
c3 = SHA256(c2 + " " + d3)
如果只交换层顺序:
layer1, layer2
变成:
layer2, layer1
即使两个层的内容都没有改变,链身份也会改变,因为文件系统叠加的顺序改变了结果。
3. 一层到底是什么:文件系统差分,而不是完整快照
镜像层通常表现为一个 tar 归档,描述“相对于父层发生了哪些变化”。它可以包含:
- 新增文件;
- 修改后的文件;
- 新目录;
- 权限、属主等元数据变化;
- 表示删除操作的白化条目。
假设基础层中有:
/etc/app.conf
/usr/bin/app
/var/lib/data.db
下一层执行了:
rm /etc/app.conf
echo new > /usr/bin/app
这一层不需要重新保存整个根文件系统。它大致记录:
删除 /etc/app.conf
覆盖 /usr/bin/app
运行时将这两层叠加后,用户看到的是:
/usr/bin/app # 新版本
/var/lib/data.db # 继承基础层
/etc/app.conf # 对上层不可见
因此,层的含义是“差分”,而不是“某一时刻完整的磁盘镜像”。
3.1 删除如何被表示:whiteout
只读的父层不能被物理修改。若新层要删除父层文件,就必须在新层中放置一个特殊的删除标记,通常称为 whiteout。
OCI 镜像层规范定义了基于 tar 条目的白化表示,例如:
.wh.filename
表示隐藏同一目录下父层中的 filename。
对于“隐藏目录下所有父层条目”的操作,还存在不透明目录标记:
.wh..wh..opq
不同存储驱动可以在应用层时把 OCI whiteout 转换为自身所需的内核或文件系统表示。Docker 使用 OverlayFS 时,镜像层归档中的删除语义需要被转换为 OverlayFS 能够表达的形式。
白化不是“把旧文件从旧层中擦除”。旧文件所在的层仍然存在,因为它可能被其他镜像或其他容器共享。
这直接导致一个重要结果:
层 1:写入 100 MB 文件
层 2:删除该文件
最终文件系统中看不到这个文件,但镜像仍然需要保存层 1 的 100 MB,再加上层 2 的删除标记。删除文件通常只减少运行时可见内容,不会回收历史层中的字节。
4. OverlayFS:容器启动后看到的根文件系统
4.1 OverlayFS 的三类目录
Linux OverlayFS 可以把多个目录组合成一个挂载点。Docker 的 overlay2 实现通常涉及:
lowerdir:只读下层,来自镜像层;upperdir:可写上层,属于当前容器;workdir:OverlayFS 内部执行某些操作时需要的工作目录;- merged mount:进程实际看到的合并目录。
抽象结构如下:
镜像层 3 lowerdir
镜像层 2 lowerdir
镜像层 1 lowerdir
--------------------
容器层 upperdir
--------------------
merged root filesystem
容器进程访问的是 merged 目录,而不是直接访问某个镜像层目录。
Docker 的镜像层本身通常保持只读。即使两个容器都基于同一个镜像运行,它们也可以共享相同的 lower layers,同时拥有各自独立的 upperdir:
┌──────────────┐
容器 A ──────>│ upperdir A │
└──────┬───────┘
│
┌──────▼───────┐
│ shared image │
│ lower layers │
└──────┬───────┘
│
┌──────▼───────┐
容器 B ──────>│ upperdir B │
└──────────────┘
这就是“镜像可共享、容器写入隔离”的基本来源。
4.2 读取路径:上层优先
对路径 /etc/app.conf 的读取可以抽象为:
- 在 upperdir 查找;
- 如果 upperdir 有白化标记,则认为该路径不存在;
- 如果 upperdir 没有,再从较新的 lowerdir 向较旧的 lowerdir 查找;
- 第一个找到的可见条目生效。
若层从旧到新为:
L1: /etc/app.conf = old
L2: /etc/app.conf = new
读取结果是:
/etc/app.conf = new
如果容器层删除该文件:
upperdir: whiteout /etc/app.conf
读取结果就是“文件不存在”,即使 L1 和 L2 中仍然保留原始文件。
4.3 写入路径:copy-up 产生额外成本
如果进程修改一个只存在于 lowerdir 的文件,OverlayFS 通常需要执行 copy-up:
- 将 lowerdir 中的文件复制到 upperdir;
- 在 upperdir 中修改副本;
- 后续读取优先使用 upperdir 的副本。
例如,镜像中有一个 500 MB 的文件:
lowerdir: /opt/cache.bin 500 MB
容器第一次执行:
echo x >> /opt/cache.bin
即使只追加一个字节,也可能需要先把整个文件复制到 upperdir。实际成本取决于内核版本、OverlayFS 行为和文件操作方式,但“修改下层文件可能触发复制”是必须考虑的机制。
目录操作也可能涉及元数据 copy-up。例如修改目录权限、创建目录项或进行某些重命名操作时,内核需要在可写层建立对应结构。
因此:
- 镜像层共享降低了初始存储;
- 容器层并不意味着所有写入都很便宜;
- 对大文件频繁原地修改,可能产生明显的 copy-up 和写放大。
4.4 容器可写层不是镜像层
运行一个容器时,容器的写入通常进入该容器自己的 upperdir:
docker run IMAGE
此时在容器内执行:
echo data > /tmp/result
不会自动修改镜像,也不会让其他基于同一镜像的容器看到 /tmp/result。
如果执行:
docker commit CONTAINER NEW_IMAGE
Docker 会把容器层中的差异保存为一个新的镜像层,并生成新的配置和清单关系。docker commit 不是可靠的构建替代品,因为它容易隐藏构建步骤、环境来源和可重复性问题。
容器删除后,容器层通常也会被删除;但挂载到独立 volume 的数据不属于容器可写层,生命周期由 volume 管理。
4.5 volume 和 bind mount 绕过了容器层
当路径被挂载覆盖时,进程访问到的是挂载源,而不是镜像或容器层中原来的内容:
docker run --rm \
-v app-data:/var/lib/app \
IMAGE
此时 /var/lib/app 的读写主要由 volume 提供。写入数据不会按普通路径进入容器 upperdir。
这解释了几个常见现象:
- 删除容器后,命名 volume 中的数据仍然存在;
docker diff不一定体现挂载卷内部的全部变化;- 镜像体积与应用数据体积是两个不同问题;
- 把数据库数据写进容器层,会增加 copy-up、层写入和容器生命周期耦合。
5. Docker Engine、containerd 和 runc 在这条链路中的位置
在现代 Docker Engine 中,粗略的数据流可以表示为:
sequenceDiagram
participant CLI as Docker CLI
participant D as dockerd
participant C as containerd
participant S as snapshotter/overlay2
participant R as runc
participant P as container process
CLI->>D: docker run IMAGE
D->>C: 创建容器任务
C->>S: 准备镜像层与容器 upperdir
S-->>C: 返回 rootfs 挂载
C->>R: 创建 OCI runtime bundle
R->>R: 建立 namespaces、mounts、cgroups 等
R->>P: 启动容器进程
P->>S: 通过 merged rootfs 读写
这里需要区分规范和实现:
- OCI Image Specification 定义镜像对象、层和配置的格式;
- OCI Runtime Specification 定义运行时 bundle、配置和 rootfs 等运行时接口;
runc是常见的 OCI runtime 实现;containerd管理镜像、快照和容器任务;overlay2是 Linux 上常见的 snapshotter 或 Docker 存储实现;- Docker Engine 通过 daemon 对这些组件进行编排。
OCI 并不保证运行时一定使用 OverlayFS。其他 Linux 文件系统、远程快照器或不同平台实现可以采用不同的层存储机制,但只要最终满足对应接口,镜像格式和运行时格式仍可以保持 OCI 兼容。
6. Dockerfile 与层:每条指令都等于一个最终镜像层吗
传统理解常常是:
RUN command1
RUN command2
等于:
layer(command1)
layer(command2)
这个模型有助于理解镜像历史,但对于现代 BuildKit,需要更精确:
- Dockerfile 会被前端解析;
- BuildKit 将其转换为 LLB 图;
- 每个操作有输入、依赖和输出;
- 最终导出的镜像包含由构建结果形成的层;
- 中间结果还可能只存在于构建缓存中,不一定成为最终镜像层。
对于常见的 RUN、COPY、ADD,分开写通常会形成不同的构建步骤和可缓存边界,但不能把 Dockerfile 文本行简单等同于“永远存在的一层”。
例如:
FROM alpine:3.20
RUN echo one > /tmp/a
RUN echo two > /tmp/b
第二个步骤通常以第一个步骤的文件系统结果为输入。若第一个步骤命中缓存,BuildKit 可以复用其结果;若第一个步骤失效,第二个步骤通常也需要重新计算,因为其父输入发生了变化。
6.1 同一条 RUN 内创建后删除,最终层通常不包含它
比较下面两个 Dockerfile。
第一种:
FROM alpine:3.20
RUN dd if=/dev/urandom of=/tmp/large.bin bs=1M count=20 \
&& rm /tmp/large.bin
该 RUN 完成后进行快照时,/tmp/large.bin 已经不存在,所以最终导出的这一层通常不会包含这个 20 MB 文件。构建过程可能短暂使用磁盘和缓存,但最终层不需要保存该文件。
第二种:
FROM alpine:3.20
RUN dd if=/dev/urandom of=/tmp/large.bin bs=1M count=20
RUN rm /tmp/large.bin
第二个 RUN 的结果会包含一个删除上层文件的白化变更,但第一个层已经保存了 20 MB 文件。镜像仍然保留历史字节。
可以用一个小型实验观察历史层:
mkdir -p /tmp/docker-layer-demo
cd /tmp/docker-layer-demo
cat > Dockerfile <<'EOF'
FROM alpine:3.20
RUN dd if=/dev/urandom of=/tmp/large.bin bs=1M count=20
RUN rm /tmp/large.bin
EOF
docker build --progress=plain -t layer-demo .
docker history --no-trunc layer-demo
docker image inspect layer-demo
预期可以看到:
docker history显示两个RUN步骤;- 删除步骤本身通常很小;
- 第一个
RUN对镜像大小贡献明显; docker image inspect中的RootFS.Layers或相关字段可以看到层链信息。
具体显示格式、压缩大小和历史命令是否被合并,会受到 Docker Engine、BuildKit 导出器和基础镜像元数据的影响;不要把 history 每一行的数字当作所有磁盘占用的精确账单。
若改成:
FROM alpine:3.20
RUN dd if=/dev/urandom of=/tmp/large.bin bs=1M count=20 \
&& rm /tmp/large.bin
最终层通常不会保留该文件。但这并不意味着构建期间完全没有 20 MB 的临时空间消耗。
7. BuildKit 缓存:缓存的键和镜像层不是一回事
7.1 缓存命中是对构建操作的复用
BuildKit 缓存的目标是复用“某个构建操作在某些输入下的结果”。可抽象为:
如果输入条件足够一致,BuildKit 可以返回之前的结果,而不重新执行命令。
缓存判断不是简单的:
RUN 命令字符串相同 => 一定命中
它还受到以下因素影响:
- 父步骤结果;
FROM基础镜像解析结果;COPY或ADD使用的上下文文件;- Dockerfile 前端和相关选项;
- 构建参数;
- 某些挂载和操作输入;
- secret、SSH、缓存挂载等特殊输入的语义。
具体缓存键属于 BuildKit 实现和 Dockerfile frontend 的行为,不能只依赖肉眼比较命令文本。
7.2 COPY 的缓存边界由输入文件决定
考虑:
FROM node:22
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
如果只修改业务源代码,第一条 COPY 的输入没有变化,那么依赖安装步骤通常可以继续使用缓存;最后的 COPY . . 和构建步骤才需要重新执行。
反过来,如果把所有文件一次性复制:
FROM node:22
WORKDIR /src
COPY . .
RUN npm ci
RUN npm run build
任何会影响 COPY . . 输入的变化,都可能使后续依赖安装步骤失效。
这不是因为后一种写法“层数更多”或“更少”,而是因为缓存操作的输入边界不同。
.dockerignore 也会影响构建上下文。被排除的文件不会作为普通上下文输入参与对应的 COPY,因此可以减少发送数据和无关缓存失效。
7.3 RUN --mount=type=cache 不是最终镜像层
BuildKit 支持缓存挂载,例如:
# syntax=docker/dockerfile:1
FROM debian:bookworm
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
这里的 /var/cache/apt 是 BuildKit 管理的缓存挂载,不是普通的容器根文件系统路径。它的用途是让多个构建复用下载缓存,而不是把缓存内容写进最终镜像。
因此要区分:
普通 RUN 写入 /var/cache/apt
-> 可能进入该步骤的文件系统差分
RUN --mount=type=cache,target=/var/cache/apt
-> 写入 BuildKit 缓存挂载,不作为最终 rootfs 内容
缓存挂载中的内容可能因缓存清理而消失,所以构建步骤必须在缓存为空时仍然能够正确执行。缓存只能加速,不能成为构建正确性的唯一来源。
sharing=locked 等选项用于控制并发访问语义;包管理器可能需要锁定,避免多个并行构建同时修改同一个包缓存而发生损坏。具体可用选项和行为以当前 Dockerfile frontend 与 BuildKit 版本为准。
7.4 secret 不应进入镜像层,也不应成为普通缓存依据
例如:
# syntax=docker/dockerfile:1
FROM alpine:3.20
RUN --mount=type=secret,id=token \
TOKEN="$(cat /run/secrets/token)" \
&& wget --header="Authorization: Bearer $TOKEN" \
-O /tmp/package.tar.gz https://example.invalid/package.tar.gz
构建时:
docker build \
--secret id=token,src="$HOME/.config/my-token" \
-t secret-demo .
secret 挂载只在该 RUN 期间可见,不应通过 COPY、ENV 或普通 RUN echo 写进镜像层。
还要注意缓存语义:secret 的内容不是普通文件系统输入,不能假设“secret 文件内容改变后,所有使用它的步骤一定自动失效”。如果构建产物确实依赖 secret 对应的版本,应显式把非敏感的版本标识作为构建输入,例如:
docker build \
--build-arg PACKAGE_VERSION=2025-01 \
--secret id=token,src="$HOME/.config/my-token" \
.
Dockerfile 中使用该参数:
ARG PACKAGE_VERSION
RUN --mount=type=secret,id=token \
fetch-private-package "$PACKAGE_VERSION"
这里的版本参数用于缓存和可追踪性,真正的 token 仍通过 secret 挂载提供。
7.5 缓存命中并不等于重新执行命令
当 RUN 命中缓存时,BuildKit 通常直接复用之前的结果,而不会再次启动 shell 执行命令。这意味着以下命令有潜在风险:
RUN curl https://example.com/latest.tar.gz -o /opt/latest.tar.gz
如果缓存命中,即使远端文件已经变化,也可能继续使用旧结果。若构建确实依赖远程内容,应使用固定版本、校验和或显式失效输入:
ARG ARCHIVE_SHA256
RUN curl -fsSL https://example.com/package-1.2.3.tar.gz \
-o /tmp/package.tar.gz \
&& echo "$ARCHIVE_SHA256 /tmp/package.tar.gz" | sha256sum -c -
更可靠的构建不是“关闭所有缓存”,而是明确哪些输入决定输出,并让这些输入进入缓存边界。
8. 镜像体积的四种“大小”
谈镜像大小时至少要区分四个概念。
8.1 压缩传输大小
仓库推送和拉取通常传输压缩后的 layer blobs。这个大小受以下因素影响:
- 文件内容可压缩性;
- 压缩格式和参数;
- 层内部归档结构。
随机数据几乎不可压缩,文本和重复数据通常更容易压缩。
8.2 解压后的层大小
容器运行时需要访问解压后的文件系统内容。一个压缩后只有 10 MB 的层,解压后可能是 50 MB;反之,压缩比例也可能因数据类型而很低。
因此:
docker pull 的网络流量
和:
本地镜像层占用
不是同一个数字。
8.3 镜像逻辑大小
镜像由一条层链组成。若层大小分别为:
L1 = 100 MB
L2 = 20 MB
L3 = 5 MB
该镜像的逻辑层内容约为:
但如果另一个镜像也引用 L1,磁盘上不会因为它再次引用而简单增加 100 MB。
8.4 本地实际占用和共享占用
设本地有两个镜像:
Image A: L1, L2, L3
Image B: L1, L4
若:
size(L1)=100 MB
size(L2)=20 MB
size(L3)=5 MB
size(L4)=30 MB
则两个镜像分别计算逻辑大小:
A = 125 MB
B = 130 MB
但本地共享层的唯一字节总量约为:
而不是:
实际磁盘占用还要考虑:
- 镜像索引、配置和 manifest;
- BuildKit 构建缓存;
- 容器 upperdir;
- volume;
- snapshotter 元数据;
- 文件系统块和 inode 开销;
- 尚未被垃圾回收的旧引用。
可以使用:
docker image ls
docker image inspect IMAGE
docker history --no-trunc IMAGE
docker system df
docker system df -v
诊断时应这样理解:
docker image ls更适合看镜像的逻辑展示;docker history适合定位哪条 Dockerfile 指令产生了大层;docker system df -v适合观察镜像、容器和本地缓存的共享与可回收情况;du直接查看 Docker 数据目录时,必须知道当前存储驱动和数据目录布局,不能把某个目录大小直接当成某个镜像的层大小。
不要在 Docker 正在运行时手工删除 /var/lib/docker 或 containerd 的内部目录。这样会破坏元数据和引用关系,正确做法是使用 Docker 的镜像、容器、builder cache 和 volume 管理命令,并先验证删除对象。
9. 一个完整的层体积实验
下面的 Dockerfile 故意把创建和删除拆成两个步骤:
FROM alpine:3.20
RUN dd if=/dev/urandom of=/tmp/blob.bin bs=1M count=20
RUN rm -f /tmp/blob.bin
RUN printf 'final file\n' > /app.txt
构建:
docker build --progress=plain -t layer-size-demo .
验证最终容器中没有文件:
docker run --rm layer-size-demo sh -c '
test ! -e /tmp/blob.bin &&
cat /app.txt
'
预期输出:
final file
但查看历史:
docker history --no-trunc layer-size-demo
通常会看到:
- 第一条
RUN产生明显大小; - 第二条
RUN大小很小; - 最终文件
/app.txt来自最后一步。
原因是:
- 第一步结束时,
/tmp/blob.bin存在,因此被写入第一层; - 第二步结束时,文件被删除,生成删除差分;
- 删除差分不能修改第一层;
- 运行时通过 whiteout 隐藏第一层中的文件;
- 最终可见文件不存在,但历史层仍占用空间。
若将前两步合并:
FROM alpine:3.20
RUN dd if=/dev/urandom of=/tmp/blob.bin bs=1M count=20 \
&& rm -f /tmp/blob.bin
RUN printf 'final file\n' > /app.txt
第一条 RUN 的最终文件系统结果中没有该文件,所以导出的最终层通常不会保存 20 MB 的文件内容。不过构建期间仍可能产生临时数据和缓存,这些数据是否立即释放取决于 builder 的缓存回收状态。
10. 多阶段构建为什么能减少最终镜像
多阶段构建把“构建环境”和“运行环境”分开:
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app
FROM alpine:3.20
COPY --from=build /out/app /usr/local/bin/app
ENTRYPOINT ["/usr/local/bin/app"]
最终阶段只从 build 阶段复制 /out/app。Go 编译器、源码和构建缓存不会因为存在于前一阶段,就自动进入最终镜像。
它减少体积的原因不是“删除了上一阶段的层”,而是:
- 最终镜像的 manifest 只引用最终阶段所需的层;
- 最终阶段没有引用构建阶段的完整根文件系统;
COPY --from=build只导出指定路径的结果。
不过构建缓存中仍可能保留构建阶段的中间结果。最终镜像变小,不代表构建机上的 BuildKit cache 同样立即变小。
11. 分层的收益和代价
11.1 收益:复用和增量传输
如果多个镜像共享基础层:
Image A: base + runtime-A
Image B: base + runtime-B
推送或拉取时,已经存在的 base 层可以复用。发布一个只改变应用二进制的版本时,通常只需要传输新应用层,而不是完整重新传输基础系统。
这也是“把变化频繁的内容放在后面的构建步骤”通常有利于缓存和增量发布的原因。
11.2 代价:历史层保留旧内容
如果一条层加入了大文件,下一条层删除它:
L1: add /large-file
L2: whiteout /large-file
最终镜像仍然引用 L1。要真正消除这部分内容,必须重新构建一条不包含该文件的层链,通常意味着修改 Dockerfile 并从合适的构建步骤重新产生镜像。
11.3 代价:层过多并不等于一定更大
层数量本身不是体积的唯一决定因素:
- 大量小层可能带来更多元数据和挂载复杂度;
- 合理拆分可以改善缓存复用;
- 不合理拆分会把临时文件固化到历史层;
- 合并所有命令也可能导致缓存边界过粗,修改一个步骤就重新执行全部操作。
真正需要优化的是层中保留的最终内容、缓存输入边界和发布复用关系,而不是机械追求“层越少越好”。
12. 常见误解与实际边界
12.1 “镜像层就是容器运行时的目录”
不完全是。
镜像层是可分发的内容对象;运行时还需要:
- 解压或挂载层;
- 建立 snapshot;
- 添加容器可写层;
- 准备 mount namespace;
- 应用 OCI runtime 配置;
- 启动容器进程。
镜像层的存储格式和运行时挂载布局是两个抽象层次。
12.2 “容器里删掉文件,就释放了镜像空间”
通常不会。
容器中的删除操作只会在 upperdir 中生成白化语义,隐藏 lowerdir 文件。它可能释放容器层中后来创建的文件,但不能回收已经属于镜像历史层的字节。
12.3 “所有写入都发生在 OverlayFS upperdir”
不一定。
以下路径可能绕过普通容器层:
- bind mount;
- named volume;
- tmpfs mount;
- 某些设备或特殊文件系统;
- Docker Desktop、远程 daemon 或 rootless 环境下的不同存储实现。
诊断存储问题时必须先确认路径是否被挂载覆盖。
12.4 “镜像摘要相同就一定是同一个运行环境”
摘要可以保证被引用的对象内容一致,但运行结果还可能受到外部因素影响:
- 内核版本;
- CPU 架构和指令集;
- cgroup、namespace 和安全策略;
- host 上的挂载;
- volume 内容;
- 网络和 DNS;
- 时间、随机数和外部服务;
- 镜像启动时访问的远程软件源。
镜像内容寻址解决的是对象身份和传输完整性,不是把所有运行环境因素都封装进镜像。
12.5 “OverlayFS 适合所有应用写负载”
OverlayFS 适合容器根文件系统的常见场景,但不等于适合所有数据负载。尤其要注意:
- 大文件原地修改可能触发 copy-up;
- 高并发写入可能增加 upperdir 压力;
- 大量小文件会消耗 inode 和元数据;
- 数据库、队列和持久化服务通常应使用专用 volume 或块存储;
- 不同内核、文件系统和 rootless 配置可能有不同限制。
对于需要持久化、独立扩容或高性能随机写的应用,应把数据路径与镜像根文件系统分离,并单独验证文件系统语义和性能。
13. 诊断镜像层、缓存和运行时写层
13.1 先确认问题属于哪一种空间
可以按以下顺序检查:
docker system df -v
docker images
docker ps -a --size
docker builder du
docker volume ls
这些命令分别帮助区分:
- 镜像及其共享层;
- 容器可写层;
- BuildKit 缓存;
- volume 数据。
如果宿主机磁盘满了,但镜像显示并不大,可能是:
- BuildKit 缓存大量积累;
- 已停止容器仍保留可写层;
- volume 占用空间;
- 日志文件增长;
- Docker 数据目录所在文件系统的 inode 耗尽;
- 其他宿主机进程占用空间。
13.2 定位大层
先查看历史:
docker history --no-trunc IMAGE
然后对 Dockerfile 逐步判断:
- 哪个
COPY引入了大构建上下文; - 哪个
RUN下载并保留了包缓存; - 是否在后续层才删除临时文件;
- 是否将依赖、源码和构建工具带入了最终阶段;
- 是否把运行时数据写进了镜像构建过程。
对于 BuildKit 构建缓存:
docker builder du
可以查看 builder 维度的缓存使用情况。清理前应确认是否有其他项目依赖这些缓存。缓存是可再生成的,但删除缓存可能显著增加下一次构建时间。
13.3 诊断 OverlayFS 写入异常
如果容器启动失败或写入失败,应检查:
docker info
docker inspect CONTAINER
docker system df
df -h
df -i
重点确认:
- 使用的存储驱动是否为
overlay2; - Docker 数据目录所在文件系统是否有空间;
- inode 是否耗尽;
- 目标路径是否被 volume 或 bind mount 覆盖;
- 容器是否以只读根文件系统运行;
- 是否触发了 upperdir、内核或底层文件系统限制;
- 是否为 rootless 或 Docker Desktop 等不同实现。
如果启动参数使用了:
docker run --read-only IMAGE
容器根文件系统不能正常写入。应用若需要临时文件,通常需要显式提供:
docker run --read-only \
--tmpfs /tmp \
IMAGE
这里 /tmp 的写入进入 tmpfs,而不是镜像层。若应用还需要持久化数据,则应使用独立 volume,而不是依赖只读根文件系统的例外路径。
14. 构建和运行的取舍
一个合理的镜像设计需要同时考虑三件事:
第一,哪些内容应该进入最终镜像
编译器、源码、测试数据和包管理器缓存通常属于构建阶段,不应自动进入运行阶段。多阶段构建可以通过显式复制最终产物来控制边界。
第二,哪些输入决定缓存结果
依赖清单、锁文件、源代码、构建参数和基础镜像版本应有清晰的输入关系。构建缓存挂载可以复用下载内容,但不能代替锁文件、校验和或版本固定。
第三,哪些数据不属于镜像
数据库数据、上传文件、运行时生成的索引和日志通常不应写入镜像层。它们应根据生命周期使用 volume、对象存储、日志系统或其他专用存储。
分层模型的核心并不是“把 Dockerfile 写成很多层”,而是让不可变的构建产物、可复用的缓存、容器临时写入和持久化数据处于正确的边界中。
15. 用一条链路总结
从源码到运行时,可以用以下关系统一理解:
Dockerfile 指令
↓
BuildKit 操作图与缓存键
↓
文件系统差分
↓
未压缩 diffID
↓
压缩 layer blob 与 blob digest
↓
manifest/config 引用
↓
镜像层 lowerdirs
↓
OverlayFS upperdir
↓
容器进程看到的 merged rootfs
其中每个阶段解决的问题不同:
- 内容寻址解决对象如何命名、校验和复用;
- 镜像分层解决文件系统变更如何组织和分发;
- OverlayFS解决只读镜像层如何与容器写入合并;
- BuildKit 缓存解决构建操作结果如何复用;
- 镜像体积分析解决压缩传输、解压层、共享层、缓存和运行时写层如何区分。
理解这些边界后,镜像体积、缓存失效、容器写入和删除文件不释放空间等现象就不再是 Docker 的“特殊行为”,而是内容寻址存储、差分层和联合挂载共同产生的可推导结果。
系列导航与关联阅读
- 系列入口:Docker 完整学习路线:从镜像与容器到安全、可观测和生产交付
- 上一篇:Docker 与 OCI 架构:CLI、Daemon、containerd、runc 和隔离边界
- 下一篇:Docker 容器生命周期:创建、启动、信号、退出、重启与清理
- 延伸:Dockerfile 与 BuildKit:构建上下文、缓存挂载、Secret 和可重复构建
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论