Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 驱动直接用它。

定义 5.4.1 · overlayfs 的四个目录

一次 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
挂载 overlay 的五个错误
  1. 「upperdir 和 workdir 可以在不同文件系统」——不行。copy-up 依赖 rename 的原子性,两者必须同一文件系统,否则挂载直接失败。
  2. 「workdir 可以随便选个非空目录」——workdir 必须是空目录,且不能与 upperdir 相同。
  3. 「lowerdir 写在右边优先级更高」——相反,最左最高。
  4. 「upper 用 NFS 也行」——NFS 不提供 overlayfs 需要的 d_type 与 trusted.* xattr 语义,不能做 upper。
  5. 「只读 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
    合并所有记录到的目录,返回目录项

三个由此产生的行为:

  1. 目录合并只合并名字列表。upper 与 lower 同名目录的条目会一起列出;但目录的元数据(权限、属主、xattr)只报 upper 的,lower 的被隐藏。
  2. readdir 结果会被缓存。合并后的名字列表缓存在打开的 struct file 里,直到 rewinddir(offset 0)才重建。所以「一边读目录一边改文件」可能看不到新条目——这不是 bug,是缓存语义。
  3. 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、某些备份软件)需要知道这一点。

xino 解决什么问题

当 lower 与 upper 在不同的底层文件系统上时,两个不同文件可能碰巧有相同的 (st_dev, st_ino),或者同一个文件在 merged 与 lower 里报不同的 st_dev。xino=on 用 inode 号的高位编码底层文件系统 ID,拼出一个全局唯一且稳定的 st_ino。代价是占用 inode 位宽;如果底层 inode 号太大,overlayfs 会退回非 xino 行为。

inode 与统计的四个坑
  1. 「find merged -inum N 能定位唯一文件」——在未开 xino 的跨文件系统 overlay 上,st_ino 可能重复;find -samefile 也可能误判。
  2. 「容器里 du -sh / 等于容器占用」——容器里的 du 看 merged,会把只读镜像层也算进去;要看容器可写层,用 du -sh /var/lib/docker/overlay2/<id>/diff 或 docker ps -s。
  3. 「tar 备份 merged 会包含所有层」——会,但它按合并视图读,得到的是「当前可见的文件」;被 whiteout 隐藏的文件不会进备份,这通常正是你要的。
  4. 「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 指向别的文件)。生产上是否开启要看内核版本与镜像来源。

定理 5.4.1 · copy-up 的原子性边界

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 又合并进来,删除就不彻底。

两种标记的对照:

标记位置形式语义
whiteoutupper 中与目标同名的条目字符设备 0:0(或带 trusted.overlay.whiteout 的 0 字节普通文件)这个名字被删除
opaqueupper 目录自身trusted.overlay.opaque=y 扩展属性这个目录不与 lower 合并
redirectupper 目录trusted.overlay.redirect=<路径>目录被 rename 过,去新路径找 lower 内容(redirect_dir=on)

镜像层 tar 里用的是另一套写法:.wh.<name> 表示删除 <name>,.wh..wh..opq 表示当前目录 opaque。Docker 在解包层时把这些文件翻译成 overlayfs 的 whiteout/opaque。5.4.6 会实际看到它们。

whiteout 的四个坑
  1. 「删了文件,镜像层就小了」——不会。lower 只读,whiteout 只是标记;空间要等镜像层无人引用才能回收。
  2. 「whiteout 是普通空文件」——默认是 0:0 字符设备;某些场景(如用户态生成的层)也用带 trusted.overlay.whiteout xattr 的 0 字节普通文件。
  3. 「opaque 目录里还能看到 lower 的文件」——opaque 的作用就是阻止合并;如果还能看到,说明 xattr 没设置成功(常见于 upper 文件系统不支持 trusted.*)。
  4. 「在 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 各行之和可能对不上:前者是去重后的展示值,后者是每层独立大小,共享层不会被扣掉。

