从最小 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 本身。

固定顺序其实很短:

  1. 第一次挂上以后,这条路径能正常访问
  2. 中间发生一次 unmount / remount
  3. 第二次挂回同一路径后,直接 stat 这条路径,返回 ENOENT
  4. 对它的父目录做一次 ls -l
  5. 再次 stat 同一路径,又成功了

这里有两个动作要先分清:

  • direct path 访问:直接拿完整路径去查,比如 stat /x/y/z
  • 父目录查看:这里特指 ls -l /x/y 这种不只列名字、还会继续补子项属性访问的动作

最诡异的地方就在这里:

  • 失败的是 direct path 访问
  • 能把它救回来的是 ls -l 这种更强的父目录查看

先不用急着记 FUSE 术语,只要先抓住一点:内核不只记“对象本身是什么”,也会记“某个父目录下面这个名字现在指向谁”。这两件事如果不同步,就会出现“直接按路径去找失败,但做一次 ls -l 又恢复”的怪现象。

本文真正想回答的问题就是:

为什么一次 ls -l 父目录,会影响一个子路径的 direct stat 结果?

如果这个问题不先讲清楚,后面就很难判断修复到底是碰巧好了,还是确实打到了点上。

2. 先补最少但够用的 FUSE 背景

这一节其实只想让读者记住一条线:内核去碰一个路径时,不只会记“这个对象自己是什么”,也会记“父目录下面这个名字现在指向谁”;而当你去读父目录时,又会碰到把“名字和对象关系”一起带回来的目录读取。

把这条线串起来,后面为什么一次 ls -l 能把坏路径救回来,就会顺很多。

2.1 按路径访问时,内核其实会碰到两层缓存

先只看最常见的按路径访问。

如果一个 FUSE 文件系统已经挂上去了,内核之后再读 /a/b/c 这种路径时,可以先粗略理解成两步:

  1. 按路径一级级往下找名字,也就是先看 / 下面有没有 a,再看 a 下面有没有 b,最后看 b 下面有没有 c
  2. 找到对象以后,再继续取属性,或者继续做目录读取、打开文件之类的动作

这两步正好对应这次问题里最相关的两层:

  • 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 几次。lookupcreatereaddirplus 这些动作都可能把这个计数加上去。
  • FORGET 可以先理解成:内核以后不用这个 inode 了,把之前记的这部分引用还回来。

把它们放回前面那条线里看,就比较容易理解了:

  1. umount 以后,旧的“名字 -> 对象”关系和旧 inode 不会自动同时消失
  2. 用户态文件系统还得把该失效的名字缓存、对象缓存准确告诉内核
  3. 如果只让对象这一层过期了,但名字这一层没处理对,后面的路径访问仍然可能沿着旧名字状态走

2.4 这一节只需要记住的最小模型

把这次问题压到最简单,就是这三层:

1
2
3
4
5
6
7
8
9
10
parent directory
|
+-- “某个名字现在指向谁”
| `-- dentry / entry cache
|
+-- “被指到的那个对象是什么”
| `-- inode / attr cache
|
`-- 目录读取
`-- readdirplus 会把“当前名字 + 当前对象”一起交回来

后面你只要一直记住这条线就够了:

  • direct path 访问,主要是在碰前两层
  • ls -l 这类父目录查看,会通过第三层把前两层重新补回来

所以:

  • 按路径访问更像是沿着现成的“名字 -> 对象”关系一路往下走
  • 目录读取更像是把当前有哪些名字、这些名字对应什么对象,再交给内核看一遍

3. 再用一个最小 FUSE demo 把它复现出来

3.1 最小目录树和复现流程

这个 demo 只保留一棵极小的目录树:

1
2
3
4
/
└── a
└── b
└── c

这里的 remount_bad 不是 shell 命令,而是输入给 demo 进程 stdin 的一条控制命令。它会做三件事:

  • /a/b/c 从旧 inode 切到新 inode
  • 对旧 inode 发一次 notify_inval_inode
  • 故意把那条目录项失效通知写错

这个 demo 只证明一件事:

同一个名字在 remount 前后换到了另一个对象上时,如果“父目录下面这个名字”没有被正确作废,direct stat 还可能先撞到旧对象;做一次 ls -l 之后才恢复。

