暗色模式

Linux OverlayFS 联合挂载:从 lower/upper 分层到 whiteout 与容器镜像

技术教程
2026-09-24
4
0
本文要点
  • OverlayFS 用四个目录拼出一个视图:lowerdir(只读底层,可多层)、upperdir(可写层)、workdir(内核内部用的中转目录,必须与 upper 同一个文件系统)、merged(挂载点,用户看到的成品)
  • 走下层:lower 里的文件原样出现在 merged 里,一个字节都不复制
  • 要 copy-up:动一个 lower 文件,内核会把它整个复制到 upper 再改。实测给一个 10MiB 的文件追加 1 字节,upper 里立刻多出 10MiB 实打实的占用
  • ⚠️ 反直觉实测:copy-up 之后 stat 看到的 inode 号没有变(本例 288072 → 288072),变的是 upper 里那份副本(288074)。overlayfs 有意保持 merged 视图的 inode 号稳定,所以不能用 inode 号判断有没有发生过 copy-up——要看 upper 目录里有没有冒出这个文件
  • 不是真删:对 lower 里的文件执行 rm,upper 里会出现一个 c---------字符设备(主次设备号 0:0),叫 whiteout,它负责把下层同名文件盖住
  • 删目录再建同名目录会换成另一套机制:upper 里的同名目录被打上 trusted.overlay.opaque="y" 扩展属性(不透明目录),表示「这一层目录里的内容我说了算,别往下层找」
  • 多层 lowerdir=a:b靠前的优先级更高,合并结果实测 a 覆盖 b——这正是容器镜像分层的原型(镜像层只读、容器层可写,删镜像里的文件靠 whiteout)
  • upper 可以放在 tmpfs 上做「内存可写层」,但 workdir 必须和 upper 在同一个挂载里,否则报 overlayfs: workdir and upperdir must reside under the same mount

Linux OverlayFS 联合挂载:从 lower/upper 分层到 whiteout 与容器镜像

Docker 镜像分层、Kubernetes 的 writable layer、Live CD 的只读根加上一层可写内存盘——这些听起来很高级的东西,底层是同一个内核模块:OverlayFS。它做的事用一句话就能说完:把若干个只读目录和一个可写目录叠起来,合成一个看起来普通的目录

难点全在细节里:写文件时数据到底去了哪、删文件为什么不是真删、改一个字节为什么要复制整个文件、df 报的又是谁的空间。本文在一台 Ubuntu 22.04(内核 5.15.0、util-linux 2.37.2)的 1 核 1.9G 小机上把这些行为逐条实测了一遍,下面所有输出都来自这台机器。

四个目录,各管一件事

先把角色分清楚,这是理解后面一切行为的前提:

目录角色能不能写
lowerdir只读底层,可以给多个(a:b:c),靠前的优先级高
upperdir可写层,所有修改都落在这里
workdir内核做原子替换时的中转站,必须是空目录且与 upper 同一个文件系统内核自己用
merged挂载点,也就是用户最终看到的合并视图跟随 upper

一个容易被忽略的约束:workdir 不是可有可无的摆设,它和 upperdir 必须在同一个挂载下。本文实测时把 upper 放到 tmpfs、workdir 留在 ext4,挂载直接失败,dmesg 给出了原因:

overlayfs: workdir and upperdir must reside under the same mount

挂载与观察

建好目录、写一个 lower 里的文件,然后挂起来看看它长什么样:

umount /tmp/ovl/merged 2>/dev/null; rm -rf /tmp/ovl; mkdir -p /tmp/ovl/lower /tmp/ovl/upper /tmp/ovl/work /tmp/ovl/merged
echo "v1-content" > /tmp/ovl/lower/keep.txt
mount -t overlay overlay -o lowerdir=/tmp/ovl/lower,upperdir=/tmp/ovl/upper,workdir=/tmp/ovl/work /tmp/ovl/merged
findmnt -no SOURCE,FSTYPE,OPTIONS /tmp/ovl/merged
stat -f -c '文件系统类型=%T' /tmp/ovl/merged
ls -li /tmp/ovl/merged/

