跳到主要内容

Docker 文件系统与存储层

前两篇已经介绍了镜像层和容器可写层,但 Docker 最终怎样把这些层组合成容器看到的根文件系统 /init 层、merged 目录又分别是什么?本篇从容器启动时的文件系统栈讲起。

完成后,你将能够:

  • 区分镜像层、init 层和容器可写层;
  • 解释 OverlayFS 中 lowerdirupperdirworkdirmerged 的作用;
  • 理解读取、首次修改和删除文件时发生的 copy_up 与 whiteout;
  • 说明卷和 bind mount 为什么不属于容器可写层;
  • 使用 Docker API 和 Linux 工具只读观察容器文件系统。
本篇讨论的范围

Docker 支持多种存储后端。本文以 Linux 上常见的 OverlayFS/overlay2 为主,同时说明 Docker Desktop、containerd image store 和其他存储驱动的差异。这里的“存储层”是容器根文件系统的实现,不是命名卷。

1. 容器看到的文件从哪里来

容器中的 /etc/usr/var 看起来像一个普通目录树,实际可能来自多个来源:

来源是否可写生命周期例子
镜像层只读跟随镜像/bin/sh、应用程序、基础配置
Docker init运行时作为只读父层跟随容器.dockerenv、部分挂载目标占位
容器可写层可写跟随容器容器运行后新建或修改的文件
tmpfs可写,存于内存容器停止后消失/run、临时缓存
命名卷可写独立于容器数据库数据、上传文件
bind mount由挂载参数决定跟随宿主机文件源代码、配置文件
Docker 运行时挂载由 Docker 管理跟随容器运行/etc/hosts/etc/hostname/etc/resolv.conf

因此,“容器内的文件”不一定存放在同一位置。排查数据、权限和磁盘占用问题时,第一步应确认目标路径属于哪一种来源。

2. 从镜像到容器的层次

假设镜像包含三层。Docker 创建容器时,还会准备 init 层和容器可写层。概念上可以从下往上理解:

# 容器进程最终看到的统一根文件系统
merged(联合挂载视图,不是新的数据层)
├── 容器可写层(读写,容器自己的变化)
├── init 层(只读,Docker 为该容器准备的初始内容)
├── 镜像层 3(只读,最接近最终镜像)
├── 镜像层 2(只读)
└── 镜像层 1(只读,通常来自基础镜像)

从容器内部只会看到合并后的一个 /,不会看到“第 1 层目录”和“第 2 层目录”。同一路径在多层中都存在时,上层内容遮住下层内容。

2.1 镜像层

Dockerfile 中会改变文件系统的指令通常产生镜像层,例如 RUNCOPYADD。这些层具有以下特点:

  • 内容只读,可被多个容器共享;
  • 镜像标签改变时,旧层仍可能被其他镜像引用;
  • 多个容器从同一镜像启动,不会各复制一份完整镜像;
  • 镜像层由内容寻址,不应直接修改宿主机内部文件。

可以用下面的命令观察镜像历史和组成根文件系统的 diff ID:

# 拉取实验镜像,确保后续命令有一致对象
docker pull alpine:3.20

# 查看 Dockerfile 历史、各步骤创建时间和大致大小
docker history --no-trunc alpine:3.20

# 查看镜像 RootFS 中记录的层类型和 diff ID
docker image inspect alpine:3.20 \
--format '{{json .RootFS}}'

docker history 中有些元数据指令可能显示为 0B,它们不一定产生包含文件内容的新层。.RootFS.Layers 中的 diff ID 是层解压内容的摘要,也不等同于宿主机 overlay2 目录名。

2.2 容器 init

Docker 创建容器时,会在镜像层和容器可写层之间准备一个很小的内部 init 层。它的主要用途是预先创建 Docker 运行容器所需的文件、目录和符号链接,让后续运行时挂载有稳定目标,并避免这些准备工作污染容器可写层。

当前 Moby 实现会为下列路径准备目录、文件或链接;具体集合可能随平台和版本变化:

# 常见目录占位,稍后用于挂载伪文件系统或设备文件系统
/dev/pts
/dev/shm
/proc
/sys

# Docker 容器标识和运行时配置文件的挂载目标
/.dockerenv
/etc/resolv.conf
/etc/hosts
/etc/hostname
/dev/console

# 让传统工具从 /etc/mtab 读取当前挂载信息
/etc/mtab -> /proc/mounts

创建完成后,init 层作为容器可写层下面的最上方只读父层使用。容器启动时,Docker 还会把实际生成的主机名、hosts 和 DNS 配置挂载到相应路径。

不要混淆三种 init
  • Docker 的文件系统 init 层:本节讨论的内部存储层;
  • docker run --init:在容器中加入一个轻量 PID 1,负责转发信号和回收子进程;
  • Linux 初始化系统:例如 systemd 或 OpenRC。

