FUSE 客户端:mount 自己的文件系统
用 fuser crate 实现 lookup/read/write/readdir,第一次把 forgefs 挂载到 /mnt 下 ls 出东西来 —— 这一刻值回所有学费。
学完这节你能做到
- 解释 FUSE 内核往返与它的性能天花板
- 实现最小 FUSE 接口集并通过 pjdfstest 子集
- 知道 Weka 为什么最终绕开 FUSE 走自研客户端
这一刻值回所有学费
四个阶段的积累到今天兑现:把 forgefs 挂到 /mnt/forgefs,ls 一下,
看到自己写的文件系统吐出目录列表。做存储运维这么多年,你 mount 过别人的文件系统
成百上千次 —— 这次挂载点后面的每一行代码都是你(和你的 AI)写的。
这节课用 fuser crate 写 forge-fuse:先搞懂 FUSE 的机制和性能天花板, 再把 L4 前两课的 forge-fs 接上内核,最后在上面跑真实负载。
FUSE 机制:一次 read 的完整旅程
FUSE(Filesystem in Userspace)的原理一句话:内核收到 VFS 请求,不自己处理,
转发给一个用户态进程回答。一次 read() 的完整路径:
- 应用调
read(fd, buf, 4096),陷入内核,走到 VFS 层 - VFS 发现这个挂载点是 FUSE,把请求编码成 FUSE 协议消息,放进
/dev/fuse的队列 - 你的 forge-fuse 进程正阻塞在
/dev/fuse上读请求 —— 醒来,拿到消息 - forge-fuse 调 forge-fs 的读路径(查日志索引、查 extent map、RPC 到存储节点)
- 数据拷回内核,内核再拷给应用,
read()返回
数一下:两次用户态-内核态往返、至少两次内存拷贝,这还没算 forge-fuse
到存储节点的网络。对比 L2 你测过的数字:一次系统调用往返约 12 微秒,
FUSE 一次空操作(如 getattr 命中缓存)往返约 510 微秒,吞吐路径上大块 IO
勉强能到几 GB/s,但小 IO 的 IOPS 天花板比内核文件系统低一个量级。
这就是 Weka 不用 FUSE 的原因 —— 它自研内核模块 + 用户态协议,客户端直接和 Frontend 进程共享内存通信,省掉 FUSE 的两次往返。我们选 FUSE 是清醒的交易: 慢 5~10 倍,换来两周就能挂载起来,且完全不用碰内核编程。差距记入 milestone 的清单。
运维时你处理过 FUSE 挂载点卡死:进程全部 D 状态,umount 不掉,
只能 umount -l 或重启。机制现在清楚了 —— 应用阻塞在内核等 FUSE 回复,
而用户态文件系统进程死了或死锁了,请求永远没有答案。所以 forge-fuse 的铁律:
回调里绝不能无限等待,每个到存储节点的 RPC 都必须带超时,
超时就回 EIO,让应用报错,而不是让整台机器的 ls 全挂住。
fuser 实战:接上 forge-fs
fuser 的核心是一个 trait:实现 Filesystem 的十几个回调,内核问什么你答什么。
最小可用集是 lookup / getattr / read / write / readdir / create / unlink / mkdir / rmdir / rename / setattr。骨架:
/// forge-fuse: 把 forge-fs 暴露给内核
pub struct ForgeFuse {
fs: Arc<forge_fs::ForgeFs>, // L4 前两课的成果
rt: tokio::runtime::Handle, // fuser 回调是同步的,异步逻辑靠它桥接
attr_ttl: Duration, // 属性缓存有效期,下一节的主角
}
impl fuser::Filesystem for ForgeFuse {
fn lookup(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEntry) {
let fs = Arc::clone(&self.fs);
let name = name.to_owned();
match self.rt.block_on(async move {
tokio::time::timeout(RPC_DEADLINE, fs.lookup(parent, name.as_bytes())).await
}) {
Ok(Ok(Some(inode))) => reply.entry(&self.attr_ttl, &to_fuse_attr(&inode), 0),
Ok(Ok(None)) => reply.error(libc::ENOENT),
Ok(Err(e)) => reply.error(errno_of(&e)), // FsError -> errno 的统一翻译
Err(_elapsed) => reply.error(libc::EIO), // 超时:宁可报错,不许挂死
}
}
fn read(&mut self, _req: &Request<'_>, ino: u64, _fh: u64,
offset: i64, size: u32, _flags: i32, _lock: Option<u64>, reply: ReplyData) {
// 同样的模式:block_on + timeout,命中日志索引或 extent map,
// 缺分片时走 RS 重构(L3 的降级读),对应用完全透明
// ...
}
// write / readdir / create / unlink / mkdir / rmdir / rename / setattr 同构
}
两个工程细节比回调本身重要:
- 错误翻译表。
FsError到 errno 的映射要一次定死:NotFound 对ENOENT、 Exists 对EEXIST、NotADirectory 对ENOTDIR、ShardUnavailable 和超时对EIO。 应用(以及 pjdfstest)只认 errno,翻译错一个,rm -rf都会行为异常。 - 同步-异步桥接。fuser 的回调是同步的,forge-fs 是 async 的,用
Handle::block_on桥接;挂载时给 fuser 开多线程会话,避免单个慢请求 队头阻塞所有 IO。
挂载入口做成 forge-cli 的子命令,运维手感:
forge-cli mount --meta 10.0.0.1:7401 /mnt/forgefs # 前台挂载,Ctrl-C 卸载
ls /mnt/forgefs && echo hello > /mnt/forgefs/a.txt && cat /mnt/forgefs/a.txt
属性缓存:一致性卖多少钱
如果每次 getattr 都去元数据分片问一圈,ls -l 一个千文件目录就是上千次 RPC,
一次 0.5 毫秒的话就是半秒 —— 不可用。FUSE 给了议价工具:回复 lookup/getattr 时
附带一个 TTL,TTL 内内核直接用缓存的属性,不再上门。
这笔交易的两端:
- TTL 开大(比如 1 秒):元数据 RPC 骤减,
ls -l、find、编译的 stat 风暴全被内核挡住 - 代价:另一个客户端改了文件,本机最长 1 秒内看到旧的 size/mtime —— 关窗一致性没了,变成最终一致
forgefs 的选择:attr TTL 默认 1 秒,entry TTL 同样 1 秒,并在文档里写明 「多客户端并发改同一文件时,元数据可见性延迟上限 1 秒」。这和 NFS 默认的 close-to-open + 属性缓存是同一档语义,你运维 NFS 时早就在这种语义下活着 —— 区别是这次由你来定价,而且知道价格标签背后的机制。单客户端场景 (我们的里程碑)则完全不受影响,因为唯一的写者就是缓存的持有者。
把 FUSE 的属性缓存 TTL 从 0 调到 1 秒,买到了什么,卖掉了什么?
跑真实负载:编译当烟雾测试
echo 和 cat 通了不算数,文件系统的照妖镜是编译:高并发 stat、
临时文件创建删除、append 写、seek 回写、rename 原子替换 —— gcc 和 cargo
会在几十秒内把你的每个回调锤上万遍。先拿小项目试:
cd /mnt/forgefs
git clone https://github.com/madler/zlib && cd zlib
./configure && make -j8 && make test # 全绿才算通
编译一旦通过,再上 pjdfstest(POSIX 兼容性测试集,FreeBSD 社区维护,
Ceph/Gluster 都拿它当门禁)扫 forgefs 的语义死角 —— 它会测你想不到的边界:
对文件调 rmdir 该回 ENOTDIR、rename 到自身该成功、unlink 已打开的文件后
fd 仍可读写。milestone 课把它列为验收项 1,这节课先跑通子集,把显而易见的
errno 翻译错误消灭掉。
review 重点有三处。第一,每个回调的超时:漏一个,就埋下一个未来的 D 状态事故。 第二,errno 翻译:AI 常把 NotEmpty 错翻成 EEXIST(正确是 ENOTEMPTY), pjdfstest 会抓,但你最好在这里就抓到。第三,对照编译耗时:forgefs 比 ext4 慢 3~10 倍属于正常(FUSE + 网络),慢 100 倍说明某条路径退化成了逐字节 RPC —— 去查 read/write 有没有按内核给的 size 整块处理。
在 forge workspace 新增 crates/forge-fuse(bin),依赖 fuser 和 forge-fs: 1. 实现 fuser::Filesystem 的最小集:lookup / getattr / setattr / read / write / readdir / create / unlink / mkdir / rmdir / rename / flush,全部走统一的 block_on + 超时(5 秒)桥接,超时回 EIO,绝不无限阻塞; 2. 写统一的 FsError -> errno 翻译函数 errno_of,覆盖 NotFound/Exists/NotADirectory/IsADirectory/NotEmpty/ShardUnavailable,并为它写测试; 3. 属性缓存 TTL 做成命令行参数 --attr-ttl,默认 1s; 4. 挂载做成 forge-cli 的 mount 子命令:forge-cli mount --meta <地址> <挂载点>,前台运行,SIGINT 时干净卸载; 5. 交付一个冒烟脚本 scripts/fuse-smoke.sh:挂载后依次执行 mkdir/写读比对/rename 覆盖/unlink 已打开文件仍可读/千文件目录 ls -l 计时,任何一步失败即退出码非 0; 6. 在挂载点上完整编译 zlib(configure + make + make test),把耗时和 make test 结果记录进 docs/perf/fuse-baseline.md,同机内核文件系统(如 ext4)同样编译一遍作对照。 生产路径不许 unwrap,回调里不许 panic(panic 会让挂载点变砖)。逐项贴执行结果。
小结
- FUSE = 内核把 VFS 请求转发给用户态进程:两次往返 + 两次拷贝,小 IO 的 IOPS 比内核文件系统低一个量级 —— Weka 自研内核客户端绕开的正是这个
- 运维时见过的 FUSE 挂载点 D 状态 = 用户态文件系统不回话;forge-fuse 铁律:每个回调带超时,超时回 EIO
- 最小回调集 + 一张不许错的 errno 翻译表;同步回调用 block_on 桥接 async 的 forge-fs
- 属性缓存 TTL 1 秒:用多客户端下最长 1 秒的元数据延迟,换 stat 风暴被内核挡住 —— NFS 级语义,这次价格由你定
- 编译是文件系统的照妖镜,pjdfstest 是语义门禁;两样都过,才有资格谈里程碑