OverlayFS 挂载:findmnt 显示的 options 里能看到四个目录的分工,stat 报出的文件系统类型是 overlayfs

三点值得注意:

  1. SOURCE 显示为 overlay,不是任何一块磁盘设备——overlayfs 是虚拟文件系统,不占用块设备;
  2. 挂载选项就是全部配置lowerdir/upperdir/workdir 三个路径直接写在 mount 参数里,改配置等于重新挂载,它不像 ext4 那样靠超级块记参数;
  3. stat -f 报的类型是 overlayfs/proc/filesystems 里也能查到),ls 出来的一切都来自被合并的下层。

开头那行 umount ... 2>/dev/null; rm -rf /tmp/ovl 是为了让实验可重复——overlayfs 的挂载点如果还挂着,重建目录会失败,所以每段实验都先清场重来。

读:下层文件原样出现

merged/keep.txt 能读出 v1-content,但它的数据仍然只在 lower 里:upper 目录此时是空的。读取路径没有产生任何复制,这是 overlayfs 高效的根本原因——只读的镜像层可以被任意多个容器共享,谁都不多占一块磁盘。

写:copy-up 与两个反直觉的细节

写入才是重头戏。给 lower 里的 edit.txt 追加一行:

umount /tmp/ovl/merged 2>/dev/null; rm -rf /tmp/ovl; mkdir -p /tmp/ovl/lower /tmp/ovl/upper /tmp/ovl/work /tmp/ovl/merged
echo "base-content" > /tmp/ovl/lower/edit.txt
mount -t overlay overlay -o lowerdir=/tmp/ovl/lower,upperdir=/tmp/ovl/upper,workdir=/tmp/ovl/work /tmp/ovl/merged
stat -c '写前:  inode=%i size=%s' /tmp/ovl/merged/edit.txt
echo "modified-in-merged" >> /tmp/ovl/merged/edit.txt
stat -c '写后:  inode=%i size=%s' /tmp/ovl/merged/edit.txt
stat -c 'upper: inode=%i size=%s' /tmp/ovl/upper/edit.txt
echo "lower 里还是: $(cat /tmp/ovl/lower/edit.txt)"
dd if=/dev/zero of=/tmp/ovl/lower/big.bin bs=1M count=10 status=none
printf 'x' >> /tmp/ovl/merged/big.bin
du -sh /tmp/ovl/lower /tmp/ovl/upper

copy-up 实测:merged 的 inode 号前后不变,upper 里冒出了副本,lower 原文完好;10MiB 文件追加 1 字节后 upper 涨到 11M

这段输出里有三个关键事实:

① 数据被整份复制到 upper,lower 一动不动。 upper: inode=288074 size=32 是内核新造的副本,而 lower 里还是: base-content 说明原始文件没被碰过。只读层永远只读,这是 overlayfs 敢让多个容器共享镜像的底气。

② 复制是「整个文件」,不是「差量」。 最后两行的 dd + printf 是专门为了验证这点:造一个 10MiB 的文件放进 lower,然后只往里追加 1 个字节du -sh 显示 upper 从 4K 直接涨到 11M。改一个字节要付整个文件的磁盘代价——容器里改大文件(数据库文件、镜像里的 jar 包)特别费空间,原因就在这里。

③ ⚠️ inode 号没有变。 这一条和很多教程里写的「copy-up 之后 inode 会变」正好相反:写前写后 merged/edit.txt 的 inode 都是 288072(lower 里那个),而真正被复制出来的 upper 副本是 288074。overlayfs 有意让合并视图的 inode 号保持稳定,否则用户态程序(比如 rsyncfind -inum、备份工具的硬链接判断)会在文件被写的一瞬间看到「文件被换掉了」。所以判断有没有 copy-up,不要看 inode,要看 upper 目录里有没有出现这个文件。

删:whiteout 是一个 0:0 的字符设备

lower 是只读的,删不掉也不可能去改它。那 rm merged/delete-me.txt 之后,下层那个文件怎么才能「消失」?答案是上层放一个占位文件把下层盖住:

