Docker 核心技术之OverlayFS
02 · OverlayFS:镜像与容器文件层
重要程度:★★★★★ 必学 | 适用角色:全部 | 前置:01 Namespace
一、为什么必须学
坑 1:容器删了,容器里的数据全没了
docker rm my-container # 容器内的数据库文件、配置、缓存,全部消失
很多人以为是"数据没挂载出来",但根本原因是:容器数据物理上只存在于一个叫 upperdir 的可写层里,而 upperdir 绑定在容器生命周期上。 删容器 = 删 upperdir。
不懂这个原理,就只知道"要用 volume",但不知道为什么要用、什么时候必须用、volume 到底存在哪。
坑 2:改了容器里的文件,镜像会不会被污染?
不会。overlayfs 的写时复制(CoW)保证镜像层永远只读。但很多人不敢确认,怕共享镜像的其他容器受影响。
实测结论:容器内 >> /etc/passwd 后,镜像原始层 grep -c hacked = 0,完全干净。
坑 3:镜像只有 12MB,为什么跑 100 个容器占了 1.2GB?
其实没占。100 个容器共享同一份 12.2MB 镜像层,每个容器只有自己的 upperdir(通常几 KB)。
但有个例外:如果容器频繁修改大文件,upperdir 会暴涨——因为 CoW 是完整复制而非增量。这是最容易被忽视的磁盘陷阱。
坑 4:容器里 rm 了一个文件,镜像层里居然有个奇怪的"文件"
删除的文件在 upper 层留下一个 c--------- 权限、link count = 2 的字符设备。这是 overlayfs 的 whiteout 机制。知道它才能读懂 upper 层的目录内容。
二、理论:为什么需要联合文件系统
镜像由多个只读层叠加而成,容器还要在此之上写入数据。传统文件系统无法把多个只读目录"叠加"成一个统一视图并保证性能。
UnionFS(Union File System) 就是为此设计:把多个目录自下而上叠加,读操作按优先级查找,写操作落到最上层。
Docker 默认用 overlay2(overlayfs 的改进实现)。
overlayfs 的三个关键路径
| 参数 |
作用 |
可写 |
lowerdir |
只读层,从左到右优先级递减 |
否 |
upperdir |
容器可写层 |
是 |
workdir |
overlayfs 内部工作目录(原子操作用) |
是 |
查找顺序:upperdir → lowerdir[0] → lowerdir[1] → ... → 都不存在才算"文件不存在"
写时复制(Copy-on-Write)
| 操作 |
是否复制 |
结果 |
| 读 |
不复制 |
零空间开销,直接读 lower |
| 改(lower 中的文件) |
完整复制到 upper |
lower 保持只读 |
| 新建 |
直接写 upper |
— |
| 删 |
在 upper 留 whiteout 标记 |
遮蔽 lower 同路径文件 |
注意"完整复制":改一个 500MB 的文件,upper 层会出现完整的 500MB 副本。
三、实操验证
3.1 拿到 overlay 挂载参数
Docker 29 已移除 inspect 的 .GraphDriver 字段,正确方式是从 /proc/<pid>/mountinfo 解析:
docker run -d --name ov-lab alpine:3.20 sh -c 'while true; do sleep 30; done'
sleep 2
CPID=$(docker inspect ov-lab --format '{{.State.Pid}}')
grep -E ' / .*overlay' /proc/$CPID/mountinfo | head -1 | tr ',' '\n' \
| grep -E "lowerdir|upperdir|workdir" | while read kv; do
k=$(echo $kv | cut -d= -f1); v=$(echo $kv | cut -d= -f2-)
echo " $k:"
echo "$v" | tr ':' '\n' | while read p; do echo " $p"; done
done
实测输出:
lowerdir:
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/134/fs
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/1/fs
upperdir:
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/135/fs
workdir:
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/135/work
拆解:
| 层 |
路径 |
是什么 |
lowerdir[0] |
snapshots/134/fs |
镜像的上层 |
lowerdir[1] |
snapshots/1/fs |
alpine 基础层(12.2MB,所有容器共享) |
upperdir |
snapshots/135/fs |
本容器私有可写层 |
workdir |
snapshots/135/work |
overlayfs 内部工作目录 |
注意 lowerdir 的顺序:越靠左优先级越高。
存成变量方便后续使用:
UPPER=$(grep -oE 'upperdir=[^ ,]*' /proc/$CPID/mountinfo | head -1 | cut -d= -f2 | cut -d, -f1)
LOWER1=$(grep -oE 'lowerdir=[^ ,]*' /proc/$CPID/mountinfo | head -1 | cut -d= -f2 | cut -d: -f1)
LOWER2=$(grep -oE 'lowerdir=[^ ,]*' /proc/$CPID/mountinfo | head -1 | cut -d= -f2 | cut -d: -f2)
3.2 场景 A:只读不复制
docker exec ov-lab sh -c 'wc -c < /etc/passwd'
# 702
宿主机检查 upper 层:
echo " upper 层中存在? $([ -e $UPPER/etc/passwd ] && echo "是(已复制)" || echo "否(未复制)")"
echo " lower 层中存在? $([ -e $LOWER2/etc/passwd ] && echo "是(只读源)" || echo "否")"
upper 层中存在? 否(未复制)
lower 层中存在? 是(只读源)
只读不复制,零空间开销。 这就是 100 个容器共用一个镜像只占一份 12.2MB 的原因。
3.3 场景 B:修改触发完整复制
docker exec ov-lab sh -c 'echo "modified-by-container" >> /etc/passwd'
宿主机检查:
echo "upper 层存在 /etc/passwd? $([ -e $UPPER/etc/passwd ] && echo "是(已复制)" || echo "否")"
tail -1 $UPPER/etc/passwd
upper 层存在 /etc/passwd? 是(已复制)
modified-by-container
关键验证——镜像原始层是否被污染:
echo " 含 modified 的行数: $(grep -c modified $LOWER2/etc/passwd 2>/dev/null)"
echo " 文件大小: $(stat -c %s $LOWER2/etc/passwd) 字节 (原始大小)"
echo " upper 层大小: $(stat -c %s $UPPER/etc/passwd) 字节"
含 modified 的行数: 0
文件大小: 702 字节 (原始大小)
upper 层大小: 724 字节
镜像层 702 字节原封不动,upper 层是 724 字节的完整副本。 隔离性得到硬验证。
完整流程:
1. 容器内执行 >> /etc/passwd
2. overlayfs 检查:目标文件在 lowerdir 中(只读)
3. 复制整个文件到 upperdir ← 完整复制,不是增量
4. 修改 upperdir 中的副本
5. 之后的读取都命中 upperdir(优先级更高)
3.4 场景 C:新建文件
docker exec ov-lab sh -c 'echo "brand-new-file" > /newfile.txt'
ls -l $UPPER/newfile.txt
cat $UPPER/newfile.txt
-rw-r--r-- 1 root root 15 Oct 20 03:13 /var/lib/containerd/.../snapshots/135/fs/newfile.txt
brand-new-file
容器内写 /newfile.txt,宿主机上真实路径是 snapshots/135/fs/newfile.txt。可写层就是容器数据的物理归宿。
3.5 删除的 whiteout 机制
overlayfs 不能真的删 lower 层文件(只读),所以用特殊标记表示"已删除"。
docker exec ov-lab sh -c 'rm -f /etc/passwd'
ls -la $UPPER/etc/ | grep -E "passwd|wh"
c--------- 2 root root 0, 0 Oct 20 03:13 passwd
三个特征:
| 特征 |
值 |
含义 |
| 权限位 |
c--------- |
字符设备(c 开头) |
| 大小 |
0, 0 |
零字节 |
| link count |
2 |
overlayfs 用硬链接计数区分普通文件和 whiteout |
overlayfs 的约定:link count 为 2 的字符设备文件就是 whiteout,表示"遮蔽 lower 层同路径文件"。
容器内验证删除生效:
docker exec ov-lab sh -c 'test -f /etc/passwd && echo " 仍存在" || echo " 已删除"'
已删除
3.6 多容器共享镜像层
docker run -d --name ov-lab2 alpine:3.20 sh -c 'while true; do sleep 30; done'
sleep 2
CPID2=$(docker inspect ov-lab2 --format '{{.State.Pid}}')
echo "容器1 lowerdir: $(grep -oE 'lowerdir=[^ ,]*' /proc/$CPID/mountinfo | head -1 | cut -d= -f2 | cut -d, -f1)"
echo "容器2 lowerdir: $(grep -oE 'lowerdir=[^ ,]*' /proc/$CPID2/mountinfo | head -1 | cut -d= -f2 | cut -d, -f1)"
echo "容器1 upperdir: $(grep -oE 'upperdir=[^ ,]*' /proc/$CPID/mountinfo | head -1 | cut -d= -f2 | cut -d, -f1)"
echo "容器2 upperdir: $(grep -oE 'upperdir=[^ ,]*' /proc/$CPID2/mountinfo | head -1 | cut -d= -f2 | cut -d, -f1)"
实测输出:
容器1 lowerdir: .../snapshots/134/fs:.../snapshots/1/fs
容器2 lowerdir: .../snapshots/136/fs:.../snapshots/1/fs
容器1 upperdir: .../snapshots/135/fs
容器2 upperdir: .../snapshots/137/fs
结构非常清晰:
snapshots/1/fs ← alpine 基础层,被所有 alpine 容器共享(只读,12.2MB 一份)
snapshots/134/fs ← 容器1 的镜像上层
snapshots/135/fs ← 容器1 可写层(私有)
snapshots/136/fs ← 容器2 的镜像上层
snapshots/137/fs ← 容器2 可写层(私有)
snapshots/1/fs 两个容器完全共用。 N 个容器的磁盘开销 = 1 份镜像 + N 份微小可写层。
3.7 容器间改动互不可见
echo "容器1 /etc/passwd: $(docker exec ov-lab sh -c 'test -f /etc/passwd && echo 存在 || echo 已删除')"
echo "容器2 /etc/passwd: $(docker exec ov-lab2 sh -c 'test -f /etc/passwd && echo 存在 || echo 已删除')"
容器1 /etc/passwd: 已删除
容器2 /etc/passwd: 存在
容器 1 删了 /etc/passwd(在 upper 层留了 whiteout),容器 2 完全不受影响。upperdir 严格私有。
3.8 删除容器 = 删除 upperdir
docker rm ov-lab
# 宿主机上直接看 snapshots 目录
ls /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/
删除容器后,snapshots/135/(upperdir + workdir)一起消失,snapshots/1、snapshots/134 等镜像层不受影响,被其他容器继续共享。
这就是容器数据"临时性"的物理来源:容器数据只存在于 upperdir,而 upperdir 绑定在容器生命周期上。
四、由此推出的实践准则
必须用 Volume / Bind Mount 的场景
| 数据类型 |
原因 |
| 数据库文件 |
容器一删就没 |
| 用户上传的文件 |
同上 |
| 应用配置 |
想换配置不该重建容器 |
| 日志 |
需要集中收集和持久化 |
| 容器间共享的数据 |
需要跨容器生命周期 |
判断标准:这份数据在容器被删/重建后还需要吗?需要 → 必须挂出去。
避免 upper 层膨胀
docker exec <c> du -sh /var/lib/docker/upperdir # 查该容器可写层大小
docker ps -s # 看所有容器可写层大小
| 坏习惯 |
后果 |
| 容器内写大量日志到文件 |
upper 层暴涨 |
容器内 apt install / apk add |
几百 MB 进 upper 层 |
| 频繁修改大文件 |
每次修改都是完整复制 |
正确做法:日志走 stdout(交给 log driver),依赖装进镜像,运行时数据挂 volume。
排查 upper 层内容
# 看容器可写层里有什么
UPPER=$(grep -oE 'upperdir=[^ ,]*' /proc/$(docker inspect <c> --format '{{.State.Pid}}')/mountinfo | head -1 | cut -d= -f2 | cut -d, -f1)
du -sh $UPPER
find $UPPER -type f -size +10M | head # 大文件
五、常见误解
误解 1:容器有独立的文件系统
不准确。 容器共享镜像的只读层,只有一个独立的可写层(upperdir)。真正的独立文件系统需要 VM。
误解 2:修改容器内的文件会损坏镜像
不会。 实测证明镜像层完全干净:
docker exec ov-lab sh -c 'echo "modified-by-container" >> /etc/passwd'
grep -c modified $LOWER2/etc/passwd
# 0 ← 镜像层零污染
写时复制把改动全隔离在 upperdir。
误解 3:删容器只是删了"壳",数据还在
错。 数据在 upperdir,删容器就是删 upperdir。要保留数据必须提前挂 volume。
误解 4:CoW 是增量复制,很省空间
错,是完整复制。 实测 702 字节的文件被改成 724 字节,upper 层里是 724 字节的完整文件,不是 22 字节的差量。
改大文件会让 upper 层暴涨。
误解 5:whiteout 是个普通空文件
不是。 它是字符设备(权限位 c 开头),且 link count = 2。这是 overlayfs 识别删除标记的方式。
ls -la $UPPER/etc/passwd
# c--------- 2 root root 0, 0 ... passwd
误解 6:镜像层每个容器都存一份
错。 实测两个容器的 lowerdir 都包含同一个 snapshots/1/fs,只读层全局共享。
六、本篇速查
# 拿 overlay 三层路径(Docker 29 正确方式)
CPID=$(docker inspect <c> --format '{{.State.Pid}}')
grep -oE 'upperdir=[^ ,]*' /proc/$CPID/mountinfo | cut -d= -f2 | cut -d, -f1
grep -oE 'lowerdir=[^ ,]*' /proc/$CPID/mountinfo | head -1 | cut -d= -f2
# 完整挂载参数
grep -E ' / .*overlay' /proc/$CPID/mountinfo
# 看容器可写层大小
docker ps -s
# 查 upper 层大文件
UPPER=$(grep -oE 'upperdir=[^ ,]*' /proc/$CPID/mountinfo | head -1 | cut -d= -f2 | cut -d, -f1)
find $UPPER -type f -size +10M | head
# 识别 whiteout
ls -la $UPPER/path | grep '^c'
# 确认镜像层未被污染
grep -c yourmarker $LOWER2/etc/passwd
上一篇:01 · Namespace:隔离视野
下一篇:03 · Cgroup:资源限额
相关:专题索引