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 镜像索引或镜像清单会引用:

  1. 一个配置对象 config
  2. 若干文件系统层 layers
  3. 每个对象的媒体类型、字节大小和摘要。

逻辑结构可以简化为:

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 摘要是对字节内容的身份计算

设一个对象的字节序列为 BB,内容摘要为:

D(B)=SHA256(B)D(B) = \operatorname{SHA256}(B)

内容寻址存储使用 D(B)D(B) 作为对象的逻辑键:

digest -> blob bytes

当两个镜像引用同一个完全相同的 blob 时,存储系统不需要保存两份字节。镜像之间的复用不是因为它们“看起来相似”,而是因为它们引用了相同的内容摘要。

这里的“相同”必须精确到参与摘要的字节。对于层归档来说,以下差异都可能导致摘要变化:

  • 文件内容不同;
  • 文件权限、属主或时间元数据不同;
  • tar 归档顺序不同;
  • 压缩算法或压缩参数不同;
  • 归档中的白化文件不同。

所以,内容寻址不是“按目录语义去重”,而是“按对象字节去重”。


2.2 压缩层摘要和解压后差分摘要不是同一个概念

Docker 镜像中常见两个容易混淆的摘要:

  • blob digest:对传输和存储的压缩层字节计算摘要;
  • diff ID:对解压后的层内容计算摘要。

可以形式化为:

blobDigest=D(compress(T))\text{blobDigest} = D(\operatorname{compress}(T))

diffID=D(T)\text{diffID} = D(T)

其中:

  • TT 是层的未压缩 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 使用链身份概念。

对于第一层:

ChainID1=diffID1\operatorname{ChainID}_1 = \operatorname{diffID}_1

对于后续层:

ChainIDn=H(ChainIDn1+ ⁣ ⁣+""+ ⁣ ⁣+diffIDn)\operatorname{ChainID}_n = H(\operatorname{ChainID}_{n-1} \mathbin{+\!\!+} " " \mathbin{+\!\!+} \operatorname{diffID}_n)

其中:

  • HH 是摘要函数;
  • ++ 表示字符串连接;
  • 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 的读取可以抽象为:

  1. 在 upperdir 查找;
  2. 如果 upperdir 有白化标记,则认为该路径不存在;
  3. 如果 upperdir 没有,再从较新的 lowerdir 向较旧的 lowerdir 查找;
  4. 第一个找到的可见条目生效。

若层从旧到新为:

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

  1. 将 lowerdir 中的文件复制到 upperdir;
  2. 在 upperdir 中修改副本;
  3. 后续读取优先使用 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 图;
  • 每个操作有输入、依赖和输出;
  • 最终导出的镜像包含由构建结果形成的层;
  • 中间结果还可能只存在于构建缓存中,不一定成为最终镜像层。

对于常见的 RUNCOPYADD,分开写通常会形成不同的构建步骤和可缓存边界,但不能把 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 缓存的目标是复用“某个构建操作在某些输入下的结果”。可抽象为:

K=f(operation,parent result,context inputs,build args,frontend)K = f(\text{operation}, \text{parent result}, \text{context inputs}, \text{build args}, \text{frontend})

如果输入条件足够一致,BuildKit 可以返回之前的结果,而不重新执行命令。

缓存判断不是简单的:

RUN 命令字符串相同 => 一定命中

它还受到以下因素影响:

  • 父步骤结果;
  • FROM 基础镜像解析结果;
  • COPYADD 使用的上下文文件;
  • 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 期间可见,不应通过 COPYENV 或普通 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

该镜像的逻辑层内容约为:

Slogical=100+20+5=125 MBS_{\text{logical}} = 100 + 20 + 5 = 125\text{ 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

但本地共享层的唯一字节总量约为:

Sunique=100+20+5+30=155 MBS_{\text{unique}} = 100 + 20 + 5 + 30 = 155\text{ MB}

而不是:

125+130=255 MB125 + 130 = 255\text{ 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 来自最后一步。

原因是:

  1. 第一步结束时,/tmp/blob.bin 存在,因此被写入第一层;
  2. 第二步结束时,文件被删除,生成删除差分;
  3. 删除差分不能修改第一层;
  4. 运行时通过 whiteout 隐藏第一层中的文件;
  5. 最终可见文件不存在,但历史层仍占用空间。

若将前两步合并:

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 编译器、源码和构建缓存不会因为存在于前一阶段,就自动进入最终镜像。

它减少体积的原因不是“删除了上一阶段的层”,而是:

  1. 最终镜像的 manifest 只引用最终阶段所需的层;
  2. 最终阶段没有引用构建阶段的完整根文件系统;
  3. 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

重点确认:

  1. 使用的存储驱动是否为 overlay2
  2. Docker 数据目录所在文件系统是否有空间;
  3. inode 是否耗尽;
  4. 目标路径是否被 volume 或 bind mount 覆盖;
  5. 容器是否以只读根文件系统运行;
  6. 是否触发了 upperdir、内核或底层文件系统限制;
  7. 是否为 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、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。