Docker 文件系统与存储层
前两篇已经介绍了镜像层和容器可写层,但 Docker 最终怎样把这些层组合成容器看到的根文件系统 /?init 层、merged 目录又分别是什么?本篇从容器启动时的文件系统栈讲起。
完成后,你将能够:
- 区分镜像层、
init层和容器可写层; - 解释 OverlayFS 中
lowerdir、upperdir、workdir和merged的作用; - 理解读取、首次修改和删除文件时发生的
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 中会改变文件系统的指令通常产生镜像层,例如 RUN、COPY 和 ADD。这些层具有以下特点:
- 内容只读,可被多个容器共享;
- 镜像标签改变时,旧层仍可能被其他镜像引用;
- 多个容器从同一镜像启动,不会各复制一份完整镜像;
- 镜像层由内容寻址,不应直接修改宿主机内部文件。
可以用下面的命令观察镜像历史和组成根文件系统的 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 配置挂载到相应路径。
- 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 | 当前容器可写层 | 是 |
workdir | OverlayFS 完成原子操作等内部工作所需目录 | 是,但不供应用直接使用 |
merged | lowerdir 与 upperdir 挂载后形成的统一视图 | 否,它是挂载点 |
等价的内核挂载概念如下:
# 仅用于解释结构,不要直接复制到 Docker 主机执行
mount -t overlay overlay \
-o lowerdir=<init层>:<镜像层3>:<镜像层2>:<镜像层1>,\
upperdir=<容器可写目录>,\
workdir=<OverlayFS工作目录> \
<merged挂载点>
很多资料口头称其为“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 大致按优先级查找:
- 如果该路径被卷、bind mount 或运行时挂载覆盖,从对应挂载读取;
- 否则在容器
upperdir中查找; - 如果上层没有,再按顺序到各个
lowerdir查找; - 返回最上方可见的版本。
读取只存在于镜像层的文件时,不会先复制到可写层。多个容器可以共享相同的只读镜像数据,这正是容器创建快、额外空间小的原因之一。
5. 首次修改与 copy_up
镜像层只读。当容器第一次修改一个仅存在于 lowerdir 的文件时,OverlayFS 会进行 copy_up:
- 把整个文件从只读层复制到
upperdir; - 在
upperdir中修改这份副本; merged视图显示上层新版本;- 下方镜像层中的原文件保持不变。
可以通过 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
可能看到 overlay2、overlayfs、fuse-overlayfs、btrfs、zfs 或其他结果。后续宿主机目录实验只适用于明确使用经典 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 数据目录。
不要在 /var/lib/docker 下执行 rm、mv、chmod、编辑文件或自行挂载/卸载。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_DIR 或 UPPER_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. 本篇练习
- 用
docker image inspect查看 Alpine 与 Nginx 的.RootFS.Layers,比较层数量; - 创建两个同镜像容器,在其中一个写文件,验证另一个看不到;
- 修改一个镜像自带文件,使用
docker diff观察C标记; - 删除一个镜像自带文件,验证新容器仍能看到它;
- 挂载命名卷写文件,验证
docker diff不会把卷内容当作可写层变化; - 在 Linux
overlay2环境中只读比较UpperDir与MergedDir,完成后通过docker rm清理。
下一篇将继续学习容器的创建、启动、停止、日志、退出码、资源限制和健康检查。