从最小 FUSE 复现到 nydusd umount 修复:一次挂载点失效通知没有发到正确位置的问题
太长不看版
- 在
nydusd的这个问题里,现象是:第一次挂载后,一个路径比如/a/b/c还能正常访问;经历一次 unmount / remount 以后,再去访问它会提示“这个路径不存在”;但如果先对它的父目录做一次ls -l /a/b,再回头访问/a/b/c,它又好了。 - 原因是:
nydusd umount时,那条挂载点入口的失效通知没有发到正确的“直接父目录 inode + 单个 entry name”上,内核于是还沿用了旧的名字状态。后面ls -l之所以能把它救回来,是因为它会继续触发更强的父目录读取和子项属性访问,把当前的名字和对象关系重新交回内核。 - 修复点是:
nydusd不要把整条挂载路径当成一个名字去发失效通知,而是要对挂载点最后一级名字,按它的直接父目录 inode 和 basename 去发。
这篇文章先不从具体系统讲起,而是先整理一个很绕的路径问题。
如果第一次读到 FUSE,可以先把它理解成:文件系统逻辑主要跑在用户态,内核通过一套接口和它配合。
前四节先只讲这个 bug 本身、FUSE 背景和一个最小 demo,不急着抛真实系统名字。到第 5 节,再回到真实场景,看它为什么会落到 nydusd 的那段修复上。
1. 先看 bug:一条路径为什么会死而复生
先不管它发生在谁身上,先只看 bug 本身。
固定顺序其实很短:
- 第一次挂上以后,这条路径能正常访问
- 中间发生一次 unmount / remount
- 第二次挂回同一路径后,直接
stat这条路径,返回ENOENT - 对它的父目录做一次
ls -l - 再次
stat同一路径,又成功了
这里有两个动作要先分清:
- direct path 访问:直接拿完整路径去查,比如
stat /x/y/z - 父目录查看:这里特指
ls -l /x/y这种不只列名字、还会继续补子项属性访问的动作
最诡异的地方就在这里:
- 失败的是 direct path 访问
- 能把它救回来的是
ls -l这种更强的父目录查看
先不用急着记 FUSE 术语,只要先抓住一点:内核不只记“对象本身是什么”,也会记“某个父目录下面这个名字现在指向谁”。这两件事如果不同步,就会出现“直接按路径去找失败,但做一次 ls -l 又恢复”的怪现象。
本文真正想回答的问题就是:
为什么一次
ls -l父目录,会影响一个子路径的 directstat结果?
如果这个问题不先讲清楚,后面就很难判断修复到底是碰巧好了,还是确实打到了点上。
2. 先补最少但够用的 FUSE 背景
这一节其实只想让读者记住一条线:内核去碰一个路径时,不只会记“这个对象自己是什么”,也会记“父目录下面这个名字现在指向谁”;而当你去读父目录时,又会碰到把“名字和对象关系”一起带回来的目录读取。
把这条线串起来,后面为什么一次 ls -l 能把坏路径救回来,就会顺很多。
2.1 按路径访问时,内核其实会碰到两层缓存
先只看最常见的按路径访问。
如果一个 FUSE 文件系统已经挂上去了,内核之后再读 /a/b/c 这种路径时,可以先粗略理解成两步:
- 按路径一级级往下找名字,也就是先看
/下面有没有a,再看a下面有没有b,最后看b下面有没有c - 找到对象以后,再继续取属性,或者继续做目录读取、打开文件之类的动作
这两步正好对应这次问题里最相关的两层:
dentry:名字缓存。更接近“在某个父目录下面,有个叫这个名字的项,它现在指向谁”inode:对象本体。更接近“这个文件或目录自己是什么”
也就是说,按路径访问 /a/b/c 时,内核其实既会碰到“b 下面 c 现在指向谁”,也会碰到“这个 c 自己是什么”。
这两层不是一回事。如果名字这一层还指着旧对象,而对象那一层又已经变了,路径访问就可能显得很怪。
2.2 读父目录时,readdirplus 会把“名字和对象”一起带回来
上面那条链只解释了“直接按路径访问”时,内核为什么会同时碰到名字和对象两层。
但这篇文章里真正把路径救回来的,不是再试一次 stat /a/b/c,而是先做了一次 ls -l /a/b。这时内核碰到的就不再是单条路径查找,而是目录读取。
这里本文只需要知道 readdirplus 就够了。可以先把它理解成:
读目录项名字的同时,顺手把这些名字当前对应的对象信息也一起交给内核。
它比“只把名字列出来”更强,因为它不只是说“这里有这个名字”,还会顺手说“这个名字现在对应哪个对象”。
所以从这一步开始,前面的两层就和目录读取接起来了:
- direct path 访问,更像是在按路径一级级找名字,再去碰对象
ls -l这种更强的父目录查看,则会通过readdirplus把“名字 + 当前对象”一起交回来
2.3 umount 以后,旧状态为什么还可能留着
再往前走一步,就会碰到这次问题真正绕的地方:明明已经 umount 了,为什么旧状态还会继续影响后面的路径访问?
先按这个通用场景,可以把 umount 粗略理解成:
内核先不再把这个文件系统入口当成当前有效入口。
但这不等于:
这条路径下面曾经见过的所有名字缓存和 inode 缓存,会在同一瞬间全部消失。
真正会牵扯到旧状态怎么退场的,恰好就是本文后面会反复碰到的这一组东西:
notify_inval_inode可以先理解成:这个 inode 自己的属性缓存过期了。notify_inval_entry(parent, name)可以先理解成:这个父目录下面、名字叫name的目录项缓存过期了。lookup count可以先理解成:内核手里还记着这个 inode 几次。lookup、create、readdirplus这些动作都可能把这个计数加上去。FORGET可以先理解成:内核以后不用这个 inode 了,把之前记的这部分引用还回来。
把它们放回前面那条线里看,就比较容易理解了:
umount以后,旧的“名字 -> 对象”关系和旧 inode 不会自动同时消失- 用户态文件系统还得把该失效的名字缓存、对象缓存准确告诉内核
- 如果只让对象这一层过期了,但名字这一层没处理对,后面的路径访问仍然可能沿着旧名字状态走
2.4 这一节只需要记住的最小模型
把这次问题压到最简单,就是这三层:
1 | parent directory |
后面你只要一直记住这条线就够了:
- direct path 访问,主要是在碰前两层
ls -l这类父目录查看,会通过第三层把前两层重新补回来
所以:
- 按路径访问更像是沿着现成的“名字 -> 对象”关系一路往下走
- 目录读取更像是把当前有哪些名字、这些名字对应什么对象,再交给内核看一遍
3. 再用一个最小 FUSE demo 把它复现出来
3.1 最小目录树和复现流程
这个 demo 只保留一棵极小的目录树:
1 | / |
这里的 remount_bad 不是 shell 命令,而是输入给 demo 进程 stdin 的一条控制命令。它会做三件事:
- 把
/a/b/c从旧 inode 切到新 inode - 对旧 inode 发一次
notify_inval_inode - 故意把那条目录项失效通知写错
这个 demo 只证明一件事:
同一个名字在 remount 前后换到了另一个对象上时,如果“父目录下面这个名字”没有被正确作废,direct
stat还可能先撞到旧对象;做一次ls -l之后才恢复。
复现流程很短,按实际跑出来的顺序看就是:
- 第一次
stat /a/b/c结果:成功。这个步骤只是先把旧对象预热到内核里。 向 demo 进程输入
remount_bad这一步之后,demo 侧会先打印:也就是说,名字1
2
3[state] remounted c old_ino=4 new_ino=5 gen=2 mode=bad
[notify] inval_inode rc=0
[notify] inval_entry rc=-2c已经切到了新对象,但那条目录项失效通知是故意发错的。这里的rc=-2可以先简单记成:这条 entry invalidation 没命中目标。第二次
stat /a/b/c结果:失败,返回ENOENT。对应日志是:这说明第二次1
[getattr] stale ino=4 -> ENOENT
stat还先走到了旧 inode。执行
ls -l /a/b结果:成功。对应日志是:这里已经能看到目录读取和旧 inode 退场的痕迹了。1
2
3[readdirplus] ino=3 off=0
[forget] ino=4 nlookup=1
[readdirplus] ino=3 off=3第三次
stat /a/b/c结果:成功。路径恢复。
3.2 代码里真正关键的地方
这个 demo 的核心状态只有一个版本号:
1 | struct State { |
也就是说:
g_version = 0时,c对应 inode4g_version = 1时,c对应 inode5
这里顺手带着的 generation,可以先把它理解成同一名字切到新对象时一起变化的版本号。本文主线只要记住:旧对象和新对象不只是 inode 不同,连这个版本号也一起变了。
remount_bad 做的事也很短:
1 | void remount_bad() { |
这里故意做了两件事:
- 对旧 inode 发
notify_inval_inode - 故意把目录项失效通知写错成:
1 | fuse_lowlevel_notify_inval_entry(g_session, kRoot, "a/b/c", 5); |
而正确写法应该是:
1 | fuse_lowlevel_notify_inval_entry(g_session, kB, "c", 1); |
也就是:
- 错误:
ROOT + "a/b/c" - 正确:
inode(b) + "c"
回头再对照刚才流程里的几组日志,就比较容易看出代码和现象怎么对应了:
- 步骤 2 的
[state]和两条[notify],都来自remount_bad() - 步骤 3 的
[getattr] stale ino=4 -> ENOENT,来自第二次stat /a/b/c - 步骤 4 的两条
[readdirplus]和一条[forget],来自ls -l /a/b
4. 从 demo 里先收一个结论
从 demo 里先记住这一点就够了:光把旧 inode 作废还不够,“父目录下面这个名字”也要按正确的父目录和名字去失效。否则 direct path 还会沿着旧名字状态走,直到 ls -l 重新把当前名字和对象关系交回内核。
到这里为止,我们只知道这是一类 FUSE 路径问题。下面才回到真实场景,看它为什么会映到 nydusd。
5. 把 demo 映回 nydusd:真实路径、三层树和 inode 边界
前四节故意没有进入真实系统。现在才把真实背景补上。
这里的 Nydus 可以先理解成一种按需把镜像内容提供出来的镜像文件系统方案,nydusd 则是它的用户态守护进程。
如果把 nydusd 的启动和挂载动作压到最简,可以先把它看成这样:
1 | sudo nydusd \ |
1 | curl --unix-socket /tmp/nydus.sock \ |
这里只保留和路径关系最相关的部分。config 继续省略细节,但 source 没省,因为它就是这次实际挂进去的那份 bootstrap 文件。
做完这两步以后,宿主机上真正访问到的完整路径才是:
1 | /data/nydus-images/harbor-yctest.huya.info/harbor_leaf_nydus/machine-learn/chengyue_hygame_cover_v0.0.14 |
后面第 5 节和第 6 节说的那些 mountpoint、mountpoint root、mountpoint_parent_ino,都默认围绕这一个例子来讲。第 6 节会直接出现 PseudoFs、Vfs、root_ino、mountpoint_parent_ino、fs_idx 这些词,所以这一节先把真实路径、三层树和 inode 边界放回同一张图里。
5.1 真实路径、挂载点和 mountpoint root 各指什么
还拿刚才这条完整路径继续说。它可以直接拆成两段:
/data/nydus-images/harbor-yctest.huya.info/harbor_leaf_nydus/machine-learn/chengyue_hygame_cover_v0.0.14
前半段是 nydusd 启动时真正挂到宿主机上的共享 FUSE 挂载根,后半段则是通过 HTTP API 动态挂进去的一条镜像子路径。
所以后面如果看到这些词,可以直接代回这个例子:
mountpoint指/harbor-yctest.huya.info/harbor_leaf_nydus/machine-learn/chengyue_hygame_cover_v0.0.14mountpoint root指最后一级chengyue_hygame_cover_v0.0.14,也就是这条子路径在PseudoFs里的那个入口mountpoint_parent_ino指这条子路径直接父目录,也就是machine-learn那一层对应的 pseudo inode
5.2 为什么这里要分成 Rafs、PseudoFs 和 Vfs
这里最容易混淆的一点是:
目录树看起来明明只有一棵,为什么这里会同时冒出
Rafs、PseudoFs、Vfs?
因为 nydusd 做的不是“把一张镜像直接摊出来”,而是“把多张后端文件系统按挂载路径拼到一棵总树上”。所以这里至少要分三层看:
Rafs:单张镜像自己的真实内容树PseudoFs:把多个挂载点串起来的路径骨架Vfs:不自己保存镜像内容,它负责沿着PseudoFs走路径,并在 mountpoint 处切到对应的Rafs
如果一定要说“三棵树”,前两棵是真有各自节点和 inode 的树;第三棵更像 Vfs 拼出来的统一视图,也就是内核最终是通过它看到前两层拼在一起后的结果。
可以先把它压成一句话:
1 | Rafs = 单张镜像内部真正的文件系统内容 |
5.3 mountpoint root 为什么正好卡在边界上
假设 nydusd API 里的镜像挂载子路径是:
1 | /harbor-yctest.huya.info/harbor_leaf_nydus/machine-learn/chengyue_hygame_cover_v0.0.14 |
把宿主机上的完整访问路径和两层树合在一起看,可以直接画成这样:
1 | /data/nydus-images # Linux 里真正挂上的 FUSE 挂载根 |
Vfs 做的事情,就是把这两层接起来。顺着上面这张图读就行:
- 先走到
/data/nydus-images,这一步还只是走到 Linux 里真正挂上的那个 FUSE 挂载根 - 穿过
/data/nydus-images以后,路径解析才进入nydusd这边的Vfs - 走到
chengyue_hygame_cover_v0.0.14之前,看到的还是PseudoFs里的路径骨架;命中这一层以后,Vfs才切到对应的Rafs root,所以下面的app/bin/etc/lib已经是镜像内部内容
所以 mountpoint root 不是一个普通目录名,它是一个边界点:
- 在它上面,路径解析还在
PseudoFs - 穿过它之后,路径解析进入真正的 backend root
这也就是为什么正文前面的 demo 能和 nydusd 对上。demo 里的 /a/b/c,映到这条真实路径时,可以直接这样看:
/a/b对应/harbor-yctest.huya.info/harbor_leaf_nydus/machine-learn/a/b/c对应最后一级chengyue_hygame_cover_v0.0.14
所以这里真正对上的,不是宿主机上的 /data/nydus-images,也不是镜像内部某个普通子目录,而是这条镜像子路径最后一级名字本身。
5.4 这里的 inode 为什么还要再分三层
到了第 6 节看修复代码时,最容易混掉的是 inode 的层级。这里至少要分清三种:
pseudo inode:属于PseudoFs,表示路径骨架上的节点- backend inode:属于具体某个 backend,这里实际就是某个
Rafs inode - kernel-visible inode:最终发给内核看的 inode
先看最不容易误会的两种:
machine-learn、chengyue_hygame_cover_v0.0.14这种挂载路径骨架节点,用的是pseudo inode- 镜像内部
/、/app、/bin这些真实对象,用的是 backend inode,也就是Rafs inode
真正容易绕的是第三种。因为对 Rafs 来说,不同镜像里都可能有 inode 1、2、3。所以 Vfs 不能把裸 backend inode 直接丢给内核,而是会再带上这个 backend 的身份编号 fs_idx,组合成内核看到的 inode。你可以先把它记成:
1 | kernel-visible inode = (fs_idx << 56) | backend inode |
这里顺手强调两个不要混的点:
- 不是把
pseudo inode和 backend inode 直接拼成一个 inode pseudo inode继续服务于挂载路径骨架;真正要编码给内核的,是 backend 身份fs_idx和 backend inode
把这三个量代回本文会更清楚:
mountpoint_parent_ino:mountpoint 直接父目录的pseudo inodemountpoint_ino:mountpoint 自己这个骨架节点的pseudo inoderoot_ino:这张 backend 自己的 root inode- 后面递归 walk backend 子树时,真正发给内核看的,则是带了
fs_idx的 kernel-visible inode
所以这次修复为什么卡在 mountpoint root,也就能说得更直白了:
- 对父目录名字关系来说,mountpoint root 还是
PseudoFs里的一个 entry - 对穿过这个 entry 之后的真实内容来说,它背后又已经是另一张 backend 的 root inode
一旦这层边界的 entry invalidation 没打准,就会出现前面那个现象:后端其实已经 remount 到新对象了,但 direct path 还沿着旧名字状态走;做一次 ls -l 父目录后,内核才重新认识这个边界点。
6. nydusd 修复到底改了什么
现在终于可以回到真实修复点。
6.1 这一节会出现的几个内部名字各指什么
这一节会出现几个内部名字,先把它们和前面的真实场景对应起来:
kicker是把请求送到 API 线程并等结果回来的地方walk_and_notify_invalidation()是最后真正去发失效通知的地方MountHandler是接 HTTP 请求的那层ApiRequest::Mount / ApiRequest::Umount是投递给 API 线程的请求对象FsService是真正处理挂载和卸载的服务层
如果想看每个节点在代码里具体做了什么,后面可以直接跳到附录 B.1 和 B.4。
6.2 这次修复相关的最短调用链
只看和这次修复直接相关的调用链,大致是:
1 | POST/DELETE /api/v1/mount?mountpoint=... |
真正决定修复是否有效的地方,不在 mount 阶段那棵镜像树怎么准备,而在 umount() 里失效通知的参数到底怎么传。
6.3 修复前后到底差在什么地方
带着上一节那三层结构和三种 inode,再看修复前后就容易很多。这里的 cmd.mountpoint 也先说明一下:它不是 /data/nydus-images 这种宿主机 FUSE 挂载根,而是 nydusd API 里的镜像挂载子路径。下面先看修复前,再看修复后的等价写法。
先把这几个变量翻成更好懂的话:
root_ino指的是那棵镜像树自己的 root inode,也就是 backend root inodemountpoint_ino指的是 pseudo 层里这条镜像子路径最后一级入口自己的 inodemountpoint_parent_ino指的是它的直接父pseudo inodefs_idx是 backend 在 VFS 里的身份编号。本文主线不用盯它,但后面递归发给内核的 backend inode 会带着它一起编码
这次修复里最关键的,就是最后这一对参数:parent + basename。这里的 basename 就是路径最后一段名字。
修复前,逻辑等价于:
1 | self.walk_and_notify_invalidation( |
也就是:
parent = ROOT_IDname = 整条 API mountpoint 子路径去掉开头的 /
修复后,逻辑变成了:
1 | let mountpoint_name = Path::new(&cmd.mountpoint) |
说白了,这段代码就是把:
ROOT_ID + 整条 API mountpoint 子路径
修成了:
直接父 pseudo inode + 最后一段名字
这和前面 demo 的那组对照是一回事。
6.4 这次改动只影响哪一层
这次改动只发生在 umount 阶段,修的是那条镜像子路径最后一级入口的 entry invalidation 参数:把“整条 API mountpoint 子路径”改成“直接父 inode + basename(最后一段名字)”。所以它改的不是 /data/nydus-images 这个宿主机 FUSE 挂载根,也不是 RAFS 后端本身,而是后端已经换了以后,内核为什么还会短时间沿着旧名字状态走。
附录 A:FUSE demo 完整档案
A.1 环境、构建与复现
A.1.1 实际验证环境
下面这套环境上已经跑通了完整复现链:
- 内核:
Linux 5.4.0-48-generic - 发行版:
Ubuntu 16.04 LTS (Xenial Xerus) g++:g++ (Ubuntu 5.4.0-6ubuntu1~16.04.2) 5.4.0 20160609- Python:
3.8.19 meson:1.11.1ninja:1.13.0.git.kitware.jobserver-pipe-1pkg-config:0.29.1
对应的 libfuse 上游源码信息如下:
- 仓库:
https://github.com/libfuse/libfuse.git - commit:
ff326b19ff09cf8e9926f6fb130b3002e050b5c0 - 当前源码声明版本:
3.19.0-rc0
注意:
- 在这套环境里,普通用户直接挂载 demo 会失败(对应 libfuse 里的
fuse_session_mount()),因此下面的实际运行命令都使用sudo。
A.1.2 demo 编译命令
这里不展开 libfuse 自己怎么安装或构建。只要你的环境里已经有可用的 libfuse3 头文件和库,就可以直接编这个 demo。
如果你的环境里 pkg-config fuse3 可用,最简单的编译命令就是:
1 | g++ -std=c++17 -Wall -Wextra -O2 \ |
如果你是自己编译或手工安装的 libfuse3,没有走 pkg-config,那就把下面这几个占位符替换成你自己的路径:
1 | g++ -std=c++17 -Wall -Wextra -O2 \ |
libfuse 本身请按你自己的系统和习惯自行准备。
A.1.3 运行与清理命令
下面是一组最直接的运行方式。
下面把挂载点路径统一记成 <mnt>,实际操作时把它替换成你自己的挂载点目录。按下面顺序操作即可。
步骤 1,先清理并重新创建挂载点目录:
1 | sudo umount -l <mnt> 2>/dev/null || true |
步骤 2,在终端 1 启动 demo:
1 | sudo ./demo_fuse_readdirplus <mnt> |
步骤 3,在终端 2 做第一次 stat:
1 | sudo stat <mnt>/a/b/c |
步骤 4,回到终端 1,输入控制命令 remount_bad:
1 | remount_bad |
步骤 5,回到终端 2,再次执行 stat:
1 | sudo stat <mnt>/a/b/c |
步骤 6,继续在终端 2 执行 ls -l:
1 | sudo ls -l <mnt>/a/b |
步骤 7,最后在终端 2 第三次执行 stat:
1 | sudo stat <mnt>/a/b/c |
测试结束后的外部清理命令:
1 | sudo pkill -f 'demo_fuse_readdirplus <mnt>' || true |
A.1.4 当前这份精简 demo 的复现结果
按 A.1.3 的步骤跑下来,结果和正文 3.1 一致:第一次 stat <mnt>/a/b/c 成功,remount_bad 之后第二次 stat <mnt>/a/b/c 失败,执行 ls -l <mnt>/a/b 之后第三次 stat <mnt>/a/b/c 恢复成功。
A.2 完整源码
下面给出本文讨论的完整 demo 源码。
1 |
|
A.3 机制细节
A.3.1 先按源码分块看这个 demo
这份 demo 虽然不长,但读的时候最好先按职责分块,不要一上来从头顺着函数名硬看。
第一块是“最小文件系统状态”:
- 常量
kRoot、kA、kB、kC1、kC2 Stateg_versionstate()
这一块只负责一件事:决定当前 c 到底指向旧 inode 4,还是新 inode 5。
第二块是“目录树和属性辅助函数”:
parent_of()lookup_child()fill_stat()make_entry()append_dirent_plus()reply_dirbuf()
这一块负责把 / -> a -> b -> c 这棵最小目录树组织起来,并把 lookup、readdirplus 需要回给内核的 entry/attr 填好。
第三块是“真正对 FUSE 请求负责的回调”:
ll_init()ll_lookup()ll_getattr()ll_opendir()ll_readdirplus()ll_forget()
这一块就是内核真正会打进来的入口。
第四块是“主动制造问题的控制路径”:
remount_bad()control_thread()
它不来自内核,而是来自我们手工输入给 demo 进程的 remount_bad 命令。
第五块才是“把一切接起来的启动代码”:
main()
这一块负责注册回调、挂载 FUSE、启动 stdin 控制线程,然后进入主循环等内核请求。
A.3.2 main() 里到底注册了什么,回调又是怎么跑起来的
这份 demo 用的是 libfuse3 的 low-level API,所以 main() 里最关键的不是业务逻辑,而是这张回调表:
1 | fuse_lowlevel_ops ops {}; |
这里可以先把它理解成:
内核之后如果发来某种 FUSE 请求,libfuse 就按这张表把请求转到对应的
ll_*函数。
后面的启动顺序也可以按职责看:
fuse_session_new(&args, &ops, sizeof(ops), nullptr)建一个 FUSE session,并把这张ops回调表交给 libfuse。fuse_session_mount(g_session, argv[1])把这个 session 真正挂到命令行传进来的挂载点。std::thread(control_thread).detach()另起一个线程专门等 stdin 里的remount_bad。fuse_session_loop(g_session)进入主循环,持续从内核收请求,再按ops分发到ll_lookup()、ll_getattr()、ll_readdirplus()这些回调里。
也就是说,这个 demo 其实有两条并行控制流:
- 一条是内核经 FUSE 打过来的正常路径访问请求
- 一条是我们手工输入
remount_bad后,demo 自己主动发 invalidation
两条线正好在这次复现里交叉起来:
- 内核侧先通过
lookup/getattr把旧的c预热进去 - stdin 侧执行
remount_bad(),把c切到新 inode,并故意发错那条 entry invalidation - 内核侧再继续发
getattr/readdirplus/forget - 我们就能看到“direct
stat失败,ls -l后恢复”的完整链路
如果再按回调作用各看一眼,这个 demo 的职责分工其实很直:
ll_init()在连接建立时打开FUSE_CAP_READDIRPLUS,并关闭READDIRPLUS_AUTO,强制目录读取走readdirplus。ll_lookup()负责名字解析,也就是“某个父目录下面这个名字当前指向哪个 inode”。ll_getattr()负责属性查询;如果打到了旧 inode4/5里已经过时的那个,会打印 stale 日志并回ENOENT。ll_opendir()负责目录打开,本例里只做最小的“活着就给开,不活就报错”。ll_readdirplus()负责目录枚举,并把 entry 和属性一起回给内核。这是ls -l /a/b能把路径救回来的关键。ll_forget()负责接收内核还回来的引用,也就是我们在日志里看到的[forget]。
A.3.3 第 5、6 步的日志分别说明了什么
对照 A.1.3 的第 5、6 步来看,第 5 步那次 stat <mnt>/a/b/c 失败时,会看到:
1 | [getattr] stale ino=4 -> ENOENT |
这说明第二次 direct stat /a/b/c 时,内核仍然先走到了旧 inode 4。问题不是“新对象不存在”,而是路径还在沿用旧的“名字 -> 对象”绑定。
A.1.3 第 6 步执行 ls -l <mnt>/a/b 时,会看到:
1 | [readdirplus] ino=3 off=0 |
这里 ino=3 就是 /a/b。ls -l /a/b 触发 readdirplus 之后,内核重新拿到了父目录 /a/b、子项名字 c,以及 c 当前对应的新 entry 信息,所以后续 direct stat /a/b/c 就能切到新对象。
同一步之后还会看到:
1 | [forget] ino=4 nlookup=1 |
这说明旧 inode 4 并不是一 remount 就立刻消失,而是先继续留在内核手里,等 ls -l 把新的 c -> inode 5 重新交回来之后,旧 inode 4 才被 FORGET。
A.3.4 为什么错误写法一定会错
这里和正文 6.3 是同一个道理:notify_inval_entry(parent, name) 只认“直接父目录 inode + 单个 entry name”。所以 demo 里的 kRoot + "a/b/c" 不会命中 /a/b 下面的 c;要命中它,就得用 kB + "c"。
附录 B:这次问题相关的 nydusd mount/umount 调用链
正文里只放了最短修复链。这里把 mount 和 umount 都展开一次。
B.1 HTTP 请求如何进入 MountHandler
nydusd 的 /api/v1/mount 入口不是直接跳到真正的挂载逻辑,而是先经过 HTTP handler。
这段流程大致是:
1 | HTTP request |
这里最值得记住的是 kicker。
它不是普通的“下一层函数调用”,而是把 ApiRequest 送到 API server 线程,再同步等结果回来。
所以源码里看起来像“handler 往下调了一层”,实际底下跑的是“HTTP 线程 -> channel -> API server 线程”这一套同步请求。
B.2 mount 主路径:从 HTTP 入口到 Vfs::mount()
mount 主路径大致是:
1 | POST /api/v1/mount |
FsService::mount() 的工作重点是:
- 根据请求参数构造 backend,也就是这次要挂进去的那棵镜像树
- 交给
Vfs::mount()把路径挂上去 - 维护 backend collection 和
upgrade manager等外围状态。这里的upgrade manager是跟后端升级流程相关的外围组件,这次 bug 不靠它触发
Vfs::mount() 这一步最重要的是:
把这条 API 镜像子路径的每一级组件挂进 pseudo 层,并让最后那个 mountpoint root 变成从 pseudo 层进入 backend root 的那道边界。
这也就是为什么正文里说 mountpoint root 不只是“一个普通目录”,而是一个边界 entry。
B.3 umount 路径:问题真正落在哪
umount 主路径大致是:
1 | DELETE /api/v1/mount |
这里和正文最相关的是 FsService::umount() 的顺序:
- 先根据这条 API 镜像子路径找到 backend 和
fs_idx。这里的fs_idx可以先理解成这个 backend 在内部表里的索引/编号 - 如果当前是 FUSE,且 backend 是 RAFS
- 取
root_ino - 找
mountpoint_name - 找
mountpoint_ino - 找
mountpoint_parent_ino - 调
walk_and_notify_invalidation()
- 取
- 然后才真正执行
get_vfs().umount(&cmd.mountpoint) - 再维护 backend collection 和
upgrade manager等外围状态
这说明这次修复不在“卸载那棵镜像树的主逻辑”里,而是在:
卸载前,如何把这条镜像子路径入口的结构变化准确通知给内核。
B.4 为什么问题会落到 walk_and_notify_invalidation()
这一步会同时对相关 inode 和 entry 发 invalidation。正文里一直强调目录项失效通知,是因为这次错的不是“没发通知”,而是 mountpoint 根目录这一层的 notify_inval_entry 参数没有对上实际结构。
B.5 为什么这条调用链和正文 demo 说的是一回事
参数对照直接看正文 6.3,这里只保留对应关系:
| demo | nydusd |
|---|---|
/a/b/c |
mountpoint root |
kB + "c" |
mountpoint_parent_ino + mountpoint_name |
| 切 inode 4 -> 5 | remount 后切到新的 RAFS root |
remount_bad |
修复前的 FsService::umount() |
附录 C:为什么默认 nydus-snapshotter 通常不受影响
这部分只回答一个很自然的追问:
如果这个问题这么严重,为什么原版
nydus-snapshotter默认使用时一直没大规模暴露?
如果第一次见到这个名字,可以先把 nydus-snapshotter 理解成容器运行时这一侧负责准备 Nydus 挂载和快照的组件。
这里的结论是:
- 不是因为 snapshotter 完全不用 FUSE
- 也不是因为 snapshotter 完全没有 pseudo 层这套挂载路径骨架
- 而是因为默认拓扑绕开了这次问题的触发条件
核心原因是默认 daemon_mode = "dedicated"。这里的 dedicated 可以先理解成:一个镜像实例通常由一个独立的 nydusd 单独服务;下面提到的 daemon 私有 FUSE 挂载根,就是这个独立 daemon 自己真正挂到宿主机上的那层目录。
这意味着:
- 一个 RAFS instance 对应一个 dedicated
nydusd - daemon 私有 FUSE 挂载根通常就是这个 snapshot 自己的
.../snapshots/<id>/mnt - 实例直接挂在这个私有 FUSE 挂载根上
所以默认拓扑更接近:
1 | 一个 snapshot |
而不是:
1 | 一个长期存在的共享父目录 |
我们这次的 bug 恰好最容易出现在后者,也就是共享父目录下反复增删 pseudo 子挂载的拓扑里。也就是说,这里说的“daemon 私有 FUSE 挂载根”和正文主线里的 mountpoint root 不是一层东西。所以这不是说 snapshotter 绝对不可能受影响;只是默认 dedicated 拓扑,已经足以解释“为什么原版默认使用里一直没把这个问题踩得很明显”。
附录 D:同一父目录下有数十个子项时,恢复为什么会分叉
正文前面故意只讲了最小场景:一个 mountpoint root,足够把修复点说明白。
这一附录补的是更接近真实环境的版本:同一父目录下面同时有数十个子项时,为什么“读过父目录以后会不会恢复”自己还会继续分叉。
为了不把真实系统细节和目录读取背景搅在一起,下面还是先从 FUSE demo 讲起,最后再映回 nydusd。
D.1 目录读取的三层视角
这一节先不提 nydusd,也还不急着进入多子项 demo 本身。先把后面一定会用到的目录读取背景讲清楚,再去看 demo 里的分叉现象。
这里先把“读父目录”具体化一下。本文这里说的“读目录”,就是这类最普通的代码:
1 | DIR *dir = opendir(parent_dir); |
也就是说:
opendir()是把父目录打开readdir()是一条一条把目录项名字往外拿
如果只看应用程序这一层,很容易把很多事混在一起。更顺的看法是把同一次目录读取拆成三层:
- 应用程序这一层,看到的是 glibc 的
readdir() strace这一层,最容易看到的是系统调用getdents64()- FUSE daemon 这一层,真正处理的是
readdir()或readdirplus()回调
先看中间这层。getdents64() 的函数声明是:
1 | ssize_t getdents64(int fd, void *dirp, size_t count); |
它往用户态缓冲区里放的目录项记录,常见的样子可以先近似理解成:
1 | struct linux_dirent64 { |
这些字段在本文里最值得记住的是:
d_name目录项名字d_ino这条目录项最后对应的 inode 号d_type目录项类型,比如目录、普通文件d_off下次继续读目录时要从哪里接着读d_reclen这一条目录项记录在缓冲区里占多少字节
再看 FUSE daemon 这一层。在前面的最小 demo 里,因为 ll_init() 里强制打开了 readdirplus,所以目录读取最终会落到 ll_readdirplus()。readdirplus 比普通 readdir 多出来的,不只是把名字列出来,而是把“名字当前对应谁、属性是什么”也一起交给内核。
把三层顺着连起来时,最容易漏掉的一点是:应用程序里多次 readdir(),经常只是在消费同一次 getdents64() 先装回来的那批目录项。
可以先看这个更贴近实际的连线图:
1 | 应用程序 / glibc |
这里故意没有把“一次 getdents64()”和“一次 FUSE readdirplus()”硬画成 1:1。本文真正要抓住的是上面这层关系:
- 多次
readdir(),常常只是在消费同一次getdents64()的结果 - 只有 glibc 手里这一批目录项快用完时,才会再发下一次
getdents64()
所以:
- 应用程序看到的是
readdir() strace看到的是getdents64()- demo 日志里看到的是
[readdirplus]或[readdir]
三边其实是在看同一条目录读取链,只是站位不同。
把这条链先记住,下一节再回头看多子项 demo 时,就不会把“应用程序读了几次目录”“glibc 补了几次缓冲区”“FUSE 侧到底收到了什么回调”这三件事混成一团。
D.2 多子项 demo 比前一个 demo 多了什么
有了上一节那条目录读取链,再回头看这个多子项 demo,就比较容易定位“新加的复杂度到底在哪里”了。其实它相比正文前面那个最小 demo,核心只多了三处。
第一处是 /a/b 不再只有一个目标子项,而是先挂上一大批稳定子项,再把目标子项放到最后。
可以先看目录结构对比:
1 | 前面的最小 demo |
代码上对应的是这两段。第一段先造出一大批稳定子项:
1 | constexpr uint32_t kDefaultStableChildren = 512; |
第二段在列 /a/b 时,先把这些稳定子项依次吐出来,最后才放目标子项:
1 | if (ino == kB) { |
第二处是它不再只注册 readdirplus,而是把 readdir 和 readdirplus 都注册进去。这样我们才能直接看到“后半段到底还是不是 readdirplus”。
1 | void ll_readdir(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, fuse_file_info*) { |
第三处是把目录读取模式做成了可切换。我们可以强制始终走 readdirplus,也可以允许内核自动退回普通 readdir:
1 | enum class ReaddirMode { |
但有一点故意没有改:它保留了和正文同一类错误。也就是说,问题不是“多子项 demo 写错了别的地方”,而是仍然故意把 entry invalidation 发到了错误的位置:
1 | const int entry_rc = fuse_lowlevel_notify_inval_entry(g_session, kRoot, "a/b/target", 10); |
正确写法应该还是“直接父目录 inode + 单个 entry name”,也就是这里的 kB + "target"。
到这里为止,目录形状、回调注册和模式切换这三个新增点都齐了。下一节就只盯其中第三点,也就是 READDIRPLUS_AUTO 到底会怎么让同一次父目录读取分成前后两段。
D.3 READDIRPLUS_AUTO 的自动切换规则
上一节已经把多子项 demo 的“新增零件”摆出来了,这一节只抽其中最关键的一件事:READDIRPLUS_AUTO 到底怎么工作。
这一节继续只讲 FUSE 本身,不提 nydusd。
前面的最小 demo 是强制总走 readdirplus。但多子项 demo 不是这样,它会切换两种模式:
- 一种是始终走
readdirplus - 一种是允许内核自动在
readdirplus和普通readdir之间切换
这个“允许自动切换”的开关叫 READDIRPLUS_AUTO。可以先把它理解成一句白话:同一次父目录读取里,前半段和后半段不一定是同一种 FUSE 回调。
libfuse 头文件里对 Linux 4.20 起的算法有一段很直接的说明。翻成白话就是:
- 开始读目录时,先发一次
READDIRPLUS - 到下一批目录项时,如果前面这些 entry 的属性已经被用到了,就继续
READDIRPLUS - 否则就退回普通
READDIR - 所以不带
-l的ls往往是先READDIRPLUS再READDIR ls -l往往会一直READDIRPLUS
如果把这个自动切换规则压成图,大致就是这样:
1 | always 模式 |
这里提不带 -l 的 ls 和 ls -l,只是借文档把自动切换策略讲清楚。后面这一节真正下结论,还是以下面的 demo 结果为准。
这一点对后面理解“为什么同样是读父目录,恢复却会分叉”非常关键。
换句话说,上一节让我们知道“这个 demo 能看到前后两段”,这一节则说明“前后两段为什么可能不是同一种目录读取”。下面才回头看现象本身。
D.4 FUSE demo 里的分叉现象
前两节已经把工具和规则都补齐了,下面才真正回到多子项 demo 的现象本身。
为了把这个现象和 nydusd 拆开,我们先看单独的多子项 FUSE demo。
这个 demo 仍然保留正文里同一类错误:它会把目标子项切到新 inode,但故意发错那条 entry invalidation。现在唯一新增的变量,是父目录下面不再只有一个子项,而是有数十个兄弟子项。
到了这种场景,现象会比正文多出一层:
- 有的目标子项,只要把父目录顺着读一遍名字,就能恢复
- 有的目标子项,光读名字还不够,必须像
ls -l那样继续对子项取属性,才会恢复
下面把“我们正在观察它会不会恢复的那个子项”统一叫目标子项。
为了不让业务名分散注意力,下面只保留两个代表性样本:
- A 镜像:目标子项排在前半段,读父目录后很快就能恢复
- B 镜像:目标子项排在后半段,光读父目录名字不够,恢复不了
后面会反复提到的几个动作,也先翻成白话:
opendir_readdir:先打开父目录,再顺着把名字读一遍,最后回头stat目标子项opendir_readdir_lstat_target:读到目标子项时,只额外对这个目标子项做一次lstatopendir_readdir_lstat_all:把读到的每个子项都再做一次lstat,最接近正文前面说的ls -lscandir_only:只做目录扫描,不逐个子项补lstat
可以先把“前半段”和“后半段”画得更直白一点:
1 | /a/b |
这一节的作用,只是把“分叉确实存在”这件事先看清。下一节再把它压成结果表,方便把“位置差异”和“动作强弱”放在同一张图里对照。
D.5 FUSE demo 的结果表
带着上一节的样本和动作定义,再把这组现象压成表,就比较容易比较了。
| 样本 | 位置证据 | 父目录动作 | 结果 |
|---|---|---|---|
| A 镜像 | 目标子项排在前半段 | 打开父目录后顺着读一遍名字(opendir_readdir) |
成功 |
| B 镜像 | 目标子项排在后半段 | 打开父目录后顺着读一遍名字(opendir_readdir) |
失败 |
| B 镜像 | 同上 | 读到目标子项时只额外 lstat 它一次(opendir_readdir_lstat_target) |
失败 |
| B 镜像 | 同上 | 只做目录扫描(scandir_only) |
失败 |
| B 镜像 | 同上 | 对读到的每个子项都补一次 lstat,也就是更接近 ls -l(opendir_readdir_lstat_all) |
成功 |
这张表要说明的核心只有一句:
同一个父目录下子项一多,就会出现一种很直观的分叉:有的目标子项,顺着把父目录名字读一遍就能恢复;有的目标子项,光读名字不够,必须像
ls -l那样继续对子项取属性,才会恢复。
但到这里还只能看见“分叉存在”,还没把它和上一节那条自动切换规则直接连上。所以下面还要再加一组对照:如果强制整个目录读取都走 readdirplus,结果会不会跟着变。
为了确认这是不是 READDIRPLUS_AUTO 造成的分叉,我们又做了同一个错误模式下的两组对照:
| demo 模式 | 父目录动作 | 结果 |
|---|---|---|
一直强制 readdirplus |
opendir_readdir |
成功 |
允许自动退回普通 readdir |
opendir_readdir |
失败 |
允许自动退回普通 readdir |
opendir_readdir_lstat_all |
成功 |
这个表说明的意思很直接:不是“只要读过父目录就够了”,而是“后半段到底还在不在 readdirplus 模式里”会影响恢复强弱。
到这里,现象、样本表和 always/auto 对照已经都摆齐了。下一节就不再扩样本,而是把 D.3 的文档规则和这一节的结果表直接对起来看。
D.6 为什么会在这里分叉
现在再回头看 D.3 那条 FUSE 文档规则,主线就清楚了。
文档说的是:
- 开始读目录时,先发一次
READDIRPLUS - 下一批如果前面这些 entry 的属性已经被用到,就继续
READDIRPLUS - 否则就退回普通
READDIR
而我们的 demo 对照又说明:
- 一直强制
READDIRPLUS时,opendir_readdir就能恢复 - 允许自动退回普通
READDIR时,opendir_readdir会失败 - 同样是自动模式,改成
opendir_readdir_lstat_all之后又能恢复
所以这一节最该记住的结论其实很简单:
这里之所以会分叉,关键不是“名字有没有被读到”,而是后半段目录读取有没有从
READDIRPLUS退回普通READDIR。
demo 自己的日志也能把这个变化直接看出来:
- 自动模式下,前一段能看到
readdirplus off=0 - 到后一段时,会变成
readdir off=... - 强制模式下,从头到尾都还是
readdirplus
如果一定要再往下补一层,才轮到 getdents64()。它在这里的意义主要是帮我们区分“目标子项落在较早的一批,还是较晚的一批”。一旦目标子项落在较晚那一批,就更容易碰到上面这条文档说的自动降级。
所以这一节真正补出来的新点是:
- 后半段更容易分叉,不是因为“后面那批目录项根本没读到”
- 而是因为目标子项落到后半段以后,那一段目录读取更可能已经从
readdirplus退回普通readdir - 这时光把名字读出来还不够,往往还得像
ls -l一样继续对子项取属性,恢复才更稳定
如果要看更细的内核对象生命周期,FORGET 仍然是有价值的旁证。但对这节主线来说,它不是最该先抓住的东西,所以这里只点到为止,不再把它当主解释线。
FUSE demo 这一侧,到这里其实已经闭环了:背景链有了,自动切换规则有了,分叉现象和结果表也有了。最后只剩一步,就是把这套关系代回真实 nydusd。
D.7 映射回 nydusd
到了这里,再把它映射回真实 nydusd 就顺了。这一步不是换一个根因,而是把前面已经在 FUSE demo 里看清的那套关系,放回真实系统的共享父目录场景里。
附录 D 不是在讲另一个根因。根因仍然是正文说的那条错误 invalidation。附录 D 只是把真实复杂场景补齐:同一父目录下子项一多,目标子项又落到后半段时,目录读取更可能已经退回普通 readdir,这会让“读过父目录就恢复”进一步分成强弱两档。
换句话说:
- 正文解释的是:为什么 mountpoint root 那一层会在 remount 之后暂时指错对象
- 这一节补充的是:当同一父目录下有很多子项时,为什么“父目录访问能不能把它救回来”还会继续分层
所以真实 nydusd 场景里,靠前的子项有时读一遍名字就够;靠后的子项,往往要像 ls -l 那样继续对子项取属性,才更稳定地把旧对象送走。