5.4 overlayfs 与镜像分层
镜像层是只读的,容器却能在里面「改文件、删文件」,这些操作到底落在哪?overlayfs 的 lower/upper/work/merged 四个目录各自是什么角色?为什么删一个 lower 里的文件,磁盘占用反而增加?
手工挂一个三层 overlay 并验证 copy-up、whiteout、opaque;看懂 docker save 层 tar 里的 .wh. 文件;在 /var/lib/docker/overlay2 里定位容器可写层;判断 copy-up 带来的启动延迟与 inode 问题。
镜像分层不是一个抽象概念,它在磁盘上有明确的位置:一组只读目录、一个可写目录、一个工作目录,加一个挂载点。内核的 overlayfs 把这四者拼成一个「合并视图」,让容器里的进程以为自己在用一个普通的根文件系统。
这个「拼」出来的文件系统有三个不直观的行为:读一个文件可能来自任意一层;第一次写一个 lower 文件会把整个文件复制到 upper;删除一个 lower 文件不会释放空间,反而会在 upper 里留下一个 0 字节的标记。这三个行为分别对应查找顺序、copy-up 与 whiteout,是理解容器磁盘占用与启动性能的关键。
这一章从一次手工挂载开始,先看清四个目录与合并规则,再验证 copy-up 与 whiteout,最后回到 Docker:层 tar 里的 .wh. 文件、overlay2 的目录布局、以及 docker save/load 与镜像层的关系。所有挂载命令都需要 root,末尾都有清理步骤。
5.4.1 为什么需要联合挂载
容器启动要求「镜像只读、容器可写、多个容器共享镜像层」。最直接的做法是每次启动复制一份 rootfs:简单,但一个 1 GiB 的镜像起 20 个容器就要 20 GiB,启动时间也随镜像大小线性增长。
联合挂载(union filesystem)换了一个思路:把多个目录叠在一起,读的时候按优先级取,写的时候只写最上面一层。镜像层永远不动,所有修改进容器自己的可写层。于是:
- 磁盘占用 = 镜像层一份 + 每个容器的可写层(只含改动);
- 启动时间 = 挂载一个 overlay(毫秒级),不复制数据;
- 多个容器共享同一份只读层,页缓存也能共享。
overlayfs 不是唯一的联合文件系统。AUFS 曾经是 Docker 的默认选择,因长期未进主线而被 overlay2 取代;UnionFS、mhddfs 等也做过类似的事。overlayfs 在 3.18 进入主线,此后的 Docker overlay2 驱动直接用它。
一次 overlay 挂载由四部分组成:
- lowerdir:一个或多个只读目录,按从左到右的优先级叠放(最左最高);
- upperdir:可写目录,所有修改、新建、删除标记都落在这里;
- workdir:与 upperdir 同一文件系统上的空目录,供 copy-up、rename 等操作暂存;
- merged:挂载点,进程看到的是 lower 与 upper 合并后的视图。
upperdir 与 workdir 必须位于同一文件系统;只读挂载可以省略 upper/work,只给 lowerdir。
在普通文件系统上,删除文件释放数据块。在 overlayfs 上,lower 是只读的,不能真的删;内核只能在 upper 里放一个「这个名字被删了」的标记(whiteout)。于是删除一个 1 GiB 的 lower 文件,lower 的 1 GiB 仍然占着,upper 再多一个 0 字节的字符设备。删除不回收镜像层空间,只阻止它再被看见。 镜像层真正释放要等没有容器再引用它。
5.4.2 一次挂载:lower/upper/work/merged
先不用 Docker,手工挂一个两层 lower 加一层 upper 的 overlay。
# 需要 root
sudo mkdir -p /tmp/ovl/{lower1,lower2,upper,work,merged}
# lower1 是最高优先级的只读层
echo "a from lower1" | sudo tee /tmp/ovl/lower1/a.txt
sudo mkdir -p /tmp/ovl/lower1/dir
echo "x from lower1" | sudo tee /tmp/ovl/lower1/dir/x.txt
# lower2 是更底下的只读层,同名文件会被 lower1 盖住
echo "a from lower2" | sudo tee /tmp/ovl/lower2/a.txt
echo "b from lower2" | sudo tee /tmp/ovl/lower2/b.txt
# 挂载:lowerdir 用冒号分隔,最左最高;upper/work 必须同一文件系统
sudo mount -t overlay overlay \
-o lowerdir=/tmp/ovl/lower1:/tmp/ovl/lower2,upperdir=/tmp/ovl/upper,workdir=/tmp/ovl/work \
/tmp/ovl/merged
# 合并视图
ls /tmp/ovl/merged
cat /tmp/ovl/merged/a.txt
# a from lower1
cat /tmp/ovl/merged/b.txt
# b from lower2
a.txt 在两层都有,读到的是 lower1 的版本——lowerdir 列表越靠左,优先级越高。这与「后写的覆盖先写的」相反,初次接触容易记反。
┌──────────── merged(挂载点,容器看到的 /)────────────┐
│ /a.txt = lower1/a.txt(未修改,直接读 lower) │
│ /b.txt = whiteout(upper 里是 0:0 字符设备) │
│ /c.txt = upper/c.txt(copy-up 后的副本) │
│ /dir/ = upper/dir(opaque=y,不再合并 lower 的 dir)│
└──────────────▲───────────────────────▲────────────────┘
│ 查找:upper 优先 │ 未命中则逐层向下
┌──────────────────┴──────────┐ ┌─────────┴──────────────────┐
│ upperdir(可写,容器层) │ │ lowerdir(只读,镜像层) │
│ a.txt c.txt │ │ lower1: a.txt dir/x.txt │
│ b.txt 是 0:0 字符设备 │ │ lower2: a.txt b.txt │
│ dir/ 带 opaque 扩展属性 │ │ │
└──────────────────────────────┘ └────────────────────────────┘
│
▼
workdir(同一文件系统,copy-up 暂存)
读这张图时把「合并视图」当成一层薄薄的索引:它不存数据,只决定每个名字去哪个目录取。目录会合并(upper 与 lower 同名目录的内容一起列出),普通文件不会(upper 有就用 upper,否则用最上层命中的 lower)。
挂载参数检查:
findmnt -T /tmp/ovl/merged -o TARGET,SOURCE,FSTYPE,OPTIONS
# /tmp/ovl/merged overlay overlay rw,relatime,lowerdir=...,upperdir=...,workdir=...
# lowerdir 的层数上限受挂载选项长度限制;Docker 用短符号链接规避
getconf PAGE_SIZE
# 4096
lowerdir 是一整个字符串,受 mount 选项长度(通常一页 4096 字节)限制。层数很多时路径会超长,Docker 的做法是在 /var/lib/docker/overlay2/l/ 下为每层建一个短名符号链接,挂载时用 lowerdir=l/ABC:l/DEF:...。内核 6.8 的 OVL_MAX_STACK 是 500,实际能挂多少层还要看路径长度。
只读 overlay 不需要 upper/work,常用于「把多层镜像拼起来检查」:
# 需要 root;只读挂载,不会产生任何写入
sudo mount -t overlay overlay -o lowerdir=/tmp/ovl/lower1:/tmp/ovl/lower2 \
/tmp/ovl/merged
findmnt -T /tmp/ovl/merged -o TARGET,FSTYPE,OPTIONS
cat /tmp/ovl/merged/a.txt
sudo umount /tmp/ovl/merged
只读挂载没有 upper,因此所有写操作都会返回 EROFS;它的价值在于「不改动任何层就能看合并结果」,镜像构建工具常用它来校验层内容。
清理(先卸载再删目录):
sudo umount /tmp/ovl/merged
sudo rm -rf /tmp/ovl
- 「upperdir 和 workdir 可以在不同文件系统」——不行。copy-up 依赖 rename 的原子性,两者必须同一文件系统,否则挂载直接失败。
- 「workdir 可以随便选个非空目录」——workdir 必须是空目录,且不能与 upperdir 相同。
- 「lowerdir 写在右边优先级更高」——相反,最左最高。
- 「upper 用 NFS 也行」——NFS 不提供 overlayfs 需要的
d_type与trusted.*xattr 语义,不能做 upper。 - 「只读 overlay 也必须给 upper/work」——可以只给
lowerdir=,此时挂载是只读的;Docker 的镜像层检查就常用这种只读挂载。
5.4.3 查找、合并与 inode
overlayfs 的查找规则可以写成一段伪代码:
lookup(name):
if upper/name 存在:
if 是 whiteout: 返回 ENOENT
if 是普通文件: 返回 upper/name
if 是目录: 记录 upper/name,继续向下找同名目录
for layer in lowerdirs(从左到右):
if layer/name 存在:
if 是目录: 记录,继续
else: 返回 layer/name
合并所有记录到的目录,返回目录项
三个由此产生的行为:
- 目录合并只合并名字列表。upper 与 lower 同名目录的条目会一起列出;但目录的元数据(权限、属主、xattr)只报 upper 的,lower 的被隐藏。
readdir结果会被缓存。合并后的名字列表缓存在打开的struct file里,直到rewinddir(offset 0)才重建。所以「一边读目录一边改文件」可能看不到新条目——这不是 bug,是缓存语义。st_dev/st_ino不保证统一。目录报 overlay 自己的st_dev;非目录对象可能报 lower 或 upper 的st_dev。如果所有层在同一个底层文件系统上,overlayfs 可以让它们统一;跨文件系统时要用xino=on才能拼出稳定 inode。
# 需要 root;观察 merged 与 lower/upper 的 inode 关系
stat -c '%d:%i %n' /tmp/ovl/merged/a.txt /tmp/ovl/lower1/a.txt
stat -c '%d:%i %n' /tmp/ovl/merged/dir /tmp/ovl/upper/dir 2>/dev/null || true
未修改的 a.txt 在 merged 与 lower1 里的 st_dev/st_ino 很可能相同(overlayfs 直接把 lower 的 inode 报给你);一旦 copy-up,merged 里的 inode 会变成 upper 的。依赖 inode 做去重、缓存、备份的工具(rsync --inplace、tar、某些备份软件)需要知道这一点。
当 lower 与 upper 在不同的底层文件系统上时,两个不同文件可能碰巧有相同的 (st_dev, st_ino),或者同一个文件在 merged 与 lower 里报不同的 st_dev。xino=on 用 inode 号的高位编码底层文件系统 ID,拼出一个全局唯一且稳定的 st_ino。代价是占用 inode 位宽;如果底层 inode 号太大,overlayfs 会退回非 xino 行为。
- 「
find merged -inum N能定位唯一文件」——在未开 xino 的跨文件系统 overlay 上,st_ino可能重复;find -samefile也可能误判。 - 「容器里
du -sh /等于容器占用」——容器里的du看 merged,会把只读镜像层也算进去;要看容器可写层,用du -sh /var/lib/docker/overlay2/<id>/diff或docker ps -s。 - 「
tar备份 merged 会包含所有层」——会,但它按合并视图读,得到的是「当前可见的文件」;被 whiteout 隐藏的文件不会进备份,这通常正是你要的。 - 「inode 号不会变」——copy-up 后 merged 里的 inode 会从 lower 的换成 upper 的;任何把 inode 当稳定 ID 的缓存都要考虑这一点。
5.4.4 copy-up:第一次写为什么慢
lower 是只读的,任何「写」操作都必须先把这个文件复制到 upper。这个动作叫 copy-up。触发条件比想象中宽:
| 操作 | 是否 copy-up | 说明 |
|---|---|---|
open(O_RDONLY) | 否 | 直接读 lower |
open(O_WRONLY/O_RDWR) | 是 | 即使不写数据,打开写也会复制 |
truncate/ftruncate | 是 | 修改大小 |
chmod/chown/utimes | 是 | 元数据修改也要复制(除非 metacopy=on) |
setxattr | 是 | 扩展属性 |
link | 是 | 硬链接需要实体 |
unlink | 否,改放 whiteout | 见 5.4.5 |
rename(同目录) | 可能是 | 依赖文件是否已在 upper |
继续用上面的挂载做实验:
# 需要 root;在 merged 里改 a.txt
echo "a modified in container" | sudo tee /tmp/ovl/merged/a.txt
# upper 里出现了完整副本
ls -l /tmp/ovl/upper
cat /tmp/ovl/upper/a.txt
# a modified in container
# lower 完全没变
cat /tmp/ovl/lower1/a.txt
# a from lower1
# inode 已经换成 upper 的
stat -c '%d:%i %n' /tmp/ovl/merged/a.txt /tmp/ovl/upper/a.txt
注意 tee 打开文件时用的是 O_WRONLY|O_CREAT|O_TRUNC,还没写入就已经触发 copy-up。这就是为什么「只是 chmod 一下」也会让一个大文件瞬间占双份空间:copy-up 复制的是整个文件,不是被修改的块。
copy-up 的代价可以用一个 512 MiB 的文件量出来:
# 需要 root
sudo dd if=/dev/zero of=/tmp/ovl/lower1/big.bin bs=1M count=512 status=none
sync
# 清 page cache,确保测量的是真实复制
echo 3 | sudo tee /proc/sys/vm/drop_caches > /dev/null
time sudo chmod 600 /tmp/ovl/merged/big.bin
# real 约 0.3–1.5 s,取决于磁盘
ls -l /tmp/ovl/upper/big.bin
chmod 触发了整文件 copy-up。对于数据库、日志、大镜像层里的单个大文件,这个延迟会直接表现为「容器第一次写很慢」。缓解手段有三个:
metacopy=on:元数据修改只复制元数据(inode + xattr),数据等真正写时再复制;- 应用层避免在容器可写层写大文件,改用 volume/bind mount;
- 构建镜像时把频繁修改的文件放在靠上的层,减少下层大文件的 copy-up。
metacopy 不是免费的:它引入 trusted.overlay.metacopy 状态,与 redirect_dir、nfs_export 有冲突;对不可信 lower 使用还有安全风险(攻击者可以伪造 xattr 指向别的文件)。生产上是否开启要看内核版本与镜像来源。
overlayfs 在一次数据 copy-up 完成前,会先把内容写进 upper 的暂存文件,fsync 它,再用 rename/link 原子地放到目标名字上。因此在崩溃后,你看到的上层文件要么是完整的新内容,要么根本不存在,不会出现「写了一半的 upper 文件」。
但这个保证只覆盖数据 copy-up:目录项与元数据修改(chmod/chown)不保证与数据在同一事务里落盘。overlayfs 默认对 upper 正常下发 sync,只有 volatile 会省略全部 sync;需要更强持久性时对关键文件显式 fsync,并确保底层设备写缓存有保护。fsync=strict 并不是 overlayfs 的挂载选项(见 5.4.12 的更正)。
5.4.5 whiteout 与 opaque:删除怎么表达
删除 lower 文件时,overlayfs 在 upper 里创建一个 whiteout:设备号为 0:0 的字符设备。它的唯一含义是「这个名字在合并视图里不存在」。
# 需要 root;删除 lower2 的 b.txt
sudo rm /tmp/ovl/merged/b.txt
ls -l /tmp/ovl/upper
# c--------- 1 root root 0, 0 ... b.txt
stat -c '%F %t:%T %n' /tmp/ovl/upper/b.txt
# character special file 0:0 /tmp/ovl/upper/b.txt
# 合并视图里已经看不到它
cat /tmp/ovl/merged/b.txt
# cat: /tmp/ovl/merged/b.txt: No such file or directory
删除目录则是另一套标记。把一个 lower 目录整个隐藏,需要在 upper 建同名目录并设置 opaque 扩展属性:
# 需要 root;先删掉 merged 里的 dir(会在 upper 留 whiteout 或空目录)
sudo rm -rf /tmp/ovl/merged/dir
# 再新建同名目录:upper 里的 dir 会带 opaque=y
sudo mkdir /tmp/ovl/merged/dir
# 读 opaque 扩展属性(trusted 命名空间需要 root)
sudo python3 -c 'import os; print(os.getxattr("/tmp/ovl/upper/dir", "trusted.overlay.opaque"))'
# b'y'
# lower1/dir 里的 x.txt 不再出现
ls -A /tmp/ovl/merged/dir
trusted.overlay.opaque=y 的含义是「这个 upper 目录不与任何 lower 同名目录合并」。没有它,mkdir merged/dir 会把 lower 的 x.txt 又合并进来,删除就不彻底。
两种标记的对照:
| 标记 | 位置 | 形式 | 语义 |
|---|---|---|---|
| whiteout | upper 中与目标同名的条目 | 字符设备 0:0(或带 trusted.overlay.whiteout 的 0 字节普通文件) | 这个名字被删除 |
| opaque | upper 目录自身 | trusted.overlay.opaque=y 扩展属性 | 这个目录不与 lower 合并 |
| redirect | upper 目录 | trusted.overlay.redirect=<路径> | 目录被 rename 过,去新路径找 lower 内容(redirect_dir=on) |
镜像层 tar 里用的是另一套写法:.wh.<name> 表示删除 <name>,.wh..wh..opq 表示当前目录 opaque。Docker 在解包层时把这些文件翻译成 overlayfs 的 whiteout/opaque。5.4.6 会实际看到它们。
- 「删了文件,镜像层就小了」——不会。lower 只读,whiteout 只是标记;空间要等镜像层无人引用才能回收。
- 「whiteout 是普通空文件」——默认是 0:0 字符设备;某些场景(如用户态生成的层)也用带
trusted.overlay.whiteoutxattr 的 0 字节普通文件。 - 「opaque 目录里还能看到 lower 的文件」——opaque 的作用就是阻止合并;如果还能看到,说明 xattr 没设置成功(常见于 upper 文件系统不支持
trusted.*)。 - 「在 lower 里手动造 whiteout 也生效」——不会。whiteout 只在 upper(以及被当作 upper 的层)里被解释;lower 里的 whiteout 是普通文件或会被忽略。
5.4.6 从层 tar 到 overlay2
现在把手工实验与 Docker 对上。先造一个「先创建、再删除」的镜像,看层 tar 里留下了什么:
# 不需要 root(用户在 docker 组)
mkdir -p /tmp/whdemo && cd /tmp/whdemo
cat > Dockerfile <<'EOF'
FROM busybox
RUN mkdir -p /data \
&& echo keep > /data/keep \
&& echo gone > /data/gone \
&& rm /data/gone
EOF
docker build -t whdemo .
docker save whdemo -o whdemo.tar
mkdir -p x && tar -C x -xf whdemo.tar
ls x
# 逐层看 tar 内容;层目录名随 Docker 版本不同
for l in x/*/layer.tar; do
echo "== $l"
tar -tf "$l"
done
某一层的输出里会出现:
data/
data/keep
data/.wh.gone
.wh.gone 就是「删除 gone」的层内表示。Docker 解包这一层时,会在 overlay2 的 upper 里创建一个名为 gone 的 whiteout(0:0 字符设备),于是合并视图里看不到它。注意 data/keep 与 data/.wh.gone 在同一个 tar 里:这一层既新增了 keep,又删除了 gone。
如果 Docker 启用了 containerd 镜像存储,docker save 会产出 OCI 布局(oci-layout、index.json、blobs/sha256/...),层是 gzip 压缩的 blob,没有 layer.tar 目录。用 tar -tf x/blobs/sha256/<digest> | zcat 之类的方式看内容;语义与经典布局一致。
再看容器可写层。overlay2 的目录结构是:
/var/lib/docker/overlay2/
├── <cache-id>/
│ ├── diff/ ← 这一层的实际内容(镜像层或容器层)
│ ├── link ← 短名,供 lowerdir 引用
│ ├── lower ← 该层的下层列表(短名)
│ ├── work/ ← 仅容器层有
│ └── merged/ ← 仅运行中的容器有(挂载点)
├── <cache-id>-init/
│ └── diff/ ← 容器启动时生成的 /etc/hosts、/etc/resolv.conf 等
└── l/
└── <short> -> ../<cache-id>/diff
l/ 下的短符号链接解决的就是 5.4.2 提到的挂载选项长度问题。查看某个容器的四个目录:
docker run -d --name ovldemo busybox sleep 300
docker inspect ovldemo --format '{{json .GraphDriver.Data}}' | python3 -m json.tool
# 需要 root:upper 就是容器可写层
sudo ls -la /var/lib/docker/overlay2/*/diff | head
# 在容器里删一个基础镜像自带的文件,观察 upper 里出现 whiteout
docker exec ovldemo rm /etc/motd 2>/dev/null || true
docker diff ovldemo
sudo find /var/lib/docker/overlay2 -name motd -printf '%y %p\n' 2>/dev/null | head
docker rm -f ovldemo
docker diff 的 D 表示删除;宿主上对应 upper 里的 whiteout(find -type c 能看到字符设备)。-init 层是 Docker 为每个容器额外生成的一层,用来放 /etc/hosts、/etc/resolv.conf、/etc/hostname 这些随容器变化的文件,避免污染镜像层。
镜像层与容器层的关系可以总结成一张表:
| 对象 | 磁盘位置 | 读写 | 生命周期 |
|---|---|---|---|
| 镜像层 | overlay2/<cache-id>/diff | 只读 | 被镜像引用;docker image rm 且无引用后回收 |
| 容器层 | overlay2/<container-id>/diff | 可写 | 随容器删除;docker rm 即回收 |
| init 层 | overlay2/<id>-init/diff | 只读(启动时生成) | 随容器删除 |
| 挂载点 | overlay2/<id>/merged | 合并视图 | 容器运行时存在 |
docker save/docker load 搬的是镜像层与 config,不包含容器层。所以「把容器提交成镜像」是另一条路(docker commit):它把容器当前的可写层压成一个新层,追加到镜像历史里。这条路径不适合生产(不可复现、层膨胀),但理解它能加深对「层就是 diff」的认识。
docker history 能看出每一层是谁、多大,是定位「哪一层把镜像撑大」的最快方式:
docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' whdemo
docker image inspect whdemo --format '{{.Size}}'
# 26214400(未压缩总大小,单位字节)
SIZE 是每层未压缩大小;只改元数据的层(ENV、CMD、LABEL)显示 0B。docker history 从上到下是「最新层 → 基础层」,与 RootFS.Layers 的顺序相反。注意 docker image ls 的 SIZE 与 docker history 各行之和可能对不上:前者是去重后的展示值,后者是每层独立大小,共享层不会被扣掉。
容器可写层里的 whiteout 会被 commit 打包成 .wh. 文件,但它们没有对应的「被删除数据」可打包——被删的文件仍在 lower 里。真正让镜像变大的是:容器里新写入的文件、日志、包缓存、apt 列表。commit 不会压缩、不会清理,也不复用构建缓存,所以「运行一段时间再 commit」通常得到一个又大又不可复现的镜像。正确做法是写 Dockerfile,让每一层只做一件可缓存的事。
5.4.7 性能、inode 与边界
overlayfs 的性能特征可以归纳成三句话:读命中最快,首次写最慢,元数据操作比普通文件系统贵。
- 读:lower 的文件直接读底层文件系统,命中 page cache 时与普通读无异。多个容器共享同一镜像层时,页缓存也共享。
- 首次写:整文件 copy-up(5.4.4)。大文件、多层 lower、冷缓存三者叠加时延迟最明显。
- 元数据:
readdir要合并多层名字并缓存;stat可能要穿透多层;rename目录在默认配置下直接返回EXDEV。
几个必须知道的边界:
| 边界 | 表现 | 规避 |
|---|---|---|
| 目录 rename | 默认 EXDEV(跨设备) | redirect_dir=on,或避免对 lower 目录改名 |
| 硬链接 | copy-up 后断裂,多个名字不再指向同一 inode | index=on(需要底层支持 NFS file handle) |
| NFS export | 默认不支持 | nfs_export=on + index=on;upper 不能是 NFS |
| inotify | 监听 merged 看不到 lower 上的直接修改 | 不要直接改 lower;需要事件用 fanotify 或应用层方案 |
mmap(MAP_SHARED) | 写映射需要可写 fd,open(O_RDWR) 时已触发 copy-up;只读 fd 上 PROT_WRITE 映射会 EACCES | 用 O_RDWR 打开后再映射,或改用 volume |
ETXTBSY | 执行 lower 中的程序时,写打开不会被拒绝 | 不要在运行中替换可执行文件;用 rename 原子替换 |
| inode 耗尽 | 每层、每个文件都占底层 inode | 监控 df -i;清理无用镜像与构建缓存 |
inode 是容器密集场景最容易忽略的资源。overlay2 的每个层目录都在底层文件系统上占 inode;一个小文件多、层数多的镜像会迅速消耗 inode。df -i /var/lib/docker 比 df -h 更早报警。清理手段是 docker image prune、docker builder prune,以及给 /var/lib/docker 单独分区。
另一个常见问题是「容器里 du 与宿主 du 不一致」。容器里的 du 看 merged,会把 lower 的文件也算进去;宿主的 du 看 overlay2/<id>/diff,只算容器层。两者都没错,只是统计对象不同。要回答「这个容器占了多少」,用 docker ps -s 的 SIZE(可写层)加镜像层分摊,或 docker system df -v。
量一次 copy-up 的代价
把 256 MiB 的文件放进镜像层,再在容器里追加一个字节,就能量出整文件 copy-up 的延迟:
# 不需要 root(用户在 docker 组)
mkdir -p /tmp/cubench && cd /tmp/cubench
cat > Dockerfile <<'EOF'
FROM busybox
RUN dd if=/dev/zero of=/big.bin bs=1M count=256 status=none
EOF
docker build -t cubench .
# 第一次运行:写 /big.bin 触发 256 MiB copy-up
time docker run --rm cubench sh -c 'echo x >> /big.bin'
# 第二个容器:同样的 copy-up 再做一次
time docker run --rm cubench sh -c 'echo x >> /big.bin'
两个容器的耗时相近,说明 copy-up 是每个容器各自发生的,镜像层不会因此被修改。若把文件放到 volume 或 bind mount 上,写入直接落到宿主目录,没有 copy-up:
mkdir -p /tmp/cubench/data
time docker run --rm -v /tmp/cubench/data:/data cubench \
sh -c 'echo x >> /data/app.log'
这类测量能解释两个生产现象:一是「大镜像启动后第一次写很慢」,二是「同一镜像起 100 个容器时磁盘 IO 尖峰」——每个容器第一次写同一个大文件,都会复制一份。把可变的大文件放到 volume,是最有效的规避方式。
清理:
docker rmi cubench
rm -rf /tmp/cubench
- 先分三类:镜像层(多镜像共享)、容器层(每容器一份)、volume(独立于容器生命周期)。
- 看总量:
docker system df -v,分别看Images、Containers、Local Volumes、Build Cache的SIZE与RECLAIMABLE。 - 看单容器:
docker ps -s的SIZE列是可写层大小,virtual列含镜像层。 - 进容器找大目录:
du -x -h -d1 / 2>/dev/null | sort -h | tail;注意结果包含只读镜像层。 - 到宿主对照:
du -sh /var/lib/docker/overlay2/*/diff,找出真正增长的容器层。 - 镜像太大:
docker history定位大层,回到 Dockerfile 合并RUN、清理包缓存、使用多阶段构建。 - inode 满:
df -i /var/lib/docker,用docker image prune -a、docker builder prune回收;长期方案是给/var/lib/docker独立分区。
早期 rootless 容器用 shiftfs 做「uid 平移」:把一个目录按映射后的 uid 呈现给容器,避免 chown 整个 rootfs。shiftfs 从未进入主线,维护成本高。内核 5.12 引入 idmapped mounts:在挂载点上附加一个 uid/gid 映射,访问时按映射解释属主与权限,底层 inode 不变。它现在是 rootless/无特权容器的标准做法,也是 overlayfs 支持 userns 的基础之一。
5.4.8 内核里的 overlayfs:三个结构体与一次查找
这一节回答:四个目录交给内核之后,overlayfs 用什么数据结构记住「这个名字来自哪一层」,一次 stat 又走了哪些函数。
挂载入口是 ovl_fill_super()(fs/overlayfs/super.c),选项解析在 fs/overlayfs/params.c。它为整个挂载点分配一个 struct ovl_fs,为每个层分配一个 struct ovl_layer;每查找一个名字,生成一个 struct ovl_entry 记录「这个名字在哪些 lower 层出现」;每个 overlay inode 对应一个 struct ovl_inode,持有 upper 的 dentry 与 copy-up 锁。
| 结构体 | 定义 | 作用 | 关键字段(Linux 6.8) |
|---|---|---|---|
ovl_fs | fs/overlayfs/ovl_entry.h | 每个 overlay 超级块一份 | numlayer、numfs、numdatalayer、layers、workbasedir、workdir、config、xino_mode |
ovl_layer | 同上 | 描述一个层 | mnt(该层的 vfsmount)、trap(inode 缓存陷阱)、idx(upper 为 0)、fsid |
ovl_entry | 同上 | 每个 dentry 的层栈 | __numlower、柔性数组 __lowerstack[](ovl_path 数组) |
ovl_inode | 同上 | 每个 overlay inode 的私有数据 | __upperdentry、oe、redirect、version、lock、cache/lowerdata_redirect 联合体 |
ovl_fs.layers 是一个数组,下标 0 固定是 upper,1 到 numlayer-1 是 lower(末尾可能还有 data-only 层,numdatalayer 单独计数)。ovl_entry.__lowerstack 只存 lower 的 (layer, dentry) 对,upper 的 dentry 放在 ovl_inode.__upperdentry 里。这种「upper 单独、lower 成栈」的布局让「upper 命中」成为一次指针解引用,而「lower 命中」需要遍历数组。
/* fs/overlayfs/ovl_entry.h(Linux 6.8,删去注释与不相干字段) */
struct ovl_layer {
struct vfsmount *mnt;
struct inode *trap;
struct ovl_sb *fs;
int idx; /* upper idx == 0 */
int fsid;
bool has_xwhiteouts;
};
struct ovl_path {
const struct ovl_layer *layer;
struct dentry *dentry;
};
struct ovl_entry {
unsigned int __numlower;
struct ovl_path __lowerstack[];
};
ovl_numlower() 返回 oe->__numlower;ovl_lowerpath() 返回栈顶;ovl_lowerdata() 返回最后一个 data-only 层。ovl_alloc_entry(n) 按 lower 层数一次性分配,所以一个目录在 8 层 lower 里存在时,它的 ovl_entry 里就有 8 个 ovl_path。这也是「层数越多,dentry 占用内存越大」的来源。
overlayfs 的元数据不存数据库,全部落在 upper 的扩展属性里。看懂这几条 xattr,getfattr 的输出就不再是天书:
xattr(trusted.overlay. 前缀) | 挂在谁身上 | 值 | 含义 |
|---|---|---|---|
opaque | upper 目录 | y / x | 不与 lower 同名目录合并;x 是带白化标记的目录 |
whiteout | upper 的 0 字节普通文件 | y | 这个名字被删除(xwhiteout 形式) |
redirect | upper 目录/文件 | 相对路径 | 目录被 rename 过,lower 内容去新路径找 |
metacopy | upper 文件 | 摘要字符串 | 只有元数据被 copy-up,数据仍指向 lower |
origin | upper 文件/目录 | file handle | 记录来源,供 NFS export 与 index 使用 |
impure | upper 目录 | y | 目录里有未与 lower 同步的条目,readdir 不能走快速路径 |
index | workdir 下的索引目录 | y | 硬链接索引项,index=on 时启用 |
不带 trusted. 前缀的文件系统(如某些 NFS、FAT)无法保存这套元数据,因此不能做 upper。userxattr 可以把前缀换成 user.overlay.,让无特权用户也能挂 overlay,但代价是任何能读该目录的用户都能伪造元数据——只应在完全可信的目录上使用。
一次查找经过的函数:
| 阶段 | 函数 | 文件 | 做什么 |
|---|---|---|---|
| 解析路径 | ovl_lookup() | namei.c | 从 dentry 缓存未命中开始,逐层查找 |
| 查一层 | ovl_lookup_layer() | namei.c | 在该层做 lookup_one,识别 whiteout |
| 合并结果 | ovl_get_inode() | inode.c | 按 upper 优先、lower 顺序选真实 inode |
| 目录迭代 | ovl_iterate() | readdir.c | 合并多层目录项,写入 ovl_dir_cache |
| 写前复制 | ovl_copy_up() / ovl_copy_up_one() | copy_up.c | 把文件或元数据复制到 upper |
| 删除 | ovl_unlink() | namei.c | 在 upper 造 whiteout |
| 改名 | ovl_rename() | namei.c | 同层 rename,跨层触发 copy-up 或 EXDEV |
| 统计 | ovl_statfs() | super.c | 转到底层 statfs |
配套的观测命令:
| 命令 | 看什么 |
|---|---|
findmnt -T merged -o TARGET,SOURCE,FSTYPE,OPTIONS | 实际挂载选项、lowerdir 顺序 |
stat -c '%d:%i %F %n' merged/a.txt upper/a.txt | inode 是否切到 upper、whiteout 类型 |
getfattr -d -m - upper/a.txt | trusted.overlay.* 元数据(需 root) |
ls -l upper | whiteout 是否显示为 0, 0 字符设备 |
df -i /var/lib/docker | inode 消耗,比 df -h 更早报警 |
bpftrace -l 'kprobe:ovl_*' | 当前内核可挂的 overlay 函数 |
d_type:为什么 upper 不能是 NFS
readdir(3) 返回的 struct dirent 里有一个 d_type 字段,告诉调用者「这是目录、普通文件还是符号链接」,调用者不用再 stat 一次。overlayfs 的目录合并依赖这个字段:
- 底层文件系统必须在
readdir里给出可靠的d_type。ext4、xfs(ftype=1)、btrfs 都支持;xfs 用ftype=0创建时全是DT_UNKNOWN,NFS 通常也不给。 - 拿到
DT_UNKNOWN时,ls、find、rm -rf只能对每个名字再lstat,系统调用数翻倍;overlayfs 自己也要多做一次查找才能判断目录项类型。 - upper 还要存
trusted.overlay.*xattr 并支持原子rename。NFS 的rename语义、xattr 支持、d_type三者都不满足,所以内核直接拒绝它做 upper。
检查你的底层文件系统:
# ext4/xfs 的 ftype 与 d_type 行为
xfs_info /var/lib/docker 2>/dev/null | grep -i ftype
stat -f -c '%T' /var/lib/docker
# ext2/ext3 或 xfs ftype=0 时,find 会退化为逐项 stat
find /var/lib/docker/overlay2 -maxdepth 1 -printf '%y %f\n' | head
find -printf '%y' 直接使用 d_type;如果输出里出现大量 ?,说明底层给的是 DT_UNKNOWN。这不会让 overlay 挂载失败,但会让镜像解包、docker diff、du 这类遍历操作明显变慢。
ovl_statfs() 把统计请求转给 upper 所在的底层文件系统(只读挂载时转给最上层 lower)。所以容器里 df -h / 显示的是宿主分区的大小,不是 cgroup 配额,也不是镜像层大小。要判断容器可写层用了多少,看 docker ps -s 或 du -sh /var/lib/docker/overlay2/<id>/diff。
5.4.9 层缓存:digest、cache-id 与共享
这一节回答:镜像层在磁盘上如何被复用,为什么「同一基础镜像起 100 个容器」不会复制 100 份,以及 docker image ls 的数字为什么不能直接相加。
一个 OCI 镜像由三部分组成:manifest、config、若干 layer blob。三个摘要容易混:
| 名称 | 计算对象 | 存在哪 | 用途 |
|---|---|---|---|
| layer digest | 压缩后的 layer blob | manifest 的 layers[].digest | 内容寻址,拉取/校验 |
| diffID | 解压后的 tar | config 的 rootfs.diff_ids | 标识解压后的层内容 |
| chainID | 从第一层到当前层的 diffID 串联后再哈希 | Docker 内部 layerdb | 作为层缓存键,决定能否复用 |
chainID 的设计目的是「同一串层只存一份」。两个镜像如果前 5 层完全相同,第 6 层不同,它们的前 5 层 chainID 相同,Docker 只保留一份 upper 目录。
# 看一个镜像的 diffID 列表
docker image inspect busybox --format '{{json .RootFS.Layers}}' | python3 -m json.tool
# overlay2 的层数据库:chainID -> cache-id
sudo ls /var/lib/docker/image/overlay2/layerdb/sha256/
# 每个目录里有 cache-id、diff、size、parent
cid=$(sudo cat /var/lib/docker/image/overlay2/layerdb/sha256/<chainID>/cache-id)
sudo ls "/var/lib/docker/overlay2/$cid/diff" | head
cache-id 是 overlay2 给这一层分配的短 ID,对应 /var/lib/docker/overlay2/<cache-id>/diff。如果两个镜像共享同一层,它们的 cache-id 相同;docker image rm 只删引用,不删目录,直到没有镜像再引用这个 chainID。
containerd 镜像存储是另一套路径,但概念相同:
| 对象 | Docker graphdriver | containerd snapshotter |
|---|---|---|
| 压缩 blob | /var/lib/docker/image/overlay2/... | /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/ |
| 解压后的层 | overlay2/<cache-id>/diff | ...snapshotter.v1.overlayfs/snapshots/<n>/fs |
| 元数据 | layerdb/sha256/<chainID>/ | ctr -n k8s.io snapshots ls |
# containerd 路径(Kubernetes 节点常见)
sudo ctr -n k8s.io snapshots ls | head
sudo ctr -n k8s.io content ls | head
共享发生在三个层次,不要混为一谈:
| 共享对象 | 共享范围 | 何时失效 |
|---|---|---|
| layer 数据块 | 所有引用同一 chainID 的镜像/容器 | 该层无人引用后被 GC |
| page cache | 所有打开同一 lower inode 的容器 | 文件被 copy-up 后,新写入走 upper 的 inode |
| dentry/inode 缓存 | 同一 overlay 挂载内 | 挂载销毁或 drop_caches |
| 容器可写层 | 不共享,每容器一份 | 随容器删除 |
所以「100 个容器共享 1 GiB 镜像」的准确说法是:磁盘上的只读层只有一份,页缓存也共享;每个容器另有一份可写层。若每个容器都往同一个 512 MiB 文件里写一个字节,100 个容器就会各自 copy-up 一份,磁盘占用变成 50 GiB——这正是 5.4.4 的代价在规模上的表现。
三个镜像共享 800 MiB 的基础层:A 加 100 MiB,B 加 200 MiB,C 加 300 MiB。
docker image ls每行显示「该镜像所有层之和」:A=900、B=1000、C=1100 MiB,相加 3000 MiB。- 磁盘上基础层只有一份,增量层各一份:800+100+200+300 = 1400 MiB。
docker system df 的 Images 行按去重后的实际占用统计,接近 1400 MiB;docker image ls 不做去重,所以两者对不上是正常的。判断「删了镜像能省多少」要看 docker system df -v 的 RECLAIMABLE,不要看 docker image ls 的 SIZE。
5.4.10 实验一:三层 overlay 的行为验证(root)
这一节给一个可以直接保存执行的脚本:它把 lower 优先级、copy-up、whiteout、opaque、目录 rename 的 EXDEV、只读挂载六件事一次验证完,失败时以非零码退出。
#!/usr/bin/env bash
# overlay-lab.sh — 在 /tmp 下验证 overlayfs 的六个行为;需要 root
set -euo pipefail
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "需要 root"; exit 1; }
B=/tmp/ovl-lab
M=$B/merged
cleanup() {
mountpoint -q "$M" && umount -l "$M" || true
mountpoint -q "$B/ro" && umount -l "$B/ro" || true
rm -rf "$B"
}
trap cleanup EXIT
ok() { printf '[ok] %s\n' "$*"; }
die() { printf '[FAIL] %s\n' "$*" >&2; exit 1; }
mkdir -p "$B"/{l1,l2,upper,work,merged,ro}
printf 'a from l1\n' > "$B/l1/a.txt"
mkdir -p "$B/l1/dir" "$B/l1/dironly"
printf 'x from l1\n' > "$B/l1/dir/x.txt"
printf 'a from l2\n' > "$B/l2/a.txt"
printf 'b from l2\n' > "$B/l2/b.txt"
# 1) 挂载:lowerdir 最左最高
mount -t overlay overlay \
-o "lowerdir=$B/l1:$B/l2,upperdir=$B/upper,workdir=$B/work" "$M"
[[ $(cat "$M/a.txt") == "a from l1" ]] || die "lower 优先级错误"
ok "lower 优先级:a.txt 来自 l1"
# 2) copy-up:写 merged 里的 lower 文件
printf 'changed in merged\n' >> "$M/a.txt"
[[ -f "$B/upper/a.txt" ]] || die "copy-up 没有生成 upper/a.txt"
[[ $(cat "$B/l1/a.txt") == "a from l1" ]] || die "lower 被改动了"
[[ $(stat -c %i "$M/a.txt") == $(stat -c %i "$B/upper/a.txt") ]] \
|| die "merged 的 inode 没有切到 upper"
ok "copy-up:upper/a.txt 存在,lower 未变,inode 已切换"
# 3) whiteout:删除只在 lower 存在的 b.txt
rm "$M/b.txt"
[[ ! -e "$M/b.txt" ]] || die "whiteout 后仍能看到 b.txt"
[[ $(stat -c %F "$B/upper/b.txt") == "character special file" ]] \
|| die "upper/b.txt 不是字符设备"
ok "whiteout:upper/b.txt 是 0:0 字符设备"
# 4) opaque:删掉 lower 目录再建同名目录
rm -rf "$M/dir"
mkdir "$M/dir"
[[ $(python3 -c "import os;print(os.getxattr('$B/upper/dir','trusted.overlay.opaque').decode())") == y ]] \
|| die "opaque xattr 没有设置"
[[ ! -e "$M/dir/x.txt" ]] || die "opaque 目录仍然合并了 lower"
ok "opaque:lower 的 dir/x.txt 被隐藏"
# 5) 目录 rename:默认 EXDEV
set +e
mv "$M/dironly" "$M/dironly2" 2>/dev/null
rc=$?
set -e
[[ $rc -ne 0 ]] || die "目录 rename 竟然成功了"
ok "rename lower 目录返回非零(EXDEV)"
# 6) 只读挂载:写操作必须失败
mount -t overlay overlay -o "lowerdir=$B/l1:$B/l2" "$B/ro"
if touch "$B/ro/new" 2>/dev/null; then
die "只读 overlay 竟然可写"
fi
ok "只读挂载:写入被拒绝"
预期输出六行 [ok]。脚本用 mountpoint 判断是否已挂载,umount -l 保证清理不会因为 busy 卡住;如果你的发行版没有 mountpoint,把清理改成 umount "$M" 2>/dev/null || true。
观察 copy-up 是隐式的:strace 里看不到任何「复制文件」的系统调用,只有一次 openat(...O_WRONLY|O_APPEND)。
# 需要 root;strace 只能看到 overlay 路径,copy-up 发生在内核内部
strace -f -e trace=openat,write,rename -o /tmp/ovl.strace \
sh -c 'echo again >> /tmp/ovl-lab/merged/a.txt'
tail -5 /tmp/ovl.strace
# openat(AT_FDCWD, "/tmp/ovl-lab/merged/a.txt", O_WRONLY|O_CREAT|O_APPEND, 0666) = 3
要看到真正的 copy-up,用 bpftrace 挂内核函数(需要 root 与 bpftrace):
# 终端 A:观察 5 秒
sudo bpftrace -e '
kprobe:ovl_copy_up { @calls[comm] = count(); }
kprobe:ovl_copy_up_one { printf("copy-up: %s pid=%d\n", comm, pid); }
interval:s:5 { exit(); }'
# 终端 B:触发一次
echo x >> /tmp/ovl-lab/merged/a.txt
如果 ovl_copy_up 被内联、符号不存在,改用 kprobe:ovl_copy_up_one 或 kprobe:ovl_copy_up_flags;bpftrace -l 'kprobe:ovl_*' 能列出当前内核可挂的符号。
5.4.11 实验二:从层 tar 到多层查找成本(root)
这一节把「镜像层 tar」与「overlay 挂载」接起来,并量一次「查找要穿几层」的成本。第一段模拟 Docker 解包:把 .wh. 文件翻译成 upper 里的 whiteout。
#!/usr/bin/env bash
# layer-lab.sh — 模拟镜像层解包 + 测多层查找成本;需要 root
set -euo pipefail
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "需要 root"; exit 1; }
B=/tmp/ovl-layer-lab
M=$B/merged
cleanup() {
mountpoint -q "$M" && umount -l "$M" || true
mountpoint -q "$B/bench/merged" && umount -l "$B/bench/merged" || true
rm -rf "$B"
}
trap cleanup EXIT
mkdir -p "$B"/{l1,l2,l3,upper,work,merged}
mkdir -p "$B/l1/etc" "$B/l1/data"
printf 'v1\n' > "$B/l1/etc/app.conf"
printf 'old\n' > "$B/l1/data/old.txt"
printf 'keep\n' > "$B/l1/data/keep.txt"
mkdir -p "$B/l2/etc" "$B/l2/data"
printf 'v2\n' > "$B/l2/etc/app.conf" # 上层覆盖
printf 'new\n' > "$B/l2/data/new.txt" # 上层新增
mkdir -p "$B/l3/data"
printf 'keep-v2\n' > "$B/l3/data/keep.txt" # 容器层修改
# 层 tar 里的 data/.wh.old.txt 被解包器翻译成 data/old.txt 的 0:0 字符设备
mknod "$B/l3/data/old.txt" c 0 0
mount -t overlay overlay \
-o "lowerdir=$B/l2:$B/l1,upperdir=$B/l3,workdir=$B/work" "$M"
[[ $(cat "$M/etc/app.conf") == v2 ]] || { echo "覆盖错误"; exit 1; }
[[ $(cat "$M/data/keep.txt") == keep-v2 ]] || { echo "修改错误"; exit 1; }
[[ -e "$M/data/new.txt" ]] || { echo "新增丢失"; exit 1; }
[[ ! -e "$M/data/old.txt" ]] || { echo "whiteout 未生效"; exit 1; }
printf '[ok] 合并视图:app.conf=v2, keep=keep-v2, old.txt 已隐藏\n'
find "$M/data" -maxdepth 1 -printf '%y %f\n' | sort
最后一行输出里 old.txt 不再出现,new.txt 与 keep.txt 都来自 upper。对应的层 tar 用 tar -tf 看是这样:
etc/app.conf
data/new.txt
data/.wh.old.txt
data/keep.txt
.wh.old.txt 不是普通文件,而是「删除 old.txt」的标记;Docker 解包时把它转成 0:0 字符设备(或带 trusted.overlay.whiteout 的 0 字节文件)。
第二段量查找成本,是一个独立脚本:同样 500 个文件放在 1、4、8 层 lower 的最底层,清掉 dentry 缓存后逐个 stat。
#!/usr/bin/env bash
# lookup-bench.sh — 测 lower 层数对查找成本的影响;需要 root
set -euo pipefail
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "需要 root"; exit 1; }
B=/tmp/ovl-bench
M=$B/merged
cleanup() {
mountpoint -q "$M" && umount -l "$M" || true
rm -rf "$B"
}
trap cleanup EXIT
for n in 1 4 8; do
rm -rf "$B"; mkdir -p "$M"
lower=""
for i in $(seq 1 "$n"); do
d="$B/l$i"; mkdir -p "$d"
for j in $(seq 1 500); do
if [[ $i -eq $n ]]; then
: > "$d/f$j" # 目标文件只放最底层
else
: > "$d/other$i-$j" # 其它层放无关文件
fi
done
lower="${lower:+$lower:}$d"
done
mount -t overlay overlay -o "lowerdir=$lower" "$M"
echo 2 > /proc/sys/vm/drop_caches
start=$(date +%s%N)
python3 - "$M" <<'PY'
import os, sys
root = sys.argv[1]
for i in range(1, 501):
os.stat(os.path.join(root, f"f{i}"))
PY
end=$(date +%s%N)
printf 'layers=%d %d ms\n' "$n" $(( (end - start) / 1000000 ))
umount "$M"
done
示例机器上的量级是:1 层约 15–25 ms,4 层约 30–45 ms,8 层约 55–80 ms。绝对值取决于 CPU 与底层文件系统,要看的是随层数近似线性增长。这条曲线解释了为什么「层数很多的镜像,首次遍历目录特别慢」:每个未命中的名字都要在每层做一次 lookup,直到最底层才命中。
两个实验都只碰 /tmp,清理脚本会卸载并删除目录。若中途失败,先手工 umount -l /tmp/ovl-lab/merged 再重跑。
5.4.12 挂载选项与性能调优
这一节回答:mount -t overlay 的选项里,哪些影响正确性、哪些只影响性能,生产上该怎么选。
| 选项 | 默认 | 作用 | 代价/约束 |
|---|---|---|---|
lowerdir= | 必填 | 冒号分隔的只读层,最左最高 | 总长度受挂载选项页大小限制;层数上限 OVL_MAX_STACK=500 |
upperdir= / workdir= | 可省略 | 可写层与暂存目录 | 必须同一文件系统;workdir 必须空 |
redirect_dir=on | off | 目录 rename 时写 trusted.overlay.redirect | 需要底层 xattr;与 metacopy=on 冲突 |
index=on | off | 硬链接 copy-up 后保持同一 inode | 需要底层支持 file handle;与 NFS export 配合 |
xino=on/auto | auto | 用 inode 高位编码 fsid,稳定 st_ino | 占用 inode 位宽;inode 号过大时退回 |
nfs_export=on | off | 支持 NFS export overlay | 需要 index=on;upper 不能是 NFS |
metacopy=on | off | 元数据修改只复制 inode/xattr | 与 redirect_dir=follow、nfs_export=on 冲突;不可信 lower 有伪造风险 |
userxattr | off | 用 user.overlay.* 代替 trusted.overlay.* | 任何用户可伪造元数据,仅限可信目录 |
volatile | off | 省略所有 sync,追求性能 | 崩溃后 upper 可能不一致;下次挂载可能拒绝 |
default_permissions | off | 由 VFS 做权限检查,而非底层 | 主要给无特权挂载/overlay-on-overlay 用 |
几个选项之间有硬冲突,挂载时会直接报 EINVAL:
metacopy=on与redirect_dir=follow、nfs_export=on不能同时开;nfs_export=on要求index=on;volatile挂载会在work/incompat/volatile留标记,崩溃后再次挂载会拒绝,确认数据可丢后手工删掉该目录才能继续。
性能上要分三个维度看,不要只盯一个数字:
| 维度 | 测量方式 | 典型结论 |
|---|---|---|
| 查找深度 | 5.4.11 的 stat 循环 | 随 lower 层数线性增长;dentry 缓存命中后消失 |
| copy-up 带宽 | 5.4.4 的 dd + time | 约等于文件大小 / 磁盘写带宽;大文件首写最痛 |
| 元数据操作 | chmod/chown 大文件计时 | 默认整文件 copy-up;metacopy=on 可降到毫秒级 |
| 目录遍历 | ls -lR、du -sh | 多层合并 + d_type 缺失时系统调用翻倍 |
用 bpftrace 量 copy-up 延迟分布,比单次 time 更能说明长尾:
sudo bpftrace -e '
kprobe:ovl_copy_up_one { @start[tid] = nsecs; }
kretprobe:ovl_copy_up_one /@start[tid]/ {
@us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}
interval:s:10 { exit(); }'
调优的优先级:先把可变大文件移出可写层(volume/bind mount),再考虑 metacopy,最后才调 volatile。volatile 换来的是「所有 sync 都省略」,适合 CI 缓存、临时构建目录这类丢了可以重来的数据;数据库、日志、需要崩溃一致性的文件不要用。
早期版本把 fsync=auto|strict 写成 overlayfs 的挂载选项。Linux 6.8 的 fs/overlayfs/params.c 里没有这个选项:默认行为是对 upper 正常下发 sync,只有 volatile 会省略全部 sync 调用。需要更强持久性时,正确做法是保持非 volatile、对关键文件 fsync,并确保底层文件系统与磁盘有写缓存保护;不存在 fsync=strict 这个开关。
5.4.13 硬链接、index 与 redirect:跨层 inode 的同一性
5.4.8 的 xattr 表说明了每个元数据「是什么」,这一节回答「什么时候会写、写错会怎样」。三个特性——index、redirect_dir、metacopy——都在解决同一个问题:upper 里的一个对象,如何指向 lower 里的一份数据或一个名字。它们各自用一种 xattr 表达这层间接,代价与失效模式完全不同。
| 操作(lower 中有对象) | 默认 | index=on | redirect_dir=on | metacopy=on |
|---|---|---|---|---|
写 nlink=2 文件的一个名字 | 只 copy-up 该名字,链接断裂 | 两名字仍指向同一 upper inode | 同默认 | 同默认(数据仍读 lower) |
chmod 1 GiB 文件 | 整文件 copy-up | 整文件 copy-up | 整文件 copy-up | 只复制 inode/xattr,upper 为 0 字节 |
mv lower 目录 | EXDEV | EXDEV | 成功,upper 写 trusted.overlay.redirect | 与 redirect_dir=follow 冲突,挂载即 EINVAL |
| 读被 copy-up 的文件 | 读 upper | 读 upper | 读 upper | upper 只有元数据,数据按 metacopy 指向 lower |
| 删除 lower 文件 | whiteout | whiteout | whiteout | whiteout |
| NFS export | 不支持 | 支持(nfs_export 要求) | redirect 无法被 index 校验 | 与 nfs_export=on 冲突 |
这张表说明三个特性不能互相替代:index 管硬链接身份,redirect_dir 管目录改名后的路径,metacopy 管元数据与数据分离。生产上最常见的事故是把 metacopy=on 当成「省空间的万能开关」,结果备份系统只备份了 upper,恢复时发现所有文件都是 0 字节。
内核路径:copy-up 时如何决定「复制数据」还是「建索引」
一次写打开的调用链是 ovl_open()(file.c)→ ovl_maybe_copy_up() → ovl_copy_up() → ovl_copy_up_one()(copy_up.c)→ ovl_do_copy_up() → ovl_copy_up_workdir() → ovl_copy_up_metadata();硬链接在 ovl_copy_up_one() 末尾由 ovl_link_up() 补上。
/* fs/overlayfs/copy_up.c(Linux 6.8,节选) */
if (ovl_need_index(c->dentry)) {
c->indexed = true;
if (S_ISDIR(c->stat.mode))
c->workdir = ovl_indexdir(c->dentry->d_sb);
else
to_index = true;
}
...
if (to_index) {
c->destdir = ovl_indexdir(c->dentry->d_sb);
err = ovl_get_index_name(ofs, origin, &c->destname);
}
ovl_need_index() 判断「这个对象是否需要索引」:index=on 且 lower 文件有多个硬链接(i_nlink > 1)时为真;开 nfs_export=on 时所有文件都为真。普通文件走 to_index 分支:先复制到 workdir 下的 index 目录,再用 ovl_link_up() 把它硬链接进 upper 的目标位置。索引项的名字由 lower inode 的 file handle 生成(ovl_get_index_name()),所以同一个 lower inode 的第二个名字 copy-up 时能查回同一个 upper inode,链接不断。
目录 copy-up 不走 to_index,而是把 workdir 换成 index 目录,并写 trusted.overlay.origin;redirect_dir=on 时,改名还会写 trusted.overlay.redirect,值是新位置相对 overlay 根的路径。查找时 ovl_lookup() 先看 upper 同名项,再看 redirect 指向的 lower 路径,最后才按原名逐层找。
实验一:index=on 保持硬链接,index=off 断裂
# 需要 root;getfattr 来自 attr 包
sudo apt-get install -y attr
sudo mkdir -p /tmp/idx/{lower,upper,work,merged}
echo v1 | sudo tee /tmp/idx/lower/f >/dev/null
sudo ln /tmp/idx/lower/f /tmp/idx/lower/a
sudo ln /tmp/idx/lower/f /tmp/idx/lower/b
stat -c '%i %h %n' /tmp/idx/lower/{f,a,b}
# 默认(index=off)挂载并写 a
sudo mount -t overlay overlay \
-o lowerdir=/tmp/idx/lower,upperdir=/tmp/idx/upper,workdir=/tmp/idx/work \
/tmp/idx/merged
echo changed > /tmp/idx/merged/a
stat -c '%i %h %n' /tmp/idx/merged/{a,b}
cat /tmp/idx/merged/b # 仍是 v1,说明 b 还指向 lower
sudo umount /tmp/idx/merged
sudo rm -rf /tmp/idx/upper/* /tmp/idx/work/*
# index=on 重做
sudo mount -t overlay overlay \
-o lowerdir=/tmp/idx/lower,upperdir=/tmp/idx/upper,workdir=/tmp/idx/work,index=on \
/tmp/idx/merged
echo changed > /tmp/idx/merged/a
stat -c '%i %h %n' /tmp/idx/merged/{a,b}
cat /tmp/idx/merged/b # changed:两个名字仍同一 inode
getfattr -d -m - /tmp/idx/upper/a
# trusted.overlay.origin=...
预期差异:index=off 时 a 与 b 的 inode 不同,b 内容仍是 v1;index=on 时两者 inode 相同,b 读到 changed。getfattr 还能在 upper 里看到 trusted.overlay.origin,它是索引与 NFS export 的共同基础。
注意清理顺序:先 umount 再删 upper/work,否则内核仍持有挂载点,rm -rf 会删到 merged 里的活文件。
实验二:redirect_dir=on 让 lower 目录改名只写一个 xattr
# 需要 root;lower 里放一个目录
sudo mkdir -p /tmp/rd/{lower/dir,upper,work,merged}
echo hello | sudo tee /tmp/rd/lower/dir/x.txt >/dev/null
# 默认:改名 lower 目录失败
sudo mount -t overlay overlay \
-o lowerdir=/tmp/rd/lower,upperdir=/tmp/rd/upper,workdir=/tmp/rd/work \
/tmp/rd/merged
mv /tmp/rd/merged/dir /tmp/rd/merged/dir2
# mv: cannot move ... Invalid cross-device link
sudo umount /tmp/rd/merged
# redirect_dir=on:改名成功,upper 只有元数据
sudo mount -t overlay overlay \
-o lowerdir=/tmp/rd/lower,upperdir=/tmp/rd/upper,workdir=/tmp/rd/work,redirect_dir=on \
/tmp/rd/merged
mv /tmp/rd/merged/dir /tmp/rd/merged/dir2
getfattr -n trusted.overlay.redirect /tmp/rd/upper/dir2
# trusted.overlay.redirect="dir"
cat /tmp/rd/merged/dir2/x.txt
# hello(数据仍在 lower,没有 copy-up)
sudo umount /tmp/rd/merged
关键证据是 getfattr 的输出:upper 里的 dir2 是一个空目录加一个 redirect="dir" 的 xattr,x.txt 没有复制。如果 lower 层是只读镜像,这个 xattr 就是「改名」的全部成本;代价是 lower 路径不能变,否则 redirect 指向空处,dir2 会读不到内容。
index=on只能修复之后的 copy-up——镜像层里已经被写成两个独立 inode 的硬链接不会自动合并;要修复只能重建上层或从原始层重新解包。- lower 不支持 file handle 时挂载失败——
index=on需要底层能name_to_handle_at();NFS、部分 FUSE、overlay-on-overlay 可能返回EOPNOTSUPP,此时应改用nfs_export=off或换底层。 redirect_dir=on与metacopy=on的冲突不是总能绕过——redirect_dir=follow与metacopy=on同时出现、且指定了upperdir时挂载直接EINVAL;redirect_dir=on也不是所有组合都安全。- 把 redirect 当普通 xattr 手工改——redirect 的值是相对 overlay 根的路径,改了它等于改目录身份;伪造后 lower 内容可能串到别的目录,NFS export 下尤其危险。
metacopy=on的 upper 文件是 0 字节——du、tar、rsync看到的是元数据副本,数据仍在 lower;只备份 upper 会得到一堆空文件。- 用
userxattr挂载后以为元数据受保护——user.overlay.*任何能写 upper 目录的用户都能改,包括把metacopy指向别的 lower 文件;只应在完全可信的目录上用。 - 离线修改 lower 后继续用同一 upper——启用了
index/metacopy/redirect_dir的挂载,lower 一旦变动,索引与 redirect 可能指向错误 inode,行为未定义;镜像层必须保持只读。 - 把
nlink当成容器内的真实链接数——overlay 的st_nlink由 upper 与 lower 合并计算,跨层硬链接在stat里可能显示 1,find -links的结果不能直接当底层事实。
5.4.14 生产陷阱 15 条
这一节把前面散落的边界收成一张排障表。每条都按「现象 → 原因 → 修复」写,遇到问题时按现象查。
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | 容器里删了大文件,宿主磁盘没降 | lower 只读,删除只是 whiteout | 删容器/镜像释放引用;不要指望 rm 回收镜像层 |
| 2 | 第一次写大文件卡几秒 | 整文件 copy-up | 大文件放 volume;或 metacopy=on 只复制元数据 |
| 3 | mount -t overlay 报 EINVAL | upper 与 work 不同文件系统,或 work 非空 | 放同一分区;work 用空目录 |
| 4 | 挂载成功但读不到 lower 的新文件 | lowerdir 顺序写反,或上层有同名 whiteout | 最左最高;findmnt 检查选项 |
| 5 | mv 目录报 Invalid cross-device link | 默认不允许 rename 纯 lower 目录 | redirect_dir=on,或避免对 lower 目录改名 |
| 6 | 硬链接在 copy-up 后变成两个独立文件 | 复制后 inode 不再相同 | index=on;或不要对硬链接文件做写操作 |
| 7 | 在 NFS 上做 upper 直接失败 | NFS 缺 d_type/xattr/rename 语义 | upper 用本地 ext4/xfs;NFS 只做 lower |
| 8 | find/rm -rf 特别慢 | 底层 d_type=DT_UNKNOWN,每项都要 stat | xfs 用 ftype=1 重建;换 ext4/btrfs |
| 9 | find -inum 找到多个不相干文件 | 跨文件系统时 st_ino 不唯一 | 挂载加 xino=on;或改用路径做主键 |
| 10 | inotify 收不到 lower 的变化 | 监听的是 merged,lower 被直接修改不产生事件 | 不要直接改 lower;需要事件用 fanotify |
| 11 | 容器里 df 显示宿主 1 TB | statfs 转给 upper 的底层文件系统 | 用 docker ps -s/du 看可写层,别用 df 判断配额 |
| 12 | volatile 挂载后重启起不来 | work/incompat/volatile 标记存在 | 确认可丢数据后删标记目录;否则重建 upper |
| 13 | docker commit 后镜像暴涨 | 可写层含日志、缓存、whiteout,且不复用构建缓存 | 用 Dockerfile 多阶段构建;commit 只做临时调试 |
| 14 | df -i 先于 df -h 告警 | 每层每文件都占底层 inode | 给 /var/lib/docker 独立分区;docker image prune |
| 15 | 两个容器写同一文件,磁盘翻倍 | copy-up 是每容器各自发生 | 共享写用 volume;只读共享用 :ro 挂载 |
第 15 条值得单独强调:overlayfs 共享的是读,不是写。任何「多个容器共同写一份数据」的需求,都应该用 volume、对象存储或数据库,而不是指望它们共享可写层。
5.4.15 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出四个目录的职责与约束 | upper/work 同文件系统 | 5.4.2 |
| 解释 lowerdir 的优先级 | 最左最高 | 5.4.2 |
| 判断一次操作是否触发 copy-up | 覆盖 open/chmod/link/unlink | 5.4.4 |
| 解释 whiteout 与 opaque 的区别 | 一个名字、一个目录 | 5.4.5 |
在层 tar 里找到 .wh. 文件 | 能对应到删除操作 | 5.4.6 |
在 overlay2 里定位容器层 | 认得出 diff/merged/l/ | 5.4.6 |
| 说出 copy-up 的性能代价与缓解 | 至少两种手段 | 5.4.4、5.4.7 |
| 说出三个 overlayfs 边界 | rename/inode/inotify 任选 | 5.4.7 |
说出 ovl_fs/ovl_entry/ovl_inode 的职责 | upper 在 layers[0] | 5.4.8 |
| 解释 chainID 与 cache-id 的关系 | 共享层只存一份 | 5.4.9 |
| 独立跑通两个 overlay 实验 | 六项断言 + 查找曲线 | 5.4.10、5.4.11 |
| 说出三个挂载选项冲突 | metacopy/redirect/nfs_export | 5.4.12 |
| 按现象定位磁盘与性能问题 | 至少六条 | 5.4.13 |
Q1 lower 有两层:L1/a.txt 与 L2/a.txt,upper 为空。读 merged 的 a.txt 得到哪个?如果 upper 里也有 a.txt 呢?
Q2 在容器里 rm /usr/bin/perl,镜像层会变小吗?磁盘上发生了什么?
Q3 为什么 chmod 一个 1 GiB 的 lower 文件会卡住?有哪些办法避免整文件复制?
Q4 overlay2/<id>/diff、merged、work 三个目录分别对应哪一层?docker rm 删的是哪一个?
Q5 在 merged 里对 lower 的一个目录执行 mv dir dir2,默认会得到什么错误?为什么?
Q6 struct ovl_entry 的 __lowerstack[] 里为什么不存 upper?upper 的 dentry 放在哪?
Q7 两个镜像共享前 5 层、第 6 层不同。磁盘上存了几份第 5 层?用什么键判断能否复用?
Q8 容器里 df -h / 显示 1 TB,是否说明这个容器能用 1 TB 磁盘?为什么?
答案
A1 读到 L1/a.txt,因为 lowerdir 列表最左最高。如果 upper 里也有 a.txt,读 upper 的版本(upper 优先于所有 lower)。
A2 不会。lower 是只读的,删除只在 upper 里创建一个 0:0 的 whiteout,镜像层的数据块仍占空间;只有等没有容器/镜像引用该层时,空间才可能被回收。
A3 chmod 修改元数据,overlayfs 需要先把整个文件复制到 upper(除 metacopy=on)。避免办法:开启 metacopy、把大文件放到 volume/bind mount、或构建镜像时避免在下层放会被 chmod 的大文件。
A4 diff 是该层的实际内容;对容器层来说它就是可写层(upper)。merged 是挂载点(合并视图),不是存储层。work 是 copy-up/rename 的暂存目录。docker rm 删除的是容器层(diff 与 -init),镜像层不受影响。
A5 EXDEV(Invalid cross-device link)。overlayfs 默认不允许 rename 一个只在 lower 或 merged 里的目录,因为改名需要移动整棵目录树并处理 lower 引用;redirect_dir=on 会 copy-up 目录元数据并写 trusted.overlay.redirect 来支持这种操作。
A6 ovl_entry 只描述 lower 的层栈;upper 是唯一的、每个 inode 最多一个,放在 ovl_inode.__upperdentry 里,命中时一次指针解引用即可,不必进数组遍历。这也解释了为什么 upper 的查找是 O(1),lower 的查找是 O(层数)。
A7 只存一份。Docker 用 chainID(前 5 层 diffID 串联后的哈希)做缓存键,chainID 相同就复用同一个 cache-id 目录;第 6 层不同只影响第 6 层及之后的 chainID。docker image ls 仍会把共享层分别算进每个镜像的 SIZE。
A8 不能。ovl_statfs() 把 statfs 转给 upper 所在的底层文件系统,所以 df 报的是宿主分区容量;容器的磁盘上限由 --storage-opt size(可写层配额)或上层存储驱动决定,内存/CPU 上限则由 cgroup 决定。判断容器实际占用要用 docker ps -s 或 du -sh .../diff。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| 内核文档 | Documentation/filesystems/overlayfs.rst | 5.4.1–5.4.13 | 完整(whiteout、copy-up、metacopy、redirect、volatile 语义以该文档为准) |
| 内核源码 | fs/overlayfs/{super,params,namei,inode,dir,readdir,copy_up,util}.c、ovl_entry.h(Linux 6.8) | 5.4.8、5.4.12、5.4.13 | 精选(结构体字段、函数路径、挂载选项表) |
| OCI | image-spec:manifest、config、layer、diffID | 5.4.9 | 精选(digest/chainID 与层缓存) |
| Docker | overlay2 graphdriver、docker system df | 5.4.6、5.4.9 | 完整(layerdb、cache-id、共享层计数) |
| N | 第 10 章 容器网络虚拟化 | 5.4.6、5.6 | 背景(镜像与容器在宿主上的落点) |
| O | 第 39 章 文件和目录、第 40 章 文件系统实现 | 5.4.2–5.4.4 | 完整(inode、目录项、链接与 rename 的对照) |
| O | 第 42 章 崩溃一致性:FSCK 和日志 | 5.4.4 | 精选(copy-up 的 fsync/rename 原子性) |
| K | 第 18 章 目录与链接(18.5 rename) | 5.4.7 | 背景(EXDEV 与跨文件系统语义) |
| K | 第 14–15 章 文件系统与文件属性 | 5.4.3 | 背景(st_dev/st_ino 与 xattr) |
| R | 第 2 章 内存管理(2.10 缺页、2.13 回收) | 5.4.7 | 背景(page cache 与 copy-up 的交互) |
下一站:5.5 OCI 运行时与容器生命周期 把前面四章的零件装进一个标准:
config.json如何描述 namespace/cgroup/rootfs,runc 的 create/start/kill/delete 各自做什么,以及 capabilities/seccomp 如何收窄容器里 root 的权力。