三者名称相似,但解决的问题完全不同。

init 层属于 Docker 内部实现。旧式 overlay2 元数据或源码中经常能看到带 -init 的内部 ID;启用 containerd image store 后,Docker 可能改用 snapshot 表示它。应用不应依赖其内部 ID、目录名或可写性。

2.3 容器可写层

每个容器拥有独立的可写层。两个容器即使来自同一镜像,也不会看到对方写入可写层的数据:

# 容器 A 在自己的可写层创建文件,然后退出
docker run --name layer-a \
alpine:3.20 \
sh -c 'echo "created by A" > /only-in-a.txt'

# 容器 B 来自同一镜像,但它的可写层中没有该文件
docker run --name layer-b \
alpine:3.20 \
sh -c 'test ! -e /only-in-a.txt && echo "B cannot see A data"'

# 容器停止后可写层仍在,因此可以从 A 复制文件
docker cp layer-a:/only-in-a.txt ./only-in-a.txt

# 删除容器会删除各自可写层;本地镜像仍然保留
docker rm layer-a layer-b

容器可写层适合日志缓冲、临时结果等短生命周期内容,不适合重要业务数据。数据持久化将在“数据持久化与备份”一篇中继续展开。

3. OverlayFS 怎样组合这些层

OverlayFS 是 Linux 内核提供的联合文件系统。Docker 的经典 overlay2 驱动利用它将多个只读层和一个可写层合并。

OverlayFS 名称Docker 中的角色是否保存实际内容
lowerdir一个或多个只读父层,包括镜像层;Docker 的内部 init 层也位于可写层之下
upperdir当前容器可写层
workdirOverlayFS 完成原子操作等内部工作所需目录是,但不供应用直接使用
mergedlowerdirupperdir 挂载后形成的统一视图否,它是挂载点

等价的内核挂载概念如下:

# 仅用于解释结构,不要直接复制到 Docker 主机执行
mount -t overlay overlay \
-o lowerdir=<init层>:<镜像层3>:<镜像层2>:<镜像层1>,\
upperdir=<容器可写目录>,\
workdir=<OverlayFS工作目录> \
<merged挂载点>
merged 不是 merge 层

很多资料口头称其为“merge 层”,更准确的名称是 merged 目录或联合挂载视图。它不额外保存一份合并后的文件,也不是可推送的镜像层;容器进程看到的根文件系统通常来源于这一联合视图。

3.1 lowerdir 的顺序

lowerdir 可以包含多个用冒号分隔的目录。越靠前的层优先级越高,因此更接近容器可写层;同名文件在上方只读层出现时,会遮住更下方的版本。

宿主机看到的挂载选项可能很长。overlay2 会使用短链接目录降低挂载参数长度,但这些短名称仍是内部实现,不能作为业务接口。

3.2 upperdir 与 merged 的关系

容器新建的普通文件实际进入 upperdir,同时立即出现在 merged 视图中。容器读取 /new.txt 时,内核通过联合视图找到上层文件。

反过来,merged 中能看到一个文件,并不表示它一定存在于 upperdir:它也可能只来自某个 lowerdir、卷或 bind mount。

3.3 workdir 不是临时文件目录

workdir 是 OverlayFS 自己使用的工作目录,必须与 upperdir 位于同一文件系统。它不是容器内的 /tmp,也不是 Docker 构建上下文。管理员不应在其中放文件、清理单个条目或将其挂载给应用。

4. 读取文件时发生什么

容器读取一个路径时,OverlayFS 大致按优先级查找:

  1. 如果该路径被卷、bind mount 或运行时挂载覆盖,从对应挂载读取;
  2. 否则在容器 upperdir 中查找;
  3. 如果上层没有,再按顺序到各个 lowerdir 查找;
  4. 返回最上方可见的版本。

读取只存在于镜像层的文件时,不会先复制到可写层。多个容器可以共享相同的只读镜像数据,这正是容器创建快、额外空间小的原因之一。

5. 首次修改与 copy_up

镜像层只读。当容器第一次修改一个仅存在于 lowerdir 的文件时,OverlayFS 会进行 copy_up

  1. 把整个文件从只读层复制到 upperdir
  2. upperdir 中修改这份副本;
  3. merged 视图显示上层新版本;
  4. 下方镜像层中的原文件保持不变。

可以通过 docker diff 观察容器相对镜像产生的变化:

# 创建并后台保持一个实验容器
docker run --detach \
--name copy-up-demo \
alpine:3.20 \
sleep 1d

# 修改镜像原本存在的文件,并创建一个新文件
docker exec copy-up-demo \
sh -c 'echo "# changed in container" >> /etc/motd; echo "new" > /new.txt'

# A 表示新增,C 表示元数据或内容发生变化
docker diff copy-up-demo