复现流程很短,按实际跑出来的顺序看就是:

  1. 第一次 stat /a/b/c 结果:成功。这个步骤只是先把旧对象预热到内核里。
  2. 向 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=-2
    也就是说,名字 c 已经切到了新对象,但那条目录项失效通知是故意发错的。这里的 rc=-2 可以先简单记成:这条 entry invalidation 没命中目标。
  3. 第二次 stat /a/b/c 结果:失败,返回 ENOENT。对应日志是:

    1
    [getattr] stale ino=4 -> ENOENT
    这说明第二次 stat 还先走到了旧 inode。
  4. 执行 ls -l /a/b 结果:成功。对应日志是:

    1
    2
    3
    [readdirplus] ino=3 off=0
    [forget] ino=4 nlookup=1
    [readdirplus] ino=3 off=3
    这里已经能看到目录读取和旧 inode 退场的痕迹了。
  5. 第三次 stat /a/b/c 结果:成功。路径恢复。

3.2 代码里真正关键的地方

这个 demo 的核心状态只有一个版本号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct State {
fuse_ino_t c_ino;
uint64_t generation;
};

std::atomic<uint64_t> g_version(0);

State state() {
const uint64_t version = g_version.load(std::memory_order_relaxed);
return {
(version & 1) ? kC2 : kC1,
version + 1,
};
}

也就是说:

  • g_version = 0 时,c 对应 inode 4
  • g_version = 1 时,c 对应 inode 5

这里顺手带着的 generation,可以先把它理解成同一名字切到新对象时一起变化的版本号。本文主线只要记住:旧对象和新对象不只是 inode 不同,连这个版本号也一起变了。

remount_bad 做的事也很短:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void remount_bad() {
const State old_state = state();
g_version.fetch_add(1, std::memory_order_relaxed);
const State new_state = state();

const int inode_rc = fuse_lowlevel_notify_inval_inode(g_session, old_state.c_ino, 0, 0);
const int entry_rc = fuse_lowlevel_notify_inval_entry(g_session, kRoot, "a/b/c", 5);

std::cout << "[state] remounted c"
<< " old_ino=" << old_state.c_ino
<< " new_ino=" << new_state.c_ino
<< " gen=" << new_state.generation
<< " mode=bad\n";
std::cout << "[notify] inval_inode rc=" << inode_rc << "\n";
std::cout << "[notify] inval_entry rc=" << entry_rc << "\n";
}

这里故意做了两件事:

  1. 对旧 inode 发 notify_inval_inode
  2. 故意把目录项失效通知写错成:
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
2
3
4
sudo nydusd \
--config /dev/null \
--apisock /tmp/nydus.sock \
--mountpoint /data/nydus-images
1
2
3
4
5
6
7
8
curl --unix-socket /tmp/nydus.sock \
-X POST \
'http://localhost/api/v1/mount?mountpoint=/harbor-yctest.huya.info/harbor_leaf_nydus/machine-learn/chengyue_hygame_cover_v0.0.14' \
-d '{
"source": "/data/nydus-metadata/harbor-yctest.huya.info/harbor_leaf_nydus/machine-learn/chengyue_hygame_cover_v0.0.14/target/nydus_bootstrap/image/image.boot",
"fs_type": "rafs",
"config": "{...}"
}'

这里只保留和路径关系最相关的部分。config 继续省略细节,但 source 没省,因为它就是这次实际挂进去的那份 bootstrap 文件。

做完这两步以后,宿主机上真正访问到的完整路径才是:

1
/data/nydus-images/harbor-yctest.huya.info/harbor_leaf_nydus/machine-learn/chengyue_hygame_cover_v0.0.14

后面第 5 节和第 6 节说的那些 mountpointmountpoint rootmountpoint_parent_ino,都默认围绕这一个例子来讲。第 6 节会直接出现 PseudoFsVfsroot_inomountpoint_parent_inofs_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.14
  • mountpoint root 指最后一级 chengyue_hygame_cover_v0.0.14,也就是这条子路径在 PseudoFs 里的那个入口
  • mountpoint_parent_ino 指这条子路径直接父目录,也就是 machine-learn 那一层对应的 pseudo inode

5.2 为什么这里要分成 RafsPseudoFsVfs

这里最容易混淆的一点是:

目录树看起来明明只有一棵,为什么这里会同时冒出 RafsPseudoFsVfs

因为 nydusd 做的不是“把一张镜像直接摊出来”,而是“把多张后端文件系统按挂载路径拼到一棵总树上”。所以这里至少要分三层看:

  • Rafs:单张镜像自己的真实内容树
  • PseudoFs:把多个挂载点串起来的路径骨架
  • Vfs:不自己保存镜像内容,它负责沿着 PseudoFs 走路径,并在 mountpoint 处切到对应的 Rafs

