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

Rust 生态里的 fuser 库把 FUSE 协议包好了:你只要为十几个回调各写一段答复, 内核问什么你答什么。最小可用集是 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. 一个慢请求不许拖垮全场。回调是内核同步等答复的,而 forge-fs 的读写要等网络; 两者之间必须有一层桥接,而且挂载时要开多线程会话 —— 否则一个卡在超时里的请求会把同挂载点上其他所有 IO 排在它后面。 桥接怎么搭是语言层的事,交给 AI;你要盯的是结果:一个慢请求不阻塞别人。

挂载入口做成 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 时早就在这种语义下活着 —— 区别是这次由你来定价,而且知道价格标签背后的机制。单客户端场景 (我们的里程碑)则完全不受影响,因为唯一的写者就是缓存的持有者。

Checkpoint单选

把 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 翻译错误消灭掉。

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

review 重点三处,都不需要你读 Rust。第一,每个操作的超时:漏一个, 就埋下一个未来的 D 状态事故 —— 用上面那张表把「操作 × 超时 × 错误码」核一遍, 再让 AI 说清楚它是怎么保证「新加一个操作不会忘记带超时」的(靠自觉不算答案)。 第二,errno 翻译:AI 常把「目录非空」错翻成 EEXIST(正确是 ENOTEMPTY), pjdfstest 会抓,但你最好在这张表上就抓到。第三,对照编译耗时:比 ext4 慢 3~10 倍 属于正常(FUSE 两次往返 + 网络),慢 100 倍说明某条路径退化成了逐字节请求。 最后让 AI 讲一遍「回调里如果出现意料之外的错误会怎样」—— 只要它的答案里出现「进程会退出」这类字样,挂载点就会变砖,退回重做。

目标:把前两课做好的元数据层和数据层接到内核上,挂成一个真能用 ls、cat、编译器去操作的挂载点。走 FUSE —— 这是清醒的架构交易:小 IO 比内核文件系统慢 5~10 倍,换来完全不碰内核编程、两周就能挂起来。

行为规格:
1. 需要响应的操作最小集:查目录项、取属性、改属性、读、写、列目录、创建、删文件、建目录、删目录、改名、flush;
2. 每一个到存储节点的请求都必须带超时,默认 5 秒,超时就返回 EIO 让应用报错。任何情况下回调都不能无限等待,也不能让进程崩溃 —— 挂载点后面的进程一死,机器上所有访问这个挂载点的进程立刻全进 D 状态,连 umount 都卸不掉,只能强制卸载或重启;
3. 内部错误到 errno 的翻译一次定死,而且必须精确:不存在是 ENOENT,已存在是 EEXIST,不是目录是 ENOTDIR,是目录是 EISDIR,目录非空是 ENOTEMPTY(不是 EEXIST),分片不可用和超时都是 EIO。应用和 POSIX 测试集只认 errno,错一个 rm -rf 的行为就不对了;
4. 属性缓存:回复「查目录项」和「取属性」时附带一个有效期,默认 1 秒、可用命令行参数调(也允许调成 0)。有效期内内核直接用缓存不再上门,代价是另一个客户端的修改最长 1 秒后才可见 —— 这个语义要明确写进文档,不能让使用者自己去猜;
5. 慢请求不许队头阻塞:一个卡住的操作不能让同一挂载点上其他所有操作跟着排队;
6. 挂载做成命令行子命令:forge-cli mount --meta <元数据地址> <挂载点>,前台运行,Ctrl-C 干净卸载,不留残留挂载点;
7. 几条 POSIX 语义不许打折:删掉一个已经打开的文件,持有它的进程仍然能继续读写;改名覆盖一个已存在的目标必须原子,任何时刻别的进程看过去,目标要么是旧内容要么是新内容,不许出现「目标不存在」的瞬间;对普通文件调 rmdir 要报 ENOTDIR,改名到自身要成功。