# 删除容器和可写层,镜像原文件不会被修改
docker rm --force copy-up-demo

copy_up 首次修改时复制整个文件,不是只复制发生变化的字节。因此频繁修改大文件会产生额外 I/O 和空间开销。数据库等写密集型数据应使用卷,并结合数据库自己的存储与备份要求设计。

6. 删除文件与 whiteout

下层镜像只读,所以容器不能真的从镜像层删除文件。删除一个只存在于 lowerdir 的文件时,OverlayFS 会在 upperdir 创建 whiteout 标记,让 merged 视图隐藏下层文件。

删除目录时会使用 opaque directory 等机制隐藏下层目录内容。结果是:

  • 容器内看不到被删除路径;
  • 原始镜像层仍然包含它;
  • 删除容器后,whiteout 随可写层消失;
  • 从同一镜像创建的新容器仍能看到原文件。
# 在容器 A 中删除镜像自带文件,Docker diff 会显示 D
docker run --name delete-demo \
alpine:3.20 \
sh -c 'rm /etc/motd'

# D 表示该路径在容器联合视图中被删除
docker diff delete-demo

# 新容器仍能看到镜像中的原始文件
docker run --rm alpine:3.20 \
sh -c 'test -e /etc/motd && echo "image file still exists"'

# 清理带有 whiteout 的容器可写层
docker rm delete-demo

这也解释了为什么 Dockerfile 中先 COPY secret /secret,再在下一条 RUN rm /secret,不会可靠地减小镜像或消除秘密:较低镜像层仍保存原内容。敏感文件从一开始就不应进入构建上下文或镜像层。

7. 卷和 bind mount 在哪里

命名卷和 bind mount 会挂载到 merged 视图中的某个目标路径,并遮住该位置原有的镜像层或容器层内容:

# 容器看到的 / 路径主要来自 merged 联合视图
/
├── etc/ 镜像层、init 层、可写层与运行时挂载组合
├── app/ 可能来自镜像层
└── data/ 如果挂载命名卷,此子树来自卷而非 upperdir

做一个对比实验:

# 创建命名卷,并把它挂载到容器 /data
docker volume create filesystem-demo-data
docker run --name mount-demo \
--mount type=volume,src=filesystem-demo-data,dst=/data \
alpine:3.20 \
sh -c 'echo "in writable layer" > /root-file.txt; echo "in volume" > /data/volume-file.txt'

# docker diff 能看到根文件系统变化,但不会列出卷内部文件变化
docker diff mount-demo

# inspect 的 Mounts 字段说明 /data 来自独立卷
docker inspect mount-demo \
--format '{{json .Mounts}}'

# 删除容器可写层,再从新容器验证卷数据仍存在
docker rm mount-demo
docker run --rm \
--mount type=volume,src=filesystem-demo-data,dst=/data,readonly \
alpine:3.20 \
cat /data/volume-file.txt

# 确认不再需要实验数据后删除卷
docker volume rm filesystem-demo-data

这就是 docker diff 不适合作为数据库备份工具的原因:它面向容器根文件系统变化,不包含独立挂载卷的数据内容。

8. 使用 Docker API 观察层信息

以下实验不要求直接进入 Docker 数据目录,并且优先使用稳定的 Docker 接口。

8.1 确认存储后端

# 查看当前 Engine 使用的存储驱动或 snapshotter
docker info \
--format 'driver={{.Driver}} root={{.DockerRootDir}}'

# 输出更完整信息,检查 Storage Driver 和 driver-status 字段
docker info

可能看到 overlay2overlayfsfuse-overlayfsbtrfszfs 或其他结果。后续宿主机目录实验只适用于明确使用经典 overlay2 graph driver 的 Linux Engine。

8.2 查看容器 GraphDriver 数据

# 创建一个保持运行的实验容器
docker run --detach \
--name filesystem-inspect \
alpine:3.20 \
sleep 1d

# 某些 overlay2 环境会显示 LowerDir、UpperDir、WorkDir 和 MergedDir
docker inspect filesystem-inspect \
--format '{{json .GraphDriver.Data}}'

# 查看容器内独立挂载及读写属性
docker inspect filesystem-inspect \
--format '{{json .Mounts}}'

# 完成观察后删除容器
docker rm --force filesystem-inspect

在启用 containerd image store、Docker Desktop 或其他驱动时,.GraphDriver.Data 可能为空,或字段与教程不同。这不表示容器没有分层文件系统,只表示内部实现没有通过这个旧接口暴露同样的宿主机路径。

9. Linux overlay2 宿主机只读观察

本节只适用于同时满足以下条件的环境:

  • 原生 Linux Docker Engine,而不是 macOS/Windows 宿主机终端;
  • docker info 明确显示经典 overlay2 存储驱动;
  • 当前用户拥有宿主机 sudo 权限;
  • 只做观察,不编辑 Docker 数据目录。