如果一定要说“三棵树”,前两棵是真有各自节点和 inode 的树;第三棵更像 Vfs 拼出来的统一视图,也就是内核最终是通过它看到前两层拼在一起后的结果。

可以先把它压成一句话:

1
2
3
Rafs      = 单张镜像内部真正的文件系统内容
PseudoFs = 把多个挂载点串起来的路径骨架
Vfs = 把前两者接起来并对 FUSE 请求做分发的那层

5.3 mountpoint root 为什么正好卡在边界上

假设 nydusd API 里的镜像挂载子路径是:

1
/harbor-yctest.huya.info/harbor_leaf_nydus/machine-learn/chengyue_hygame_cover_v0.0.14

把宿主机上的完整访问路径和两层树合在一起看,可以直接画成这样:

1
2
3
4
5
6
7
8
9
/data/nydus-images                              # Linux 里真正挂上的 FUSE 挂载根
└── harbor-yctest.huya.info # 进入 nydusd 后,下面这些先还是 PseudoFs 骨架
└── harbor_leaf_nydus
└── machine-learn
└── chengyue_hygame_cover_v0.0.14 # mountpoint root:最后一级 PseudoFs entry,也是切到后端的边界
├── app # 从这里往下看到的内容,已经来自对应的 Rafs root
├── bin
├── etc
└── lib

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-learnchengyue_hygame_cover_v0.0.14 这种挂载路径骨架节点,用的是 pseudo inode
  • 镜像内部 //app/bin 这些真实对象,用的是 backend inode,也就是 Rafs inode

真正容易绕的是第三种。因为对 Rafs 来说,不同镜像里都可能有 inode 123。所以 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 inode
  • mountpoint_ino:mountpoint 自己这个骨架节点的 pseudo inode
  • root_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
2
3
4
5
6
7
8
POST/DELETE /api/v1/mount?mountpoint=...
-> MountHandler
-> kicker
-> API server 线程
-> ApiRequest::Mount / ApiRequest::Umount
-> FsService::mount() / FsService::umount()
-> walk_and_notify_invalidation()
-> FUSE notify

真正决定修复是否有效的地方,不在 mount 阶段那棵镜像树怎么准备,而在 umount() 里失效通知的参数到底怎么传。

6.3 修复前后到底差在什么地方

带着上一节那三层结构和三种 inode,再看修复前后就容易很多。这里的 cmd.mountpoint 也先说明一下:它不是 /data/nydus-images 这种宿主机 FUSE 挂载根,而是 nydusd API 里的镜像挂载子路径。下面先看修复前,再看修复后的等价写法。

先把这几个变量翻成更好懂的话:

  • root_ino 指的是那棵镜像树自己的 root inode,也就是 backend root inode
  • mountpoint_ino 指的是 pseudo 层里这条镜像子路径最后一级入口自己的 inode
  • mountpoint_parent_ino 指的是它的直接父 pseudo inode
  • fs_idx 是 backend 在 VFS 里的身份编号。本文主线不用盯它,但后面递归发给内核的 backend inode 会带着它一起编码

这次修复里最关键的,就是最后这一对参数:parent + basename。这里的 basename 就是路径最后一段名字。

修复前,逻辑等价于:

1
2
3
4
5
6
self.walk_and_notify_invalidation(
ROOT_ID,
cmd.mountpoint.trim_start_matches('/'),
root_ino,
fs_idx,
)?;

也就是:

  • parent = ROOT_ID
  • name = 整条 API mountpoint 子路径去掉开头的 /

修复后,逻辑变成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let mountpoint_name = Path::new(&cmd.mountpoint)
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| Error::InvalidArguments(...))?;

let mountpoint_ino = self
.get_vfs()
.get_root_pseudofs()
.path_walk(&cmd.mountpoint)?
.ok_or(Error::NotFound)?;

let mountpoint_parent_ino = self
.get_vfs()
.get_root_pseudofs()
.get_parent_inode(mountpoint_ino)
.ok_or(Error::NotFound)?;

self.walk_and_notify_invalidation(
mountpoint_parent_ino,
mountpoint_name,
root_ino,
fs_idx,
)?;

说白了,这段代码就是把:

  • 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
  • meson1.11.1
  • ninja1.13.0.git.kitware.jobserver-pipe-1
  • pkg-config0.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