例 5.4.1 · 为什么 `docker commit` 后镜像变大很多

容器可写层里的 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 后断裂,多个名字不再指向同一 inodeindex=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
容器磁盘占用排查
  1. 先分三类:镜像层(多镜像共享)、容器层(每容器一份)、volume(独立于容器生命周期)。
  2. 看总量:docker system df -v,分别看 Images、Containers、Local Volumes、Build Cache 的 SIZE 与 RECLAIMABLE。
  3. 看单容器:docker ps -s 的 SIZE 列是可写层大小,virtual 列含镜像层。
  4. 进容器找大目录:du -x -h -d1 / 2>/dev/null | sort -h | tail;注意结果包含只读镜像层。
  5. 到宿主对照:du -sh /var/lib/docker/overlay2/*/diff,找出真正增长的容器层。
  6. 镜像太大:docker history 定位大层,回到 Dockerfile 合并 RUN、清理包缓存、使用多阶段构建。
  7. inode 满:df -i /var/lib/docker,用 docker image prune -a、docker builder prune 回收;长期方案是给 /var/lib/docker 独立分区。
shiftfs 与 idmapped mounts

早期 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_fsfs/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. 前缀)挂在谁身上值含义
opaqueupper 目录y / x不与 lower 同名目录合并;x 是带白化标记的目录
whiteoutupper 的 0 字节普通文件y这个名字被删除(xwhiteout 形式)
redirectupper 目录/文件相对路径目录被 rename 过,lower 内容去新路径找
metacopyupper 文件摘要字符串只有元数据被 copy-up,数据仍指向 lower
originupper 文件/目录file handle记录来源,供 NFS export 与 index 使用
impureupper 目录y目录里有未与 lower 同步的条目,readdir 不能走快速路径
indexworkdir 下的索引目录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.txtinode 是否切到 upper、whiteout 类型
getfattr -d -m - upper/a.txttrusted.overlay.* 元数据(需 root)
ls -l upperwhiteout 是否显示为 0, 0 字符设备
df -i /var/lib/dockerinode 消耗,比 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 这类遍历操作明显变慢。

df 在容器里看到的是宿主容量

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 blobmanifest 的 layers[].digest内容寻址,拉取/校验
diffID解压后的 tarconfig 的 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 graphdrivercontainerd 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 的代价在规模上的表现。

例 5.4.2 · 为什么 `docker image ls` 的 SIZE 相加不等于磁盘占用

三个镜像共享 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=onoff目录 rename 时写 trusted.overlay.redirect需要底层 xattr;与 metacopy=on 冲突
index=onoff硬链接 copy-up 后保持同一 inode需要底层支持 file handle;与 NFS export 配合
xino=on/autoauto用 inode 高位编码 fsid,稳定 st_ino占用 inode 位宽;inode 号过大时退回
nfs_export=onoff支持 NFS export overlay需要 index=on;upper 不能是 NFS
metacopy=onoff元数据修改只复制 inode/xattr与 redirect_dir=follow、nfs_export=on 冲突;不可信 lower 有伪造风险
userxattroff用 user.overlay.* 代替 trusted.overlay.*任何用户可伪造元数据,仅限可信目录
volatileoff省略所有 sync,追求性能崩溃后 upper 可能不一致;下次挂载可能拒绝
default_permissionsoff由 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=onredirect_dir=onmetacopy=on
写 nlink=2 文件的一个名字只 copy-up 该名字,链接断裂两名字仍指向同一 upper inode同默认同默认(数据仍读 lower)
chmod 1 GiB 文件整文件 copy-up整文件 copy-up整文件 copy-up只复制 inode/xattr,upper 为 0 字节
mv lower 目录EXDEVEXDEV成功,upper 写 trusted.overlay.redirect与 redirect_dir=follow 冲突,挂载即 EINVAL
读被 copy-up 的文件读 upper读 upper读 upperupper 只有元数据,数据按 metacopy 指向 lower
删除 lower 文件whiteoutwhiteoutwhiteoutwhiteout
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 与 redirect 的八个坑
  1. index=on 只能修复之后的 copy-up——镜像层里已经被写成两个独立 inode 的硬链接不会自动合并;要修复只能重建上层或从原始层重新解包。
  2. lower 不支持 file handle 时挂载失败——index=on 需要底层能 name_to_handle_at();NFS、部分 FUSE、overlay-on-overlay 可能返回 EOPNOTSUPP,此时应改用 nfs_export=off 或换底层。
  3. redirect_dir=on 与 metacopy=on 的冲突不是总能绕过——redirect_dir=follow 与 metacopy=on 同时出现、且指定了 upperdir 时挂载直接 EINVAL;redirect_dir=on 也不是所有组合都安全。
  4. 把 redirect 当普通 xattr 手工改——redirect 的值是相对 overlay 根的路径,改了它等于改目录身份;伪造后 lower 内容可能串到别的目录,NFS export 下尤其危险。
  5. metacopy=on 的 upper 文件是 0 字节——du、tar、rsync 看到的是元数据副本,数据仍在 lower;只备份 upper 会得到一堆空文件。
  6. 用 userxattr 挂载后以为元数据受保护——user.overlay.* 任何能写 upper 目录的用户都能改,包括把 metacopy 指向别的 lower 文件;只应在完全可信的目录上用。
  7. 离线修改 lower 后继续用同一 upper——启用了 index/metacopy/redirect_dir 的挂载,lower 一旦变动,索引与 redirect 可能指向错误 inode,行为未定义;镜像层必须保持只读。
  8. 把 nlink 当成容器内的真实链接数——overlay 的 st_nlink 由 upper 与 lower 合并计算,跨层硬链接在 stat 里可能显示 1,find -links 的结果不能直接当底层事实。

5.4.14 生产陷阱 15 条

这一节把前面散落的边界收成一张排障表。每条都按「现象 → 原因 → 修复」写,遇到问题时按现象查。

#现象原因修复
1容器里删了大文件,宿主磁盘没降lower 只读,删除只是 whiteout删容器/镜像释放引用;不要指望 rm 回收镜像层
2第一次写大文件卡几秒整文件 copy-up大文件放 volume;或 metacopy=on 只复制元数据
3mount -t overlay 报 EINVALupper 与 work 不同文件系统,或 work 非空放同一分区;work 用空目录
4挂载成功但读不到 lower 的新文件lowerdir 顺序写反,或上层有同名 whiteout最左最高;findmnt 检查选项
5mv 目录报 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
8find/rm -rf 特别慢底层 d_type=DT_UNKNOWN,每项都要 statxfs 用 ftype=1 重建;换 ext4/btrfs
9find -inum 找到多个不相干文件跨文件系统时 st_ino 不唯一挂载加 xino=on;或改用路径做主键
10inotify 收不到 lower 的变化监听的是 merged,lower 被直接修改不产生事件不要直接改 lower;需要事件用 fanotify
11容器里 df 显示宿主 1 TBstatfs 转给 upper 的底层文件系统用 docker ps -s/du 看可写层,别用 df 判断配额
12volatile 挂载后重启起不来work/incompat/volatile 标记存在确认可丢数据后删标记目录;否则重建 upper
13docker commit 后镜像暴涨可写层含日志、缓存、whiteout,且不复用构建缓存用 Dockerfile 多阶段构建;commit 只做临时调试
14df -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/unlink5.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_export5.4.12
按现象定位磁盘与性能问题至少六条5.4.13
即时自测 · 5.4

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.rst5.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精选(结构体字段、函数路径、挂载选项表)
OCIimage-spec:manifest、config、layer、diffID5.4.9精选(digest/chainID 与层缓存)
Dockeroverlay2 graphdriver、docker system df5.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 的权力。