交付:能挂载的程序 + 一个冒烟脚本(挂载后依次:建目录、写读比对、改名覆盖、删掉已打开的文件后继续读、一千个文件的目录列表计时,任何一步失败即退出码非 0)+ 在挂载点上完整编译 zlib(configure、make、make test),把耗时与 make test 结果、以及同机内核文件系统上同样编译一遍的耗时,一起记进一份对照文档。另外给我一个能打印「每次读写请求的大小分布」的开关,我要用它判断有没有退化成小块请求。(用哪个 FUSE 库、同步回调怎么接上异步逻辑、错误怎么建模你定,最后告诉我为什么选它。)
你的验收动作自己跑、自己看,不用读代码
  • 挂载后手工敲一遍:ls、echo 写、cat 读、mkdir、rmdir、mv 覆盖、rm、对普通文件敲 rmdir 看报错。再 Ctrl-C 卸载,确认 mount 输出里没有残留、挂载点又变回一个干净空目录
  • 让 AI 把「每个操作 × 它的超时 × 各种失败情况分别返回哪个 errno」列成一张表交给你,你逐格核对。重点盯目录非空那格必须是 ENOTEMPTY、对文件 rmdir 那格必须是 ENOTDIR —— 表格核对不需要读一行代码,却抓得住这一课最高频的一类 bug
  • 破坏性验证:挂载点上跑着一个 find 或者 tail -f,然后 kill -9 一个存储节点。确认命令在 5 秒左右报 IO 错误退出而不是永久卡住;另开一个终端敲 ls 挂载点也必须返回(报错也算返回)。再确认 ps 里没有留下 D 状态进程、umount 能正常卸掉 —— 这一条直接验第 2 条铁律
  • 编译对照:同一份 zlib 在挂载点和本机 ext4 上各编译一次记耗时。慢 3~10 倍正常;慢到 100 倍说明某条路径退化成了逐字节请求,打开请求大小分布的开关看看 —— 内核给的应该是几十到几百 KiB 的块,清一色 4 KiB 甚至更小就是它没按内核给的长度整块处理
  • 属性缓存对照:有效期设成 0 和 1 秒各跑一次一千文件目录的 ls -l 计时,两次耗时应该差出一个量级。差不出来说明这个有效期根本没送到内核,缓存是假的
必须问到听懂的问题答不上来就别合并
  • 后端某个存储节点不响应了,我在挂载点上敲 ls 会发生什么、多久返回、返回什么错?为什么不能选择「多等一会儿说不定就好了」?
  • forge-fuse 这个进程自己被 kill -9 了,正在读写这个挂载点的应用会怎样?我要把机器救回来该敲哪几条命令?
  • 属性缓存有效期 1 秒:另一台客户端刚往一个文件里追加了内容,我这边 ls -l 什么时候能看到新的大小?我 tail -f 这个文件会卡吗?
  • 应用一次 4 KiB 的写,从系统调用到最终落在存储节点的盘上,一共经过几次用户态与内核态之间的往返、几次内存拷贝?哪一段是 FUSE 独有的开销?

小结

  • FUSE = 内核把 VFS 请求转发给用户态进程:两次往返 + 两次拷贝,小 IO 的 IOPS 比内核文件系统低一个量级 —— Weka 自研内核客户端绕开的正是这个
  • 运维时见过的 FUSE 挂载点 D 状态 = 用户态文件系统不回话;forge-fuse 铁律:每个回调带超时,超时回 EIO
  • 最小回调集 + 一张不许错的 errno 翻译表;同步回调用 block_on 桥接 async 的 forge-fs
  • 属性缓存 TTL 1 秒:用多客户端下最长 1 秒的元数据延迟,换 stat 风暴被内核挡住 —— NFS 级语义,这次价格由你定
  • 编译是文件系统的照妖镜,pjdfstest 是语义门禁;两样都过,才有资格谈里程碑