2
3
4
g++ -std=c++17 -Wall -Wextra -O2 \
demo_fuse_readdirplus.cpp \
$(pkg-config fuse3 --cflags --libs) \
-o demo_fuse_readdirplus

如果你是自己编译或手工安装的 libfuse3,没有走 pkg-config,那就把下面这几个占位符替换成你自己的路径:

1
2
3
4
5
g++ -std=c++17 -Wall -Wextra -O2 \
demo_fuse_readdirplus.cpp \
-I<libfuse3-include-dir> \
-L<libfuse3-lib-dir> -lfuse3 -lpthread \
-o demo_fuse_readdirplus

libfuse 本身请按你自己的系统和习惯自行准备。

A.1.3 运行与清理命令

下面是一组最直接的运行方式。

下面把挂载点路径统一记成 <mnt>,实际操作时把它替换成你自己的挂载点目录。按下面顺序操作即可。

步骤 1,先清理并重新创建挂载点目录:

1
2
3
sudo umount -l <mnt> 2>/dev/null || true
rm -rf <mnt>
mkdir -p <mnt>

步骤 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
2
3
sudo pkill -f 'demo_fuse_readdirplus <mnt>' || true
sudo umount -l <mnt> || true
rm -rf <mnt>

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
#define FUSE_USE_VERSION 35

#include <fuse_lowlevel.h>

#include <sys/stat.h>
#include <unistd.h>

#include <atomic>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <string>
#include <thread>