umount /tmp/ovl/merged 2>/dev/null; rm -rf /tmp/ovl; mkdir -p /tmp/ovl/lower/confdir /tmp/ovl/upper /tmp/ovl/work /tmp/ovl/merged
echo "a=1" > /tmp/ovl/lower/confdir/app.conf; echo "obsolete" > /tmp/ovl/lower/delete-me.txt
mount -t overlay overlay -o lowerdir=/tmp/ovl/lower,upperdir=/tmp/ovl/upper,workdir=/tmp/ovl/work /tmp/ovl/merged
rm /tmp/ovl/merged/delete-me.txt; rm -rf /tmp/ovl/merged/confdir; mkdir /tmp/ovl/merged/confdir
ls -la /tmp/ovl/upper/
stat -c '%n 类型=%F 设备号=%t:%T' /tmp/ovl/upper/delete-me.txt
cd /tmp/ovl/upper && getfattr -d -m - confdir

whiteout 与不透明目录:upper 里出现 c--------- 0,0 的字符设备,以及带 trusted.overlay.opaque="y" 的目录

输出里的 c--------- 2 root root 0, 0 delete-me.txt 就是 whiteout(白障):一个主次设备号都是 0 的字符设备(stat 明确写着 character special file 设备号=0:0)。它的作用不是存数据,而是给合并层一个信号——「这个名字在下层有,但在这层被删了,别往下找」。用户 ls merged/ 时它不会出现,看起来就像真的删掉了。

这也解释了一个常见疑问:为什么容器里删掉镜像层的大文件,镜像体积一点没小?因为 lower 层根本没动,只是多了一个 0 字节的标记文件。

删目录:opaque 属性接手

同一个实验里还做了后半段:rm -rf merged/confdir 之后又 mkdir merged/confdir,造出一个同名的新目录。这时 upper 里的 confdir 不再是字符设备,而是一个普普通通的目录,区分它和普通目录靠的是扩展属性:

trusted.overlay.opaque="y"

trusted.overlay.opaque="y" 表示这是一个不透明目录:合并视图里这个目录的内容完全以 upper 为准,不许再去下层找同名目录里的东西。凡是「删除目录后又重建同名目录」,都会走这条路径——whiteout 只能盖住一个名字,盖不住一整个目录树,所以目录级别的遮蔽换成了打标记。

顺带一提,查这个属性要用 getfattr(来自 attr 包,不在最小化系统里,apt install attr 即可),并且在 upper 目录里用相对路径查,否则 getfattr 会多打一行 Removing leading '/' from absolute path names 的提示,把干净的输出弄乱。

多层 lowerdir:容器镜像的原型

lowerdir 支持冒号分隔的多层,写在前面的优先级更高。把两个 lower 叠起来:

mount -t overlay overlay -o lowerdir=/tmp/ovl/lower2:/tmp/ovl/lower,upperdir=/tmp/ovl/upper,workdir=/tmp/ovl/work /tmp/ovl/merged

实测结果:lower2lower 里都有 keep.txt(内容分别是 v2-contentv1-content),合并后读到的是 v2-content——靠前的层赢。而只在 lower2 里存在的 extra.txt 也正常出现在合并视图里,两个目录的内容按「上层覆盖下层」的规则并成了一个。

这正是容器镜像的工作方式:镜像的每一层是一个只读 lower,容器启动时在最上面加一个可写的 upper。改文件 = copy-up 到容器层,删文件 = 写 whiteout,容器层只有几十 KB 也能"改"一个几百 MB 的镜像。

一条实测补充:挂载之后再往 lower 里放文件,merged 里能立刻看到(本例新增的 late.txt 无需重新挂载就出现了)。但这属于「不该依赖」的行为——镜像层的约定就是只读不变,生产里不要靠动态改 lower 来生效。

upper 放到 tmpfs:内存可写层与 df 的口径

既然 upper 只是一个普通目录,那把它放到 tmpfs 上,就得到了一个「重启即消失的内存可写层」——Live CD 和某些沙盒就是这么做的。前提是 workdir 也要一起放进这个 tmpfs:

