Storforge
编码预计 55 分钟

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() 的完整路径:

  1. 应用调 read(fd, buf, 4096),陷入内核,走到 VFS 层
  2. VFS 发现这个挂载点是 FUSE,把请求编码成 FUSE 协议消息,放进 /dev/fuse 的队列
  3. 你的 forge-fuse 进程正阻塞在 /dev/fuse 上读请求 —— 醒来,拿到消息
  4. forge-fuse 调 forge-fs 的读路径(查日志索引、查 extent map、RPC 到存储节点)
  5. 数据拷回内核,内核再拷给应用,read() 返回

数一下:两次用户态-内核态往返、至少两次内存拷贝,这还没算 forge-fuse 到存储节点的网络。对比 L2 你测过的数字:一次系统调用往返约 12 微秒, FUSE 一次空操作(如 getattr 命中缓存)往返约 510 微秒,吞吐路径上大块 IO 勉强能到几 GB/s,但小 IO 的 IOPS 天花板比内核文件系统低一个量级。

这就是 Weka 不用 FUSE 的原因 —— 它自研内核模块 + 用户态协议,客户端直接和 Frontend 进程共享内存通信,省掉 FUSE 的两次往返。我们选 FUSE 是清醒的交易: 慢 5~10 倍,换来两周就能挂载起来,且完全不用碰内核编程。差距记入 milestone 的清单。

×你见过的 D 状态,现在知道谁的锅了

运维时你处理过 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 同构
}

两个工程细节比回调本身重要:

  1. 错误翻译表FsError 到 errno 的映射要一次定死:NotFound 对 ENOENT、 Exists 对 EEXIST、NotADirectory 对 ENOTDIR、ShardUnavailable 和超时对 EIO。 应用(以及 pjdfstest)只认 errno,翻译错一个,rm -rf 都会行为异常。
  2. 同步-异步桥接。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 -lfind、编译的 stat 风暴全被内核挡住
  • 代价:另一个客户端改了文件,本机最长 1 秒内看到旧的 size/mtime —— 关窗一致性没了,变成最终一致

forgefs 的选择:attr TTL 默认 1 秒,entry TTL 同样 1 秒,并在文档里写明 「多客户端并发改同一文件时,元数据可见性延迟上限 1 秒」。这和 NFS 默认的 close-to-open + 属性缓存是同一档语义,你运维 NFS 时早就在这种语义下活着 —— 区别是这次由你来定价,而且知道价格标签背后的机制。单客户端场景 (我们的里程碑)则完全不受影响,因为唯一的写者就是缓存的持有者。

Checkpoint单选

把 FUSE 的属性缓存 TTL 从 0 调到 1 秒,买到了什么,卖掉了什么?

跑真实负载:编译当烟雾测试

echocat 通了不算数,文件系统的照妖镜是编译:高并发 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 该回 ENOTDIRrename 到自身该成功、unlink 已打开的文件后 fd 仍可读写。milestone 课把它列为验收项 1,这节课先跑通子集,把显而易见的 errno 翻译错误消灭掉。

AI 结对:实现 forge-fuse 并通过编译烟雾测试pair with ai

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 是语义门禁;两样都过,才有资格谈里程碑