namespace {

constexpr fuse_ino_t kRoot = FUSE_ROOT_ID;
constexpr fuse_ino_t kA = 2;
constexpr fuse_ino_t kB = 3;
constexpr fuse_ino_t kC1 = 4;
constexpr fuse_ino_t kC2 = 5;
constexpr double kTimeout = 60.0;

struct State {
fuse_ino_t c_ino;
uint64_t generation;
};

std::atomic<uint64_t> g_version(0);
fuse_session* g_session = nullptr;

State state() {
const uint64_t version = g_version.load(std::memory_order_relaxed);
return {
(version & 1) ? kC2 : kC1,
version + 1,
};
}

bool is_live_ino(fuse_ino_t ino, const State& s) {
return ino == kRoot || ino == kA || ino == kB || ino == s.c_ino;
}

fuse_ino_t parent_of(fuse_ino_t ino) {
switch (ino) {
case kA:
return kRoot;
case kB:
return kA;
case kC1:
case kC2:
return kB;
default:
return kRoot;
}
}

fuse_ino_t lookup_child(fuse_ino_t parent, const char* name, const State& s) {
if (parent == kRoot && std::strcmp(name, "a") == 0) {
return kA;
}
if (parent == kA && std::strcmp(name, "b") == 0) {
return kB;
}
if (parent == kB && std::strcmp(name, "c") == 0) {
return s.c_ino;
}
return 0;
}

void fill_stat(fuse_ino_t ino, struct stat* st) {
std::memset(st, 0, sizeof(*st));
st->st_ino = ino;
st->st_mode = S_IFDIR | 0755;
st->st_nlink = 2;
st->st_uid = getuid();
st->st_gid = getgid();
}

fuse_entry_param make_entry(fuse_ino_t ino, uint64_t generation) {
fuse_entry_param entry {};
entry.ino = ino;
entry.generation = generation;
entry.attr_timeout = kTimeout;
entry.entry_timeout = kTimeout;
fill_stat(ino, &entry.attr);
return entry;
}

void append_dirent_plus(fuse_req_t req, char* buf, size_t cap, size_t* pos,
const char* name, fuse_ino_t ino, uint64_t generation,
off_t next_off) {
const fuse_entry_param entry = make_entry(ino, generation);
const size_t len = fuse_add_direntry_plus(req, buf + *pos, cap - *pos, name, &entry,
next_off);
if (*pos + len <= cap) {
*pos += len;
}
}

void reply_dirbuf(fuse_req_t req, const char* buf, size_t pos, size_t size, off_t off) {
if (static_cast<size_t>(off) >= pos) {
fuse_reply_buf(req, nullptr, 0);
return;
}

size_t n = pos - off;
if (n > size) {
n = size;
}
fuse_reply_buf(req, buf + off, n);
}

void ll_readdirplus(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, fuse_file_info*) {
const State s = state();
char buf[768] {};
size_t pos = 0;

std::printf("[readdirplus] ino=%llu off=%lld\n",
static_cast<unsigned long long>(ino),
static_cast<long long>(off));

if (off == 0) {
append_dirent_plus(req, buf, sizeof(buf), &pos, ".", ino, 1, 1);
append_dirent_plus(req, buf, sizeof(buf), &pos, "..", parent_of(ino), 1, 2);
if (ino == kRoot) {
append_dirent_plus(req, buf, sizeof(buf), &pos, "a", kA, 1, 3);
} else if (ino == kA) {
append_dirent_plus(req, buf, sizeof(buf), &pos, "b", kB, 1, 3);
} else if (ino == kB) {
append_dirent_plus(req, buf, sizeof(buf), &pos, "c", s.c_ino, s.generation, 3);
}
}

reply_dirbuf(req, buf, pos, size, off);
}

void ll_init(void*, fuse_conn_info* conn) {
fuse_set_feature_flag(conn, FUSE_CAP_READDIRPLUS);
fuse_unset_feature_flag(conn, FUSE_CAP_READDIRPLUS_AUTO);
std::cout << "[init] proto=" << conn->proto_major << "." << conn->proto_minor
<< " readdirplus=always\n";
}

void ll_lookup(fuse_req_t req, fuse_ino_t parent, const char* name) {
const State s = state();
const fuse_ino_t ino = lookup_child(parent, name, s);
if (ino == 0) {
fuse_reply_err(req, ENOENT);
return;
}
const fuse_entry_param entry = make_entry(ino, ino == s.c_ino ? s.generation : 1);
fuse_reply_entry(req, &entry);
}

void ll_getattr(fuse_req_t req, fuse_ino_t ino, fuse_file_info*) {
const State s = state();
if (is_live_ino(ino, s)) {
struct stat st {};
fill_stat(ino, &st);
fuse_reply_attr(req, &st, kTimeout);
return;
}

if (ino == kC1 || ino == kC2) {
std::printf("[getattr] stale ino=%llu -> ENOENT\n",
static_cast<unsigned long long>(ino));
}
fuse_reply_err(req, ENOENT);
}

void ll_opendir(fuse_req_t req, fuse_ino_t ino, fuse_file_info* fi) {
const State s = state();
if (is_live_ino(ino, s)) {
fuse_reply_open(req, fi);
} else {
fuse_reply_err(req, ENOENT);
}
}

void ll_forget(fuse_req_t req, fuse_ino_t ino, uint64_t nlookup) {
std::printf("[forget] ino=%llu nlookup=%llu\n",
static_cast<unsigned long long>(ino),
static_cast<unsigned long long>(nlookup));
fuse_reply_none(req);
}

void remount_bad() {
const State old_state = state();
g_version.fetch_add(1, std::memory_order_relaxed);
const State new_state = state();

const int inode_rc = fuse_lowlevel_notify_inval_inode(g_session, old_state.c_ino, 0, 0);
const int entry_rc = fuse_lowlevel_notify_inval_entry(g_session, kRoot, "a/b/c", 5);

std::cout << "[state] remounted c"
<< " old_ino=" << old_state.c_ino
<< " new_ino=" << new_state.c_ino
<< " gen=" << new_state.generation
<< " mode=bad\n";
std::cout << "[notify] inval_inode rc=" << inode_rc << "\n";
std::cout << "[notify] inval_entry rc=" << entry_rc << "\n";
}

void control_thread() {
std::string cmd;
while (std::getline(std::cin, cmd)) {
if (cmd == "remount_bad") {
remount_bad();
}
}
}

} // namespace

int main(int argc, char* argv[]) {
if (argc != 2) {
std::fprintf(stderr, "Usage: %s <mountpoint>\n", argv[0]);
return 1;
}

std::setvbuf(stdout, nullptr, _IOLBF, 0);
std::setvbuf(stderr, nullptr, _IOLBF, 0);
std::cout << std::unitbuf;

char* fuse_argv[] = {argv[0]};
fuse_args args = FUSE_ARGS_INIT(1, fuse_argv);
fuse_lowlevel_ops ops {};
ops.init = ll_init;
ops.lookup = ll_lookup;
ops.getattr = ll_getattr;
ops.opendir = ll_opendir;
ops.readdirplus = ll_readdirplus;
ops.forget = ll_forget;

g_session = fuse_session_new(&args, &ops, sizeof(ops), nullptr);
if (g_session == nullptr) {
std::fprintf(stderr, "fuse_session_new failed\n");
return 1;
}

if (fuse_session_mount(g_session, argv[1]) != 0) {
std::fprintf(stderr, "fuse_session_mount failed\n");
fuse_session_destroy(g_session);
return 1;
}

std::thread(control_thread).detach();

std::cout << "Mounted at " << argv[1] << "\n";
std::cout << "Commands: remount_bad\n";

const int rc = fuse_session_loop(g_session);
fuse_session_unmount(g_session);
fuse_session_destroy(g_session);
return rc == 0 ? 0 : 1;
}