mkdir -p /tmp/ovl/tm && mount -t tmpfs -o size=64m tmpfs /tmp/ovl/tm
mkdir -p /tmp/ovl/tm/upper /tmp/ovl/tm/work /tmp/ovl/tmerged
mount -t overlay overlay -o lowerdir=/tmp/ovl/lower2:/tmp/ovl/lower,upperdir=/tmp/ovl/tm/upper,workdir=/tmp/ovl/tm/work /tmp/ovl/tmerged
df -h /tmp/ovl/tmerged | tail -1
overlay          64M  4.0K   64M   1% /tmp/ovl/tmerged

df 报的是 64M,也就是 tmpfs 的容量,而不是磁盘的 7.6G。这提醒一件事:df 看到的容量永远是 upper 所在文件系统的容量。容器场景里经常有人疑惑「容器里 df 显示的怎么是宿主机的盘」,道理就在这——它报的是可写层的账。

卸载、只读与清理

overlayfs 支持重挂载成只读,这在「共享底层 + 临时只读视图」的场景很好用:

mount -o remount,ro /tmp/ovl/merged
touch /tmp/ovl/merged/nope
touch: cannot touch '/tmp/ovl/merged/nope': Read-only file system

mount -o remount,ro 返回 rc=0,之后写入直接吃 EROFS注意:只读的是合并视图,upper 目录本身没有任何写保护——绕过挂载点直接往 /tmp/ovl/upper/ 里写是照样能成功的,隔离靠的是挂载视图而不是文件权限。

清理时按顺序来:先 umount 挂载点,再删目录。顺序反了会得到 target is busy,因为挂载点被占用时目录树是删不掉的:

umount /tmp/ovl/merged
rm -rf /tmp/ovl

另外,本次实验用的 /tmp/ovl2/tmp/ovl 这类目录在测试完要当场删掉——小磁盘机器上,dd 出来的 10MiB 副本加上 copy-up 产生的真实占用很容易堆起来,养成「先 df -h 再收工」的习惯。

坑位清单

把上面实测到的坑收成一张表:

现象原因正确做法
挂载报 wrong fs type, bad option...dmesg 说 workdir/upperdir 不同挂载workdirupperdir 不在同一个文件系统把两个目录放进同一个挂载(例如同一个 tmpfs)
改一个字节,磁盘占用涨了一整个文件copy-up 是整文件复制大文件尽量放在可写层之外,或用 volume 挂进来
想靠 inode 号判断文件有没有被复制上来overlayfs 有意保持 merged 的 inode 号稳定看 upper 目录里有没有出现该文件
删了下层的文件,磁盘一点没释放whiteout 只是一个 0:0 字符设备,下层数据仍在想省空间要重建镜像层,而不是"删文件"
删目录后重建同名目录,下层内容还在里面目录的遮蔽是靠 opaque 属性,不是 whiteoutgetfattr 确认 trusted.overlay.opaque="y"
容器里 df 显示的容量不对df 口径是 upper 所在的文件系统看可写层的实际容量,别当成宿主机磁盘
umount 前就 rm -rf 实验目录挂载点占用中,删不掉umountrm -rf

小结

OverlayFS 的规则其实只有三条:

  1. 读走下层、写靠 copy-up——写一个文件会把整个文件复制到 upper,动一字节也是整个文件;
  2. 删靠 whiteout、目录遮蔽靠 opaque——下层数据永远不动,标记文件才是"删除"的真相;
  3. 容量和分层都由 upper 决定——df 报的是 upper 所在文件系统,多层 lowerdir 靠前的赢。

理解这三条,容器镜像为什么能分层复用、为什么删文件不减体积、为什么改大文件会突然爆盘,就都能自己推出来了。

动手前建议先把挂载体系补一遍(Linux 磁盘挂载:从 fdisk 分区到 fstab 开机自动挂载);如果实验里造了大文件,duls 对不上的现象可以看 Linux 稀疏文件:从 truncate 预分配到 du 与 ls 大小不一致Linux 磁盘空间排查与清理:从 df 到 journald 的完整救火流程;想进一步了解镜像本身的体积是怎么构成的,可以配合 Docker 镜像瘦身:多阶段构建从 1.6GB 到 184MB 一起看。

发表评论

暂无评论,快来抢沙发吧!