本文要点
- 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/
三点值得注意:
SOURCE显示为overlay,不是任何一块磁盘设备——overlayfs 是虚拟文件系统,不占用块设备;- 挂载选项就是全部配置:
lowerdir/upperdir/workdir三个路径直接写在 mount 参数里,改配置等于重新挂载,它不像 ext4 那样靠超级块记参数; 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
这段输出里有三个关键事实:
① 数据被整份复制到 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 号保持稳定,否则用户态程序(比如 rsync、find -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
输出里的 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实测结果:lower2 和 lower 里都有 keep.txt(内容分别是 v2-content 和 v1-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 -1overlay 64M 4.0K 64M 1% /tmp/ovl/tmergeddf 报的是 64M,也就是 tmpfs 的容量,而不是磁盘的 7.6G。这提醒一件事:df 看到的容量永远是 upper 所在文件系统的容量。容器场景里经常有人疑惑「容器里 df 显示的怎么是宿主机的盘」,道理就在这——它报的是可写层的账。
卸载、只读与清理
overlayfs 支持重挂载成只读,这在「共享底层 + 临时只读视图」的场景很好用:
mount -o remount,ro /tmp/ovl/merged
touch /tmp/ovl/merged/nopetouch: cannot touch '/tmp/ovl/merged/nope': Read-only file systemmount -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 不同挂载 | workdir 与 upperdir 不在同一个文件系统 | 把两个目录放进同一个挂载(例如同一个 tmpfs) |
| 改一个字节,磁盘占用涨了一整个文件 | copy-up 是整文件复制 | 大文件尽量放在可写层之外,或用 volume 挂进来 |
| 想靠 inode 号判断文件有没有被复制上来 | overlayfs 有意保持 merged 的 inode 号稳定 | 看 upper 目录里有没有出现该文件 |
| 删了下层的文件,磁盘一点没释放 | whiteout 只是一个 0:0 字符设备,下层数据仍在 | 想省空间要重建镜像层,而不是"删文件" |
| 删目录后重建同名目录,下层内容还在里面 | 目录的遮蔽是靠 opaque 属性,不是 whiteout | 用 getfattr 确认 trusted.overlay.opaque="y" |
容器里 df 显示的容量不对 | df 口径是 upper 所在的文件系统 | 看可写层的实际容量,别当成宿主机磁盘 |
umount 前就 rm -rf 实验目录 | 挂载点占用中,删不掉 | 先 umount 再 rm -rf |
小结
OverlayFS 的规则其实只有三条:
- 读走下层、写靠 copy-up——写一个文件会把整个文件复制到 upper,动一字节也是整个文件;
- 删靠 whiteout、目录遮蔽靠 opaque——下层数据永远不动,标记文件才是"删除"的真相;
- 容量和分层都由 upper 决定——
df报的是 upper 所在文件系统,多层lowerdir靠前的赢。
理解这三条,容器镜像为什么能分层复用、为什么删文件不减体积、为什么改大文件会突然爆盘,就都能自己推出来了。
动手前建议先把挂载体系补一遍(Linux 磁盘挂载:从 fdisk 分区到 fstab 开机自动挂载);如果实验里造了大文件,du 和 ls 对不上的现象可以看 Linux 稀疏文件:从 truncate 预分配到 du 与 ls 大小不一致 和 Linux 磁盘空间排查与清理:从 df 到 journald 的完整救火流程;想进一步了解镜像本身的体积是怎么构成的,可以配合 Docker 镜像瘦身:多阶段构建从 1.6GB 到 184MB 一起看。
评论 (0)
暂无评论,快来抢沙发吧!