A.3 机制细节

A.3.1 先按源码分块看这个 demo

这份 demo 虽然不长,但读的时候最好先按职责分块,不要一上来从头顺着函数名硬看。

第一块是“最小文件系统状态”:

  • 常量 kRootkAkBkC1kC2
  • State
  • g_version
  • state()

这一块只负责一件事:决定当前 c 到底指向旧 inode 4,还是新 inode 5

第二块是“目录树和属性辅助函数”:

  • parent_of()
  • lookup_child()
  • fill_stat()
  • make_entry()
  • append_dirent_plus()
  • reply_dirbuf()

这一块负责把 / -> a -> b -> c 这棵最小目录树组织起来,并把 lookupreaddirplus 需要回给内核的 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
2
3
4
5
6
7
fuse_lowlevel_ops ops {};
ops.init = ll_init;
ops.lookup = ll_lookup;
ops.getattr = ll_getattr;
ops.opendir = ll_opendir;
ops.readdirplus = ll_readdirplus;
ops.forget = ll_forget;

这里可以先把它理解成:

内核之后如果发来某种 FUSE 请求,libfuse 就按这张表把请求转到对应的 ll_* 函数。

后面的启动顺序也可以按职责看:

  1. fuse_session_new(&args, &ops, sizeof(ops), nullptr) 建一个 FUSE session,并把这张 ops 回调表交给 libfuse。
  2. fuse_session_mount(g_session, argv[1]) 把这个 session 真正挂到命令行传进来的挂载点。
  3. std::thread(control_thread).detach() 另起一个线程专门等 stdin 里的 remount_bad
  4. 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() 负责属性查询;如果打到了旧 inode 4/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
2
[readdirplus] ino=3 off=0
[readdirplus] ino=3 off=3

这里 ino=3 就是 /a/bls -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
2
3
4
HTTP request
-> router
-> MountHandler::handle_request() // HTTP 入口 handler
-> kicker(ApiRequest) // 把请求对象投递到 API 线程

这里最值得记住的是 kicker

它不是普通的“下一层函数调用”,而是把 ApiRequest 送到 API server 线程,再同步等结果回来。

所以源码里看起来像“handler 往下调了一层”,实际底下跑的是“HTTP 线程 -> channel -> API server 线程”这一套同步请求。

B.2 mount 主路径:从 HTTP 入口到 Vfs::mount()

mount 主路径大致是:

1
2
3
4
5
6
7
POST /api/v1/mount
-> MountHandler // HTTP 入口 handler
-> ApiRequest::Mount // 投递给 API 线程的请求对象
-> ApiServerController::do_mount() // API 线程里的分发入口
-> FsService::mount() // 真正处理挂载的服务层
-> Vfs::mount() // 维护 pseudo/VFS 树的那层
-> PseudoFs root.mount(path)

FsService::mount() 的工作重点是:

  1. 根据请求参数构造 backend,也就是这次要挂进去的那棵镜像树
  2. 交给 Vfs::mount() 把路径挂上去
  3. 维护 backend collection 和 upgrade manager 等外围状态。这里的 upgrade manager 是跟后端升级流程相关的外围组件,这次 bug 不靠它触发

Vfs::mount() 这一步最重要的是:

把这条 API 镜像子路径的每一级组件挂进 pseudo 层,并让最后那个 mountpoint root 变成从 pseudo 层进入 backend root 的那道边界。

这也就是为什么正文里说 mountpoint root 不只是“一个普通目录”,而是一个边界 entry。

B.3 umount 路径:问题真正落在哪

umount 主路径大致是:

1
2
3
4
5
6
7
DELETE /api/v1/mount
-> MountHandler // HTTP 入口 handler
-> ApiRequest::Umount // 投递给 API 线程的请求对象
-> ApiServerController::do_umount() // API 线程里的分发入口
-> FsService::umount() // 真正处理卸载的服务层
-> walk_and_notify_invalidation()
-> Vfs::umount()

