从最小 FUSE 复现到 nydusd umount 修复:一次挂载点失效通知没有发到正确位置的问题
太长不看版
这个问题发生在 nydusd 重新挂载镜像的时候:第一次挂载后路径能访问;卸载后再挂回同一条路径,直接访问有时会提示“不存在”。但如果先对它的父目录执行一次 ls -l,再访问原来的路径,它又能恢复。
ls -l 这里不是普通的“看一眼目录”。它会在读父目录名字的同时,继续读取子项属性;这会让内核重新拿到当前的“这个名字现在对应哪个对象”。这部分机制正文会先用一个最小 FUSE demo 展开。
根因是卸载时,nydusd 需要告诉内核:“这个父目录下面的某个名字已经变了。”原来的代码通知发错了位置,内核还记着旧的名字关系,所以直接按路径访问时会走到旧状态。修复就是把通知发到正确位置:不要把整条挂载路径当成一个名字,而是用“直接父目录 + 最后一级名字”去通知内核。
正文的走法是:先看现象和一个最小 FUSE demo,再回到 nydusd 的真实路径结构和源码修复。
如果第一次读到 FUSE,可以先把它理解成:文件系统逻辑主要跑在用户态,内核通过一套接口和它配合。
先看 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结果?
如果这个问题不先讲清楚,后面就很难判断修复到底是碰巧好了,还是确实打到了点上。
先补最少但够用的 FUSE 背景
这一节只补后面会反复用到的三个点:按路径访问会碰到名字缓存和对象缓存,ls -l 会触发更强的目录读取,umount 以后旧状态不会自动同时消失。
按路径访问时,内核其实会碰到两层缓存
先只看最常见的按路径访问。
如果一个 FUSE 文件系统已经挂上去了,内核之后再读 /a/b/c 这种路径时,可以先粗略理解成两步:
- 按路径一级级往下找名字,也就是先看
/下面有没有a,再看a下面有没有b,最后看b下面有没有c - 找到对象以后,再继续取属性,或者继续做目录读取、打开文件之类的动作
这两步正好对应这次问题里最相关的两层:
dentry:名字缓存。更接近“在某个父目录下面,有个叫这个名字的项,它现在指向谁”inode:对象本体。更接近“这个文件或目录自己是什么”
也就是说,按路径访问 /a/b/c 时,内核其实既会碰到“b 下面 c 现在指向谁”,也会碰到“这个 c 自己是什么”。
这两层不是一回事。如果名字这一层还指着旧对象,而对象那一层又已经变了,路径访问就可能显得很怪。
读父目录时,readdirplus 会把“名字和对象”一起带回来
上面那条链只解释了“直接按路径访问”时,内核为什么会同时碰到名字和对象两层。
但这篇文章里真正把路径救回来的,不是再试一次 stat /a/b/c,而是先做了一次 ls -l /a/b。这时内核碰到的就不再是单条路径查找,而是目录读取。
这里本文只需要知道 readdirplus 就够了。它不是 glibc 的 readdir(),而是 FUSE daemon 侧的目录读取回调;用户在 shell 里执行 ls、ls -l 这类操作,经过内核以后,可能在 FUSE daemon 侧落到这个回调上。可以先把它理解成:
读目录项名字的同时,顺手把这些名字当前对应的对象信息也一起交给内核。
它比“只把名字列出来”更强,因为它不只是说“这里有这个名字”,还会顺手说“这个名字现在对应哪个对象”。
本文前半段只需要先记住这个对照:
stat path:直接按完整路径解析到目标,再取目标属性。ls parent:主要是打开父目录并读出名字。ls -l parent:除了读名字,还会继续对子项取属性。
所以从这一步开始,前面的两层就和目录读取接起来了:
- direct path 访问,更像是在按路径一级级找名字,再去碰对象
ls -l这种更强的父目录查看,则会通过readdirplus把“名字 + 当前对象”一起交回来
正文先用最小 demo 里的简化模型讲清“为什么 ls -l 能恢复”。真实系统里,目录读取还有一层自动切换策略,普通 ls 和 ls -l 的差别会更细;这部分放到同一父目录下有数十个子项时,恢复为什么会分叉里单独展开。
umount 以后,旧状态为什么还可能留着
再往前走一步,就会碰到这次问题真正绕的地方:明明已经 umount 了,为什么旧状态还会继续影响后面的路径访问?
先按这个通用场景,可以把 umount 粗略理解成:
内核先不再把这个文件系统入口当成当前有效入口。
但这不等于:
这条路径下面曾经见过的所有名字缓存和 inode 缓存,会在同一瞬间全部消失。
真正会牵扯到旧状态怎么退场的,恰好就是本文后面会反复碰到的这一组东西:
notify_inval_inode可以先理解成:这个 inode 自己的属性缓存过期了。notify_inval_entry(parent, name)可以先理解成:这个父目录下面、名字叫name的目录项缓存过期了。这里的name是直接子项名,也就是路径最后一级名字,不是整条路径。lookup count可以先理解成:内核手里还记着这个 inode 几次。lookup、create、readdirplus这些动作都可能把这个计数加上去。FORGET可以先理解成:内核以后不用这个 inode 了,把之前记的这部分引用还回来。
把它们放回前面那条线里看,就比较容易理解了:
umount以后,旧的“名字 -> 对象”关系和旧 inode 不会自动同时消失- 用户态文件系统还得把该失效的名字缓存、对象缓存准确告诉内核
- 如果只让对象这一层过期了,但名字这一层没处理对,后面的路径访问仍然可能沿着旧名字状态走
这一节只需要记住的最小模型
把这次问题压到最简单,就是这三层:
1 | parent directory |
后文只会反复用这张图里的一个区别:direct path 访问更像是沿着现成的“名字 -> 对象”关系一路往下走;ls -l 这类父目录查看,则会通过目录读取把当前名字和对象关系再交给内核看一遍。
再用一个最小 FUSE demo 把它复现出来
最小目录树和复现流程
这个 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可以先简单记成:这条目录项失效通知没命中目标;如果熟悉 Linux errno,也可以把它看成ENOENT这一类“没找到”。第二次
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结果:成功。路径恢复。
代码里真正关键的地方
这个 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); |
这个 low-level API 的形状可以先记成:
1 | notify_inval_entry(session, 直接父 inode, 单个名字, strlen(name)) |
也就是说,name 只能是直接子项名,不能带 /,也不能传整条路径。下面这两个调用里,本文真正要关心的是第二个参数 parent inode 和第三个参数 name;最后的 5 / 1 只是名字长度,不是这次问题的主因。
而正确写法应该是:
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
目录项失效通知要发到正确位置
这个 demo 的结论很小:光把旧 inode 作废还不够,“父目录下面这个名字”也要按正确的父目录和名字去失效。否则 direct path 还会沿着旧名字状态走,直到 ls -l 重新把当前名字和对象关系交回内核。
下面把这个结论放回真实场景,看它为什么正好对应 nydusd 的修复。
把 demo 映回 nydusd:真实路径、两层树和 inode 边界
前四节故意没有进入真实系统。现在才把真实背景补上。
这里的 Nydus 可以先理解成一种按需把镜像内容提供出来的镜像文件系统方案,nydusd 则是它的用户态守护进程。
真实路径会比较长,所以先用一条短路径把层次摆出来:
1 | 宿主机 FUSE 挂载根: /data/nydus-images |
后面真实路径、挂载点和最后一级入口各指什么和修复前后到底差在什么地方反复出现的几个词,可以先这样记:
| 名字 | 在短路径例子里对应什么 | 这一层属于谁 |
|---|---|---|
| 宿主机 FUSE 挂载根 | /data/nydus-images |
Linux 真正挂上的 FUSE 根 |
API mountpoint 子路径 |
/repo/model/v1 |
nydusd API 里的镜像挂载子路径 |
| mountpoint 最后一级入口 | 最后一级 v1 |
PseudoFs 里的边界目录项 |
mountpoint_parent_ino |
model 这一层的 inode |
PseudoFs 里的直接父目录 inode |
Rafs root_ino |
v1 背后那棵 Rafs 内容树的根 inode |
Rafs 自己的 root inode |
这里先特别提醒一句:mountpoint 最后一级入口和 Rafs root_ino 不是同一个东西。前者是 PseudoFs 里的边界目录项,后者是挂在这个目录项后面的 Rafs root inode。
把短路径画成树,大概是这样:
1 | /data/nydus-images # Linux 里真正挂上的 FUSE 挂载根 |
带着这张短图,再看真实例子会轻一点。如果把 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 |
后面真实路径、挂载点和最后一级入口各指什么和修复前后到底差在什么地方说的那些 mountpoint、mountpoint 最后一级入口、mountpoint_parent_ino,都默认围绕这一个真实例子来讲。你可以把它和前面的短路径这样对上:
1 | /repo/model/v1 |
真实路径、挂载点和最后一级入口各指什么
还拿刚才这条完整路径继续说。它可以直接拆成两段:
/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.14- mountpoint 最后一级入口 指最后一级
chengyue_hygame_cover_v0.0.14,也就是这条子路径在PseudoFs里的那个入口 mountpoint_parent_ino指这条子路径直接父目录,也就是machine-learn那一层对应的 pseudo inode
一句话记忆:mountpoint 是整条 API 子路径,最后一级入口是它的最后一级名字,mountpoint_parent_ino 是这个最后一级名字的直接父目录 inode。
这里真正要分清的是 Rafs 和 PseudoFs
nydusd 做的不是“把一张镜像直接摊出来”,而是“把多张 Rafs 内容树按挂载路径拼到一棵总树上”。后面真正要分清的是两层对象:
Rafs:单张镜像自己的真实内容树PseudoFs:把多个挂载点串起来的路径骨架
这里不用把 Vfs 单独当成一棵树来记。它更像连接层:沿着 PseudoFs 走路径,走到 mountpoint 最后一级入口时,再切到对应的 Rafs 内容树。内核最终看到的是这两层接起来后的结果。
最后一级入口为什么正好卡在边界上
假设 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这边 - 走到
chengyue_hygame_cover_v0.0.14之前,看到的还是PseudoFs里的路径骨架;命中这一层以后,才切到对应的 Rafs root,所以下面的app/bin/etc/lib已经是镜像内部内容
所以 mountpoint 最后一级入口不是一个普通目录名,它是一个边界点:
- 在它上面,路径解析还在
PseudoFs - 穿过它之后,路径解析进入真正的 Rafs 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,也不是镜像内部某个普通子目录,而是这条镜像子路径最后一级名字本身。
这里的 inode 为什么还要分清来源
到了修复前后到底差在什么地方看修复代码时,最容易混掉的是 inode 的来源。这里至少要分清三种:
pseudo inode:属于PseudoFs,表示路径骨架上的节点Rafs inode:属于具体那张 Rafs 内容树- 内核最终看到的 inode:发给内核时使用的 inode
先看最不容易误会的两种:
machine-learn、chengyue_hygame_cover_v0.0.14这种挂载路径骨架节点,用的是pseudo inode- 镜像内部
/、/app、/bin这些真实对象,用的是Rafs inode
真正容易绕的是第三种。因为对 Rafs 来说,不同镜像里都可能有 inode 1、2、3。所以发给内核时,不能只给裸 Rafs inode,还要带上这张 Rafs 的身份编号 fs_idx。你可以先把它记成:
1 | 内核看到的 inode = (fs_idx << 56) | Rafs inode |
这里顺手强调两个不要混的点:
- 不是把
pseudo inode和Rafs inode直接拼成一个 inode pseudo inode继续服务于挂载路径骨架;真正要编码给内核的,是 Rafs 身份fs_idx和Rafs inode
把这三个量代回本文会更清楚:
mountpoint_parent_ino:mountpoint 直接父目录的pseudo inodemountpoint_ino:mountpoint 最后一级入口自己的pseudo inode,主要是为了继续找到它的父目录root_ino:这张 Rafs 内容树自己的 root inode- 后面递归 walk Rafs 子树时,真正发给内核看的,则是带了
fs_idx的 inode
所以这次修复为什么卡在 mountpoint 最后一级入口,也就能说得更直白了:
- 对父目录名字关系来说,mountpoint 最后一级入口还是
PseudoFs里的一个目录项 - 对穿过这个目录项之后的真实内容来说,它背后又已经是另一张 Rafs 内容树的 root inode
一旦这层边界的目录项失效通知没打准,就会出现前面那个现象:Rafs 内容树其实已经 remount 到新对象了,但 direct path 还沿着旧名字状态走;做一次 ls -l 父目录后,内核才重新认识这个边界点。
nydusd 修复到底改了什么
现在终于可以回到真实修复点。
这一节会出现的几个内部名字各指什么
这一节会出现几个内部名字,先把它们和前面的真实场景对应起来:
kicker是把请求送到 API 线程并等结果回来的地方walk_and_notify_invalidation()是最后真正去发失效通知的地方MountHandler是接 HTTP 请求的那层ApiRequest::Mount / ApiRequest::Umount是投递给 API 线程的请求对象FsService是真正处理挂载和卸载的服务层
如果只跟这次 bug 的主线,先盯住 FsService::umount() 和 walk_and_notify_invalidation() 就够了;其他名字先当调用链背景。
如果想看每个节点在代码里具体做了什么,后面可以直接跳到HTTP 请求如何进入 MountHandler和为什么问题会落到 walk_and_notify_invalidation()。
这次修复相关的最短调用链
只看和这次修复直接相关的调用链,大致是:
1 | POST/DELETE /api/v1/mount?mountpoint=... |
真正决定修复是否有效的地方,不在 mount 阶段那棵镜像树怎么准备,而在 umount() 里失效通知的参数到底怎么传。
修复前后到底差在什么地方
这次修复里最关键的是目录项失效通知的最后一对参数:直接父目录 inode + 最后一级名字。
这里的 inode 为什么还要分清来源已经把两层树和几种 inode 来源铺好了。这里直接代回修复代码:cmd.mountpoint 不是 /data/nydus-images 这种宿主机 FUSE 挂载根,而是 nydusd API 里的镜像挂载子路径;mountpoint_ino 只是用来找到它的直接父 pseudo inode;root_ino 指 Rafs root,不是前面说的 mountpoint 最后一级入口;fs_idx 服务于后面对 Rafs 子树的递归通知。
修复前,逻辑等价于:
1 | self.walk_and_notify_invalidation( |
也就是:
parent = ROOT_IDname = 整条 API mountpoint 子路径去掉开头的 /
把本文这个真实例子代进去,修复前大概就是:
1 | ROOT_ID + "harbor-yctest.huya.info/harbor_leaf_nydus/machine-learn/chengyue_hygame_cover_v0.0.14" |
修复后,逻辑变成了:
1 | let mountpoint_name = Path::new(&cmd.mountpoint) |
说白了,这段代码就是把:
ROOT_ID + 整条 API mountpoint 子路径
修成了:
直接父 pseudo inode + 最后一段名字
代回真实例子,就是:
1 | ino(machine-learn) + "chengyue_hygame_cover_v0.0.14" |
这和前面 demo 的那组对照是一回事:
| demo | nydusd 真实场景 |
|---|---|
/a/b |
API 子路径的直接父目录,也就是 .../machine-learn |
c |
API 子路径最后一级 chengyue_hygame_cover_v0.0.14 |
kB + "c" |
mountpoint_parent_ino + mountpoint_name |
这次改动只影响哪一层
这次改动只发生在 umount 阶段,修的是那条镜像子路径最后一级入口的目录项失效通知参数:把“整条 API mountpoint 子路径”改成“直接父 inode + 最后一级名字”。所以它改的不是 /data/nydus-images 这个宿主机 FUSE 挂载根,也不是 Rafs 内容树本身,而是 Rafs 已经换了以后,内核为什么还会短时间沿着旧名字状态走。
附录:FUSE demo 完整档案
环境、构建与复现
实际验证环境
下面这套环境上已经跑通了完整复现链:
- 内核:
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。
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 本身请按你自己的系统和习惯自行准备。
运行与清理命令
下面是一组最直接的运行方式。
下面把挂载点路径统一记成 <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 |
当前这份精简 demo 的复现结果
按运行与清理命令里的步骤跑下来,结果和最小目录树和复现流程一致:第一次 stat <mnt>/a/b/c 成功,remount_bad 之后第二次 stat <mnt>/a/b/c 失败,执行 ls -l <mnt>/a/b 之后第三次 stat <mnt>/a/b/c 恢复成功。
完整源码
下面给出本文讨论的完整 demo 源码。
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 需要回给内核的目录项和属性填好。
第三块是“真正对 FUSE 请求负责的回调”:
ll_init()ll_lookup()ll_getattr()ll_opendir()ll_readdirplus()ll_forget()
这一块就是内核真正会打进来的入口。
第四块是“主动制造问题的控制路径”:
remount_bad()control_thread()
它不来自内核,而是来自我们手工输入给 demo 进程的 remount_bad 命令。
第五块才是“把一切接起来的启动代码”:
main()
这一块负责注册回调、挂载 FUSE、启动 stdin 控制线程,然后进入主循环等内核请求。
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,并故意发错那条目录项失效通知 - 内核侧再继续发
getattr/readdirplus/forget - 我们就能看到“direct
stat失败,ls -l后恢复”的完整链路
如果再按回调作用各看一眼,这个 demo 的职责分工其实很直:
ll_init()在连接建立时打开FUSE_CAP_READDIRPLUS,并关闭自动切换开关READDIRPLUS_AUTO,强制目录读取走readdirplus。这个自动切换开关会在READDIRPLUS_AUTO的自动切换规则里专门解释。ll_lookup()负责名字解析,也就是“某个父目录下面这个名字当前指向哪个 inode”。ll_getattr()负责属性查询;如果打到了旧 inode4/5里已经过时的那个,会打印 stale 日志并回ENOENT。ll_opendir()负责目录打开,本例里只做最小的“活着就给开,不活就报错”。ll_readdirplus()负责目录枚举,并把目录项和属性一起回给内核。这是ls -l /a/b能把路径救回来的关键。ll_forget()负责接收内核还回来的引用,也就是我们在日志里看到的[forget]。
第 5、6 步的日志补充
最小目录树和复现流程已经把失败和恢复流程串起来了。这里只补几行日志怎么读。
运行与清理命令里的第 5 步那次 stat <mnt>/a/b/c 失败时,会看到:
1 | [getattr] stale ino=4 -> ENOENT |
这说明第二次 direct stat /a/b/c 仍然先走到了旧 inode 4。问题不是“新对象不存在”,而是路径还在沿用旧的“名字 -> 对象”绑定。
运行与清理命令里的第 6 步执行 ls -l <mnt>/a/b 时,会看到:
1 | [readdirplus] ino=3 off=0 |
这里 ino=3 就是 /a/b。ls -l /a/b 触发 readdirplus 后,内核重新拿到了子项名字 c 以及它当前对应的新目录项信息,所以后续 direct stat /a/b/c 能切到新对象。
同一步之后还会看到:
1 | [forget] ino=4 nlookup=1 |
这说明旧 inode 4 不是一 remount 就立刻消失,而是等 ls -l 把新的 c -> inode 5 交回来之后,才被内核通过 FORGET 还回来。
为什么错误写法一定会错
这里和修复前后到底差在什么地方是同一个道理:notify_inval_entry(parent, name) 只认“直接父目录 inode + 单个名字”。所以 demo 里的 kRoot + "a/b/c" 不会命中 /a/b 下面的 c;要命中它,就得用 kB + "c"。
附录:这次问题相关的 nydusd mount/umount 调用链
正文里只放了最短修复链。这里把 mount 和 umount 都展开一次。
HTTP 请求如何进入 MountHandler
nydusd 的 /api/v1/mount 入口不是直接跳到真正的挂载逻辑,而是先经过 HTTP handler。
这段流程大致是:
1 | HTTP request |
这里最值得记住的是 kicker。
它不是普通的“下一层函数调用”,而是把 ApiRequest 送到 API server 线程,再同步等结果回来。
所以源码里看起来像“handler 往下调了一层”,实际底下跑的是“HTTP 线程 -> channel -> API server 线程”这一套同步请求。
mount 主路径:从 HTTP 入口到 Vfs::mount()
下面这段调用链里,源码变量有时会叫 backend。为了不让读者多记一层概念,本文仍然统一叫它 Rafs 内容树;只有引用源码原名时,才在括号里补一句。
mount 主路径大致是:
1 | POST /api/v1/mount |
FsService::mount() 的工作重点是:
- 根据请求参数构造这棵 Rafs 内容树
- 交给
Vfs::mount()把路径挂上去 - 维护保存这些 Rafs 实例的内部集合(源码里叫 backend collection)和
upgrade manager等外围状态。这里的upgrade manager是跟 Rafs 实例升级流程相关的外围组件,这次 bug 不靠它触发
Vfs::mount() 这一步最重要的是:
把这条 API 镜像子路径的每一级组件挂进 pseudo 层,并让最后一级入口变成从 pseudo 层进入 Rafs root 的那道边界。
这也就是为什么正文里说 mountpoint 最后一级入口不只是“一个普通目录”,而是一个边界目录项。
umount 路径:问题真正落在哪
umount 主路径大致是:
1 | DELETE /api/v1/mount |
这里和正文最相关的是 FsService::umount() 的顺序:
- 先根据这条 API 镜像子路径找到对应的 Rafs 内容树和
fs_idx。fs_idx可以先理解成这棵 Rafs 在内部表里的索引/编号 - 如果当前是 FUSE,且对应的文件系统类型是 Rafs(源码常量名是
RAFS,不是另一层对象)- 取
root_ino - 找
mountpoint_name - 找
mountpoint_ino - 找
mountpoint_parent_ino - 调
walk_and_notify_invalidation()
- 取
- 然后才真正执行
get_vfs().umount(&cmd.mountpoint) - 再维护保存这些 Rafs 实例的内部集合和
upgrade manager等外围状态
这说明这次修复不在“卸载那棵镜像树的主逻辑”里,而是在:
卸载前,如何把这条镜像子路径入口的结构变化准确通知给内核。
为什么问题会落到 walk_and_notify_invalidation()
这一步会同时对相关 inode 和目录项发失效通知。正文里一直强调目录项失效通知,是因为这次错的不是“没发通知”,而是 mountpoint 最后一级入口这一层的 notify_inval_entry 参数没有对上实际结构。
为什么这条调用链和正文 demo 说的是一回事
参数对照直接看修复前后到底差在什么地方,这里只保留对应关系:
| demo | nydusd |
|---|---|
/a/b/c |
mountpoint 最后一级入口 |
kB + "c" |
mountpoint_parent_ino + mountpoint_name |
| 切 inode 4 -> 5 | remount 后切到新的 Rafs root |
remount_bad |
修复前的 FsService::umount() |
附录:为什么默认 nydus-snapshotter 通常不受影响
这部分只回答一个很自然的追问:
如果这个问题这么严重,为什么原版
nydus-snapshotter默认使用时一直没大规模暴露?
如果第一次见到这个名字,可以先把 nydus-snapshotter 理解成容器运行时这一侧负责准备 Nydus 挂载和快照的组件。
这里的结论是:
- 不是因为 snapshotter 完全不用 FUSE
- 也不是因为 snapshotter 完全没有 pseudo 层这套挂载路径骨架
- 而是因为默认拓扑绕开了这次问题的触发条件
核心原因是默认 daemon_mode = "dedicated"。这里的 dedicated 可以先理解成:一个镜像实例通常由一个独立的 nydusd 单独服务。和它相对的共享形态,可以先粗略理解成:多个镜像子路径挂在同一个长期存在的共享父目录下面。下面提到的 daemon 私有 FUSE 挂载根,就是这个独立 daemon 自己真正挂到宿主机上的那层目录。
这意味着:
- 一个 Rafs 实例对应一个 dedicated
nydusd - daemon 私有 FUSE 挂载根通常就是这个 snapshot 自己的
.../snapshots/<id>/mnt - 实例直接挂在这个私有 FUSE 挂载根上
所以默认拓扑更接近:
1 | 一个 snapshot |
而不是:
1 | 一个长期存在的共享父目录 |
我们这次的 bug 恰好最容易出现在后者,也就是共享父目录下反复增删 pseudo 子挂载的拓扑里。也就是说,这里说的“daemon 私有 FUSE 挂载根”和正文主线里的 mountpoint 最后一级入口不是一层东西。所以这不是说 snapshotter 绝对不可能受影响;只是默认 dedicated 拓扑,已经足以解释“为什么原版默认使用里一直没把这个问题踩得很明显”。
附录:同一父目录下有数十个子项时,恢复为什么会分叉
正文前面只讲了一个 mountpoint 最后一级入口的最小场景,已经足够说明修复点。真实环境里,同一个父目录下面可能同时有数十个子项,这时“读过父目录以后会不会恢复”还会继续分叉。
这个附录仍然先从 FUSE demo 讲起,最后再映回 nydusd。
目录读取的三层视角
先把“读父目录”具体化一下。本文这里说的“读目录”,就是这类最普通的代码:
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 侧到底收到了什么回调”这三件事混成一团。
多子项 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 写错了别的地方”,而是仍然故意把目录项失效通知发到了错误的位置:
1 | const int entry_rc = fuse_lowlevel_notify_inval_entry(g_session, kRoot, "a/b/target", 10); |
正确写法应该还是“直接父目录 inode + 单个名字”,也就是这里的 kB + "target"。
这三个新增点里,真正决定后面分叉的是第三个:自动切换开关会让同一次父目录读取分成前后两段。下一节会专门解释这个开关。
READDIRPLUS_AUTO 的自动切换规则
前面的最小 demo 是强制总走 readdirplus。但多子项 demo 不是这样,它会切换两种模式:
- 一种是始终走
readdirplus - 一种是允许内核自动在
readdirplus和普通readdir之间切换
这个“允许自动切换”的开关叫 READDIRPLUS_AUTO。可以先把它理解成一句白话:同一次父目录读取里,前半段和后半段不一定是同一种 FUSE 回调。
这里还有一个大小写约定:下面全大写的 READDIRPLUS / READDIR 是 FUSE 协议请求类型;对应到 demo 代码里的回调,就是 ll_readdirplus() / ll_readdir()。
libfuse 头文件里对 Linux 4.20 起的算法有一段很直接的说明。翻成白话就是:
- 开始读目录时,先发一次
READDIRPLUS - 到下一批目录项时,如果前面这些目录项的属性已经被用到了,就继续
READDIRPLUS - 否则就退回普通
READDIR - 所以不带
-l的ls往往是先READDIRPLUS再READDIR ls -l往往会一直READDIRPLUS
如果把这个自动切换规则压成图,大致就是这样:
1 | always 模式 |
这里提不带 -l 的 ls 和 ls -l,只是借文档把自动切换策略讲清楚;真正下结论,还是以后面的 demo 结果为准。
FUSE demo 里的分叉现象
现在看单独的多子项 FUSE demo。它仍然保留正文里同一类错误:把目标子项切到新 inode,但故意发错那条目录项失效通知。唯一新增的变量,是父目录下面不再只有一个子项,而是有数十个兄弟子项。
到了这种场景,现象会比正文多出一层:
- 有的目标子项,只要把父目录顺着读一遍名字,就能恢复
- 有的目标子项,光读名字还不够,必须像
ls -l那样继续对子项取属性,才会恢复
下面把“我们正在观察它会不会恢复的那个子项”统一叫目标子项。
为了不让业务名分散注意力,下面只保留两个代表性样本:
- A 镜像:目标子项排在前半段,读父目录后很快就能恢复
- B 镜像:目标子项排在后半段,光读父目录名字不够,恢复不了
后面会反复提到的几个动作,也先翻成白话:
opendir_readdir:先打开父目录,再顺着把名字读一遍,最后回头stat目标子项opendir_readdir_lstat_target:读到目标子项时,只额外对这个目标子项做一次lstatopendir_readdir_lstat_all:把读到的每个子项都再做一次lstat,最接近正文前面说的ls -lscandir_only:只做目录扫描,不逐个子项补lstat
可以先把“前半段”和“后半段”画得更直白一点:
1 | /a/b |
把这个现象压成表以后,位置差异和动作强弱会更清楚。
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_AUTO 连起来,还要看一组模式对照。
为了确认这是不是 READDIRPLUS_AUTO 造成的分叉,我们又做了同一个错误模式下的两组对照:
| demo 模式 | 父目录动作 | 结果 |
|---|---|---|
一直强制 readdirplus |
opendir_readdir |
成功 |
允许自动退回普通 readdir |
opendir_readdir |
失败 |
允许自动退回普通 readdir |
opendir_readdir_lstat_all |
成功 |
为什么会在这里分叉
把READDIRPLUS_AUTO 的自动切换规则里的文档规则和上面的 demo 对照放在一起看:
- 文档规则是:开始读目录时先发
READDIRPLUS;下一批如果前面这些目录项的属性已经被用到,就继续READDIRPLUS;否则退回普通READDIR - 一直强制
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 仍然是有价值的旁证。但对这节主线来说,它不是主解释线,所以这里只点到为止。
映射回 nydusd
这一步不是换一个根因,而是把前面已经在 FUSE demo 里看清的关系,放回真实系统的共享父目录场景里。
根因仍然是正文说的那条错误目录项失效通知。这个附录只是把真实复杂场景补齐:同一父目录下子项一多,目标子项又落到后半段时,目录读取更可能已经退回普通 readdir,这会让“读过父目录就恢复”进一步分成强弱两档。
换句话说:
- 正文解释的是:为什么 mountpoint 最后一级入口那一层会在 remount 之后暂时指错对象
- 这一节补充的是:当同一父目录下有很多子项时,为什么“父目录访问能不能把它救回来”还会继续分层
所以真实 nydusd 场景里,靠前的子项有时读一遍名字就够;靠后的子项,往往要像 ls -l 那样继续对子项取属性,才更稳定地把旧对象送走。