OverlayFS:容器文件系统是怎么工作的?我在容器中读写文件为什么变慢?

OverlayFS:容器文件系统是怎么工作的?我在容器中读写文件为什么变慢?
OverlayFS容器文件系统是怎么工作的我在容器中读写文件为什么变慢实验环境Ubuntu 24.04 / 内核 6.8.0-106-generic / Cgroup v2 / Docker 29.1.3overlayfs/ 华为云 FlexusX 实例 8C16G一、引子一个真实的变慢现场某天凌晨一位同事在群里甩了一张火焰图“我们的 Java 服务迁移到容器后启动时间从 8 秒变成 25 秒磁盘 I/O 那一坨特别宽但宿主机的iostat看着也不忙啊”类似的困惑我听过很多“容器里写个日志文件为什么比物理机上慢”“明明宿主机磁盘很闲容器里的dd却跑不满”“两个容器跑同一个镜像一个容器改了文件另一个没变这俩看到的是同一份吗”这些问题的答案几乎都藏在容器文件系统这一层。理解它你就能解释 80% 的容器磁盘 I/O 玄学。本文不堆概念全部用真机命令 真实输出带你把手扒开 OverlayFS 看个透彻。二、动手手工 mount 一个 OverlayFSOverlayFS overlay 联合文件系统是 Docker 默认的存储驱动overlay2的内核底座。它把多个目录叠在一起给用户一个统一的合并视图。我们在实验机上手工搭一套最小模型目录约定如下lower/ 只读层类比镜像层 upper/ 可写层类比容器可写层 work/ 工作目录overlay 内部用必须和 upper 同文件系统 merged/ 挂载点用户看到的统一视图mkdir-p/root/ovl/{lower,upper,work,merged}echolower a/root/ovl/lower/a.txtecholower b/root/ovl/lower/b.txtmkdir-p/root/ovl/lower/subecholower c/root/ovl/lower/sub/c.txtechoupper x/root/ovl/upper/x.txtmount-toverlay overlay\-olowerdir/root/ovl/lower,upperdir/root/ovl/upper,workdir/root/ovl/work\/root/ovl/merged挂载成功后看merged的合并视图# ls -la /root/ovl/merged drwxr-xr-x 1 root root 4096 ... . -rw-r--r-- 1 root root 8 ... a.txt # 来自 lower -rw-r--r-- 1 root root 8 ... b.txt # 来自 lower drwxr-xr-x 2 root root 4096 ... sub # 来自 lower -rw-r--r-- 1 root root 8 ... x.txt # 来自 uppera.txt、b.txt、sub/来自 lowerx.txt来自 upper——它们被合并成了同一个目录视图。这就是联合挂载union mount最直观的样子。2.1 新建文件落在哪里echonew file/root/ovl/merged/created.txtls-la/root/ovl/upper-rw-r--r-- 1 root root 9 ... created.txt # 新建文件出现在 upper -rw-r--r-- 1 root root 8 ... x.txt结论在合并视图里新建的文件直接落到了upper可写层。lower 一个字节都没动。2.2 修改 lower 里的文件触发 copy-up这是 OverlayFS 最关键的机制。我们尝试修改一个来自 lower 的文件a.txtechomodified in merged/root/ovl/merged/a.txtecho--- upper 现在是否有 a.txtcopy-up 的结果---ls-la/root/ovl/upper/a.txt# -rw-r--r-- 1 root root 19 ... /root/ovl/upper/a.txtecho[lower/a.txt]:;cat/root/ovl/lower/a.txt# lower a原文件没变echo[upper/a.txt]:;cat/root/ovl/upper/a.txt# modified in mergedecho[merged/a.txt]:;cat/root/ovl/merged/a.txt# modified in merged看到了吗lower/a.txt仍是lower a原封不动而upper/里凭空多出了a.txt内容是modified in merged。merged看到的也是修改后的内容。这个过程叫copy-up写时复制当你要修改一个来自只读层的文件时OverlayFS 会先把整个文件从 lower 拷贝到 upper然后再在 upper 里改。lower 永远只读。2.3 删除文件whiteout白障在合并视图里删掉 lower 的b.txtrm/root/ovl/merged/b.txtls-la/root/ovl/merged/|grepb.txt# 空merged 里已无 b.txtls-la/root/ovl/upper/|grepb.txt# c--------- 2 root root 0, 0 ... b.txt注意upper/b.txt是个字符设备c---------主设备号 0、次设备号 0。这不是普通文件而是 OverlayFS 的白障whiteout——它相当于一个标记告诉联合层这个文件被删了别再从 lower 里把它显示出来。可以用stat验证它的设备号stat-c%n %c/root/ovl/upper/b.txt# /root/ovl/upper/b.txt ?0:0的字符设备就是 whiteout 的标志。Docker 容器里你rm一个镜像里的文件底层就是这么干的。三、原理深挖联合挂载与 copy-up 的开销3.1 内核里到底发生了什么用户视角 (merged) │ ▼ ┌──────────────────────────┐ │ OverlayFS │ │ 读upper 优先否则 lower │ │ 写一律落到 upper │ │ 删在 upper 写 whiteout │ └──────────────────────────┘ │ │ ▼ ▼ [ upper 可写层 ] [ lower 只读层 × N ] (容器可写层) (镜像各层, 多容器共享)关键规则只有三条读先看 upper 有没有没有再去 lower 找lower 有多层时从上往下逐层找最上层优先。写/新建一律写到 upper。改copy-up要改 lower 的文件先把整文件拷到 upper 再改。3.2 copy-up 有多慢实测说话copy-up 的开销不是改几个字节而是先把整个文件从 lower 复制一份到 upper。文件越大首次写入越慢。我们放一个 300MB 的大文件到 lower然后在合并视图里分别做首次写和第二次写# lower 放 300MB 大文件模拟镜像层里的大文件ddif/dev/zeroof/root/ovlbig/lower/bigfile.binbs1Mcount300statusnonemount-toverlay overlay-olowerdir...,upperdir...,workdir... /root/ovlbig/merged# 首次写触发 copy-up先整文件拷贝到 upper再追加sync;time(echohello/root/ovlbig/merged/bigfile.bin)# real 0m3.051s user 0m0.000s sys 0m0.253s# 此时 ls -l upper/bigfile.bin → 314572806 字节比原 300MB 多了那 6 字节# 第二次写已在 upper直接写sync;time(echoworld/root/ovlbig/merged/bigfile.bin)# real 0m0.000s铁证首次写 300MB 文件耗时3.05 秒绝大部分时间花在把 300MB 从 lower 拷到 upper第二次写几乎0 秒。这就是容器里写大文件为什么第一次特别慢的真正原因——你以为只追加了几个字节内核却先把 300MB 搬了个家。延伸这也是为什么很多性能建议说不要把大文件放进镜像层再去改以及日志、临时文件这种高频写路径要用 volume 而不是容器可写层。四、对应到 Docker你的容器到底长啥样接下来把上面的手工模型对到真实的 Docker 上。这里有个重要坑本文的实验机是Docker 29.1.3它已经用 containerd 的 snapshotter 管理镜像层docker inspect里已经没有老资料里的GraphDriver.Data字段了取而代之的是Storagedockerinspect ovltest--format{{json .Storage}}# {RootFS:{Snapshot:{Name:overlayfs}}}真正的 overlay 挂载点在哪里看宿主机的挂载表mount|grepoverlay# overlay on /var/lib/docker/rootfs/overlayfs/3e9e5db... type overlay# (rw, lowerdir.../snapshots/2/fs:.../snapshots/1/fs,# upperdir.../snapshots/3/fs,# workdir .../snapshots/3/work)拆解一下这条挂载lowerdirsnapshots/2/fs:snapshots/1/fs—— 两个只读层镜像层用:分隔自上而下叠加upperdirsnapshots/3/fs—— 容器唯一的可写层workdirsnapshots/3/work—— overlay 内部工作目录。在容器里写一个文件去宿主机 upper 层找它dockerexecovltestsh-cecho hello-from-container /root/written_in_container.txtUPPER/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/3/fsls-la$UPPER/root/# -rw-r--r-- 1 root root 21 ... written_in_container.txtcat$UPPER/root/written_in_container.txt# hello-from-container完全对得上容器里/root/written_in_container.txt在宿主机就是snapshots/3/fs/root/written_in_container.txt。这就是容器里写文件落到了哪。镜像层与 snapshot 的对应关系用docker image inspect看链 IDdiff iddockerimage inspect ubuntu:24.04--format{{json .RootFS.Layers}}# [sha256:f103cd120fdd6cbc7f3d0e1d5bfb3e16290c32ead7b822d15c34460ecdf64b29]而宿主机上只读层就是snapshots/1、snapshots/2ls/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/# 1 2 31、2是 ubuntu 镜像的只读层被所有该镜像的容器共享3是本次容器的可写层。五、一个反直觉的事实多个容器看的是同一份文件既然镜像只读层是共享的那多容器读同一个文件page cache 会缓存几份答案是只缓存一份——因为 page cache 在内核里是按(设备号, inode 号)做键的而多个容器看到的只读层文件底层的 inode 是同一个。实测验证启动两个容器都基于ubuntu:24.04各自 stat 镜像层文件/etc/os-releasedockerexeccgteststat-c%i /etc/os-release# 525177dockerexeccgtest2stat-c%i /etc/os-release# 525177两个容器看到的 inode 都是525177。去宿主机 snapshot 只读层一查find/var/lib/containerd/.../snapshots/-nameos-release\-execstat-c%i %n{}\;|grep^525177 # 525177 .../snapshots/1/fs/etc/os-release结论SAME_INODE_CONFIRMED。两个容器读的是同一个物理 inode在共享只读层snapshots/1里page cache 自然只缓存一次。这解释了为什么一个容器预热过的镜像另一个容器起来特别快——它们共享同一份缓存。六、为什么容器里读写会变慢排查思路把前面的实验串起来容器 I/O 变慢的常见根因有四类copy-up 开销改镜像层里的大文件首次写要先整文件拷贝。表现time看首次写巨慢第二次飞快见 3.2。多层查找只读层越多读一个文件要逐层往下找虽然目录项有缓存但冷启动/大量小文件时明显。docker history image看层数FROM scratch拼的镜像层数爆炸要警惕。可写层膨胀拖慢容器可写层是 overlay 的一层文件多了、碎片多了查找和 writeback 都更慢而且它和镜像层混在一起删容器才释放。Page cache 与内存纠缠容器读的/写缓存的 page 都算进容器的 memory cgroup见博客 3内存吃紧时触发回收连带 I/O 抖动。排查清单真人可用# 1) 看容器可写层占了多少dockerps-s# 2) 定位容器的 overlay 挂载看 upper 大小mount|grepoverlaydu-sh/var/lib/containerd/.../snapshots/N/fs# 3) 高频写路径是不是落在了可写层dockerinspectc--format{{json .Mounts}}# 没挂 volume 的都在可写层# 4) 冷启动时是不是在改大文件copy-up七、最佳实践用 volume 避开 overlay 开销既然可写层有 copy-up、膨胀、与镜像耦合这些毛病高频写路径日志、临时文件、数据库数据目录应当用 volume / bind mount绕开 overlay。实测对比容器内写 500MBoflagdirect直写隔离缓存写 500MB 到容器可写层 /tmp走 overlay upper 5000 records out 524288000 bytes copied, 3.35534 s, 156 MB/s 写 500MB 到 bind volume /data直写宿主机 ext4 5000 records out 524288000 bytes copied, 4.00356 s, 131 MB/s注意对纯新建文件来说overlay 与 bind mount 的吞吐其实差不多156 vs 131 MB/s这俩都受同一块云盘限制。真正拉开差距的是前面说的copy-up和可写层膨胀——当你改的是镜像里已有的大文件或长期往可写层堆数据overlay 的代价才会凸显。所以经验法则只读/一次性写的程序代码、配置 → 可写层无妨日志、临时文件、数据库、需要反复改的大文件 →一定要挂 volume需要极限性能且不怕重启丢失 →--tmpfs纯内存见博客 2。八、扩展多层只读层的查找代价与不透明白障前面演示的是两层1 个 lower 1 个 upper。真实镜像往往有很多层。Docker 镜像的每一层都是 OverlayFS 的一个 lower启动时从上到下依次叠加。读一个文件时OverlayFS 要从最上层 lower 往下逐层找直到在某层找到为止。这一节把多层的几个隐藏代价讲清楚。8.1 多层查找的代价在哪读路径merged 视角read(/etc/nginx.conf) │ ┌────────────┴───────────────────────────────┐ │ upper (容器可写层) │ │ 没找到 → 去 lower[N] │ │ 没找到 → 去 lower[N-1] │ │ ... │ │ 没找到 → 去 lower[1] │ │ 找到返回 │ └─────────────────────────────────────────────┘ 层数越多冷路径上逐层 miss的次数越多。要注意目录项dentry和 inode 是有缓存的热路径上不会真的每层都去磁盘找一遍所以层数多导致读慢在日常随机读里并不明显。真正会被层数拖慢的是两类场景冷启动 / 首次访问缓存还是空的打开一个深层文件要真的逐层 stat层数多则 syscall 次数线性增加大量小文件、目录遍历像find、npm install、python -c import ...这种要枚举海量路径的操作每层都要参与合并getdents要合并所有层同名的目录项层数爆炸时开销显著。这也是为什么优化镜像时“合并 RUN 指令减少层数”用多阶段构建删掉构建工具层是有意义的——不是省那几 MB而是省层数的查找与合并开销。8.2 删目录要用不透明白障前面删普通文件用的是 whiteout字符设备c--------- 0,0。删整个目录时OverlayFS 用的是另一种机制——不透明白障opaque whiteout在 upper 对应位置创建一个目录并打上trusted.overlay.opaquey扩展属性。它的语义是从这个目录往下别再去 lower 找了只认 upper 里的内容。# 在 merged 里删掉 lower 的 sub 目录再看 upper 留下了什么rm-rf/root/ovl/merged/subls-la/root/ovl/upper/|grepsub# drwxr-xr-x 2 root root sub ← 目录还在但是不透明的getfattr-ntrusted.overlay.opaque /root/ovl/upper/sub2/dev/null# trusted.overlay.opaquey ← 关键标记要点不透明白障让 upper 的目录盖住了 lower 的同名目录避免 OverlayFS 再去下层翻可能已经被删掉的内容。Docker 里你rm -rf一个镜像里的目录底层就是它。8.3 一个常被忽略的事实upper 也会反向拖累读很多人以为只读层共享、读快却忘了 upper 也在读路径上每次读都要先查 upper 有没有覆盖。当容器的可写层里堆了大量文件和小目录日志、临时文件、缓存时OverlayFS 在 upper 的查找也会变慢而且这些文件是脏的、无法被多个容器共享缓存。所以可写层别乱塞东西既是磁盘问题也是读性能问题。九、动手诊断你的容器到底卡在 overlay 哪一步当你怀疑慢在文件系统时可以按这条链路快速定位# 1) 确认容器根确实挂在 overlay 上并看清三层路径grepoverlay /proc/mounts|grep-v/root/ovl# 排除我们手工实验那套# overlay /var/lib/docker/rootfs/overlayfs/id ... lowerdir... upperdir... workdir...# 2) 看容器可写层upper实际占了多少dockerps-s# SIZE 列虚线是镜像共享层实线是本容器独有的可写层UPPER/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/N/fsdu-sh$UPPER# upper 越大copy-up / 查找越慢# 3) 是不是在频繁改大文件copy-up 高发# 用 fatrace / inotify 看容器里哪些路径在高频写重点怀疑落在可写层、且源文件在镜像层的大文件一个常被忽略的判断overlay 不是所有慢的背锅侠。先排除 CPU、网络、锁竞争用perf top、flamegraph、netstat -i看丢包确认 I/O 才是瓶颈后再回到 overlay 的 copy-up 与可写层膨胀上。——盲目去优化 overlay往往治标不治本。存储驱动速览overlay2 不是唯一选择驱动原理适用备注overlay2联合挂载 copy-up绝大多数场景默认需要overlay内核模块copy-up 有首写开销devicemapper瘦供给thin provisioning旧 RHEL 生态配置复杂、已逐步弃用btrfs / zfs原生写时复制文件系统需要原生快照/配额需要底层就是 btrfs/zfs运维门槛高aufs早期联合文件系统老版本 Docker已废弃新内核不再默认支持结论很明确没特殊需求就用 overlay2把精力放在减少 copy-up、把高频写挂 volume上比换驱动划算得多。十、常见误区三连误区一“挂 volume 一定比可写层快。”实测表明对纯新建文件两者吞吐相近本文 156 vs 131 MB/s。volume 真正的价值是避开 copy-up改镜像大文件和避免可写层无限膨胀而不是读写更快本身。误区二“每个容器都拿到了一份独立的镜像拷贝。”不对。只读层是多容器共享同一 inode、共享 page cache的见第五节。容器间不是拷贝关系是叠加视图关系。误区三“在容器里rm一个文件空间立刻回来。”大多时候不会。overlay 的删除是写 whiteout一个字符设备标记真正占空间的 upper 数据要等到容器被删除时才随可写层一起回收。docker rm前它一直占着盘。十一、小结与思考题本文我们从零手工 mount 了一个 OverlayFS亲眼看到了合并视图、新建落 upper、改文件触发copy-up、删除产生whiteout 字符设备copy-up 的真实代价300MB 文件首次写3.05 秒第二次0 秒Docker 29 下 overlay 挂载点在/var/lib/docker/rootfs/overlayfs/id可写层在 containerd 的snapshots/N/fs且docker inspect已改用Storage字段多容器共享只读层同一 inodepage cache只缓存一份volume 能绕开 overlay 的 copy-up 与膨胀问题。思考题如果镜像层有 10 层你在最顶层容器里rm了一个只存在于第 8 层的文件OverlayFS 要干几件事whiteout 会写到哪一层copy-up 是整文件拷贝。如果镜像里有个 2GB 的文件你在容器里只改了其中一行第一次写的代价大约是多少生产上怎么规避为什么多容器共享只读层只缓存一份既省内存、又可能成为瓶颈提示太多容器同时读同一文件时的锁与 cache 争用下一篇我们聊《容器文件 Quota容器为什么会把宿主机磁盘写满怎么限制》看看怎样防止一个失控的容器把整块盘拖垮。顺带一提overlay 解决的是文件系统视图与写时复制它不解决磁盘空间隔离——容器照样能把宿主机的盘写满这正是 Quota 那篇要补上的另一半。实验环境与命令均在本机 Ubuntu 24.04 / 内核 6.8 / Docker 29.1.3 / Cgroup v2 真机执行输出为原始截取。