这里和正文最相关的是 FsService::umount() 的顺序:

  1. 先根据这条 API 镜像子路径找到 backend 和 fs_idx。这里的 fs_idx 可以先理解成这个 backend 在内部表里的索引/编号
  2. 如果当前是 FUSE,且 backend 是 RAFS
    • root_ino
    • mountpoint_name
    • mountpoint_ino
    • mountpoint_parent_ino
    • walk_and_notify_invalidation()
  3. 然后才真正执行 get_vfs().umount(&cmd.mountpoint)
  4. 再维护 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
2
3
一个 snapshot
-> 一个 daemon
-> 一个私有 FUSE 挂载根

而不是:

1
2
一个长期存在的共享父目录
-> 下面反复增删多个 pseudo 子挂载

我们这次的 bug 恰好最容易出现在后者,也就是共享父目录下反复增删 pseudo 子挂载的拓扑里。也就是说,这里说的“daemon 私有 FUSE 挂载根”和正文主线里的 mountpoint root 不是一层东西。所以这不是说 snapshotter 绝对不可能受影响;只是默认 dedicated 拓扑,已经足以解释“为什么原版默认使用里一直没把这个问题踩得很明显”。

附录 D:同一父目录下有数十个子项时,恢复为什么会分叉

正文前面故意只讲了最小场景:一个 mountpoint root,足够把修复点说明白。

这一附录补的是更接近真实环境的版本:同一父目录下面同时有数十个子项时,为什么“读过父目录以后会不会恢复”自己还会继续分叉。

为了不把真实系统细节和目录读取背景搅在一起,下面还是先从 FUSE demo 讲起,最后再映回 nydusd

D.1 目录读取的三层视角

这一节先不提 nydusd,也还不急着进入多子项 demo 本身。先把后面一定会用到的目录读取背景讲清楚,再去看 demo 里的分叉现象。

这里先把“读父目录”具体化一下。本文这里说的“读目录”,就是这类最普通的代码:

1
2
3
4
5
6
7
8
DIR *dir = opendir(parent_dir);
struct dirent *entry;

while ((entry = readdir(dir)) != NULL) {
printf("%s\n", entry->d_name);
}

closedir(dir);

也就是说:

  • opendir() 是把父目录打开
  • readdir() 是一条一条把目录项名字往外拿

如果只看应用程序这一层,很容易把很多事混在一起。更顺的看法是把同一次目录读取拆成三层:

  • 应用程序这一层,看到的是 glibc 的 readdir()
  • strace 这一层,最容易看到的是系统调用 getdents64()
  • FUSE daemon 这一层,真正处理的是 readdir()readdirplus() 回调

先看中间这层。getdents64() 的函数声明是:

1
ssize_t getdents64(int fd, void *dirp, size_t count);

它往用户态缓冲区里放的目录项记录,常见的样子可以先近似理解成:

1
2
3
4
5
6
7
struct linux_dirent64 {
ino64_t d_ino;
off64_t d_off;
unsigned short d_reclen;
unsigned char d_type;
char d_name[];
};

这些字段在本文里最值得记住的是:

  • d_name 目录项名字
  • d_ino 这条目录项最后对应的 inode 号
  • d_type 目录项类型,比如目录、普通文件
  • d_off 下次继续读目录时要从哪里接着读
  • d_reclen 这一条目录项记录在缓冲区里占多少字节

再看 FUSE daemon 这一层。在前面的最小 demo 里,因为 ll_init() 里强制打开了 readdirplus,所以目录读取最终会落到 ll_readdirplus()readdirplus 比普通 readdir 多出来的,不只是把名字列出来,而是把“名字当前对应谁、属性是什么”也一起交给内核。

把三层顺着连起来时,最容易漏掉的一点是:应用程序里多次 readdir(),经常只是在消费同一次 getdents64() 先装回来的那批目录项。

可以先看这个更贴近实际的连线图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
应用程序 / glibc
readdir() -> entry #0
readdir() -> entry #1
readdir() -> entry #2
^ ^ ^
|__________都在消费 glibc 手里的同一批目录项__________|

glibc 补目录缓冲区
getdents64(fd, buf, 4096) // 先装回第一批目录项
getdents64(fd, buf, 4096) // 第一批用得差不多了,再装第二批
getdents64(fd, buf, 4096) = 0 // 目录已经读完

内核 / FUSE
readdirplus(off=0)
readdirplus(off=...)
...

这里故意没有把“一次 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
2
3
4
5
6
7
8
9
10
11
12
13
14
前面的最小 demo
/
└── a
└── b
└── c