不要手工修改 Docker 数据目录

不要在 /var/lib/docker 下执行 rmmvchmod、编辑文件或自行挂载/卸载。Docker 的层、引用计数和元数据必须保持一致,手工修改可能同时破坏多个镜像和容器。Docker Desktop 的数据还位于内部 Linux 虚拟机,宿主机路径不可直接套用。

创建实验容器并取得 Docker 暴露的路径:

# 创建长时间运行的实验容器
docker run --detach \
--name overlay-observe \
alpine:3.20 \
sleep 1d

# 只读打印 overlay2 相关目录;不同版本可能不返回这些字段
docker inspect overlay-observe \
--format 'LowerDir={{.GraphDriver.Data.LowerDir}}
UpperDir={{.GraphDriver.Data.UpperDir}}
WorkDir={{.GraphDriver.Data.WorkDir}}
MergedDir={{.GraphDriver.Data.MergedDir}}'

查看联合挂载选项:

# 取得容器 merged 挂载点;只有字段非空时才继续
MERGED_DIR=$(docker inspect overlay-observe \
--format '{{.GraphDriver.Data.MergedDir}}')

# 让 findmnt 显示该挂载点的文件系统类型和完整选项
sudo findmnt -T "$MERGED_DIR" \
-o TARGET,SOURCE,FSTYPE,OPTIONS

# 只读列出 upperdir,容器新文件会出现在这里
UPPER_DIR=$(docker inspect overlay-observe \
--format '{{.GraphDriver.Data.UpperDir}}')
sudo ls -la "$UPPER_DIR"

再写一个文件,比较联合视图与 upperdir

# 通过正常 Docker API 在容器可写层创建文件
docker exec overlay-observe \
sh -c 'echo "upper layer" > /created-in-container.txt'

# merged 是容器根文件系统联合视图,应该能看到该文件
sudo ls -l "$MERGED_DIR/created-in-container.txt"

# upperdir 保存文件实际的上层副本,也应该能看到它
sudo ls -l "$UPPER_DIR/created-in-container.txt"

# 使用 Docker API 清理;不要手动删除上述内部目录
docker rm --force overlay-observe

如果 MERGED_DIRUPPER_DIR 为空,应停止本节实验。这通常意味着当前 Docker 使用了不同存储实现,不应靠猜测内部路径继续操作。

10. 常见误解

“每个容器都有一份完整镜像副本”

不是。多个容器共享只读镜像层,每个容器通常只增加自己的 init 层、可写层和运行时挂载。实际实现还可能通过 snapshotter、页缓存和底层文件系统进一步共享数据。

“merged 保存了合并后的所有文件”

不是。merged 是联合挂载点。它提供统一视图,但不会为所有下层文件重新保存一份副本。

“删除容器文件会从镜像中释放空间”

不是。删除下层文件通常只在可写层创建 whiteout。要减小镜像,必须修改 Dockerfile 构建过程并重新构建,使不需要的文件不进入最终镜像层。

“init 层就是容器可写层的初始状态”

不准确。它是一个单独的内部层,运行时作为容器可写层的只读父层,用来准备 Docker 所需的挂载点等内容。容器自身变化进入上方可写层。

“所有容器都能在 /var/lib/docker/overlay2 找到目录”

不一定。Docker Desktop 将 Engine 运行在 Linux 虚拟机中;rootless 模式的数据目录不同;containerd image store 和其他驱动也使用不同布局。先查看 docker info,再决定使用哪个诊断方法。

11. 分层文件系统的性能影响

  • 只读镜像层可以共享,能降低多个容器的额外磁盘占用;
  • 首次修改下层大文件会触发 copy_up,产生额外 I/O;
  • 镜像层很多时,路径查找和元数据操作可能更复杂;
  • 容器可写层适合短生命周期变化,不适合高写入量的持久数据;
  • 卷绕过容器联合可写层,更适合作为数据库和持久业务数据的存储入口;
  • 镜像体积应通过合理 Dockerfile、多阶段构建和构建上下文控制,而不是直接清理存储目录。

12. 本篇练习

  1. docker image inspect 查看 Alpine 与 Nginx 的 .RootFS.Layers,比较层数量;
  2. 创建两个同镜像容器,在其中一个写文件,验证另一个看不到;
  3. 修改一个镜像自带文件,使用 docker diff 观察 C 标记;
  4. 删除一个镜像自带文件,验证新容器仍能看到它;
  5. 挂载命名卷写文件,验证 docker diff 不会把卷内容当作可写层变化;
  6. 在 Linux overlay2 环境中只读比较 UpperDirMergedDir,完成后通过 docker rm 清理。

下一篇将继续学习容器的创建、启动、停止、日志、退出码、资源限制和健康检查。