这里的多子项 demo
/
└── a
└── b
├── child_0000_...
├── child_0001_...
├── ...
└── target

代码上对应的是这两段。第一段先造出一大批稳定子项:

1
2
3
4
5
6
7
8
9
constexpr uint32_t kDefaultStableChildren = 512;
constexpr const char* kTargetName = "target";

for (uint32_t i = 0; i < count; ++i) {
char name[96] {};
std::snprintf(name, sizeof(name), "child_%04u_padding_for_readdirplus_%04u", i, i);
const fuse_ino_t ino = kStableBase + i;
g_stable_children.push_back({name, ino});
}

第二段在列 /a/b 时,先把这些稳定子项依次吐出来,最后才放目标子项:

1
2
3
4
5
6
if (ino == kB) {
for (const auto& child : g_stable_children) {
entries.push_back({child.name, child.ino, 1});
}
entries.push_back({kTargetName, s.target_ino, s.generation});
}

第二处是它不再只注册 readdirplus,而是把 readdirreaddirplus 都注册进去。这样我们才能直接看到“后半段到底还是不是 readdirplus”。

1
2
3
4
5
6
7
8
9
10
void ll_readdir(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, fuse_file_info*) {
reply_dir_listing(req, ino, size, off, ReplyKind::Readdir);
}

void ll_readdirplus(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, fuse_file_info*) {
reply_dir_listing(req, ino, size, off, ReplyKind::ReaddirPlus);
}

ops.readdir = ll_readdir;
ops.readdirplus = ll_readdirplus;

第三处是把目录读取模式做成了可切换。我们可以强制始终走 readdirplus,也可以允许内核自动退回普通 readdir

1
2
3
4
5
6
7
8
9
10
11
12
13
enum class ReaddirMode {
AlwaysPlus,
Auto,
};

void ll_init(void*, fuse_conn_info* conn) {
fuse_set_feature_flag(conn, FUSE_CAP_READDIRPLUS);
if (g_readdir_mode == ReaddirMode::Auto) {
fuse_set_feature_flag(conn, FUSE_CAP_READDIRPLUS_AUTO);
} else {
fuse_unset_feature_flag(conn, FUSE_CAP_READDIRPLUS_AUTO);
}
}

但有一点故意没有改:它保留了和正文同一类错误。也就是说,问题不是“多子项 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
  • 所以不带 -lls 往往是先 READDIRPLUSREADDIR
  • ls -l 往往会一直 READDIRPLUS

如果把这个自动切换规则压成图,大致就是这样:

1
2
3
4
5
6
7
8
9
10
11
always 模式
读父目录
-> 第一批目录项: READDIRPLUS
-> 第二批目录项: READDIRPLUS
-> 第三批目录项: READDIRPLUS

auto 模式
读父目录
-> 第一批目录项: READDIRPLUS
-> 第二批目录项: 如果前面没用到属性,就退回 READDIR
如果前面已经用到属性,就继续 READDIRPLUS

这里提不带 -llsls -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:读到目标子项时,只额外对这个目标子项做一次 lstat
  • opendir_readdir_lstat_all:把读到的每个子项都再做一次 lstat,最接近正文前面说的 ls -l
  • scandir_only:只做目录扫描,不逐个子项补 lstat

可以先把“前半段”和“后半段”画得更直白一点:

1
2
3
4
5
6
7
8
/a/b
├── child_0000_...
├── child_0001_...
├── ...
├── child_0020_... <- 前半段,较早读到
├── child_0021_...
├── ...
└── target <- 后半段,较晚读到

这一节的作用,只是把“分叉确实存在”这件事先看清。下一节再把它压成结果表,方便把“位置差异”和“动作强弱”放在同一张图里对照。

D.5 FUSE demo 的结果表

带着上一节的样本和动作定义,再把这组现象压成表,就比较容易比较了。

样本 位置证据 父目录动作 结果
A 镜像 目标子项排在前半段 打开父目录后顺着读一遍名字(opendir_readdir 成功
B 镜像 目标子项排在后半段 打开父目录后顺着读一遍名字(opendir_readdir 失败
B 镜像 同上 读到目标子项时只额外 lstat 它一次(opendir_readdir_lstat_target 失败
B 镜像 同上 只做目录扫描(scandir_only 失败
B 镜像 同上 对读到的每个子项都补一次 lstat,也就是更接近 ls -lopendir_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 那样继续对子项取属性,才更稳定地把旧对象送走。