探索构建微型FUSE文件系统
摘要
本文将引导你使用Rust构建一个名为magicfs的最小FUSE文件系统,该系统使用metadata.json和blob文件作为后端存储,演示了名称查找、inode稳定性和内核缓存等核心文件系统概念。
暂无内容
查看缓存全文
缓存时间: 2026/06/15 08:58
# 构建一个微型 FUSE 文件系统
来源:https://www.shayon.dev/post/2026/161/building-a-tiny-fuse-filesystem/
最近我在处理沙箱、存储和网络相关的工作,而这些工作不断回到文件问题上——这很合理,因为 Unix 在过去五十多年里一直以“一切皆文件”(https://en.wikipedia.org/wiki/Everything_is_a_file)来组织自己。你的终端和随机数生成器是可以打开和读取的设备文件(`/dev/tty`、`/dev/urandom`),甚至网络套接字——虽然是用自己的系统调用创建而不是通过路径打开——之后也是通过相同的接口进行读写。
在这篇文章中,我构建了一个小型文件系统,它有真正的后端存储、足够像文件系统的元数据,并故意省略了一些功能,这样代码仍然可读。
`magicfs` 挂载在 `/magic`,但它同时在旁边维护自己的本地后端存储,名称和 inode 编号存储在 `metadata.json` 中,而文件内容则作为普通的本地文件保存在 `blobs/` 目录下。称那个目录为 blob 存储有点夸张,因为这些 blob 只是被命名为像 `blob-000000000001` 这样的文件,但将元数据与文件内容分开,可以让示例涵盖名称查找、inode 稳定性、写入顺序、内核缓存以及 `fsync()` 对文件系统的要求。
完整示例代码在 github.com/shayonj/magicfs(https://github.com/shayonj/magicfs),如果你有 Docker,可以在启用 FUSE 的情况下运行该文件系统。
## 先试试看
``
docker run -it --rm --device /dev/fuse --cap-add SYS_ADMIN shayonj/magicfs
``
``
$ ls /magic
hello.txt notes.txt
$ cat /magic/hello.txt
Hello from a tiny FUSE filesystem.
$ echo "remember the milk" > /magic/notes.txt
$ cat /magic/notes.txt
remember the milk
``
在那个 shell 内部,挂载点是应用程序使用的接口,而 store 目录是由文件系统进程拥有的私有状态,所以 shell 看到一个普通的目录,尽管其背后的数据是一个元数据文件加上几个本地的 blob。
``
$ find /tmp/magicfs-store -type f
/tmp/magicfs-store/metadata.json
/tmp/magicfs-store/blobs/blob-000000000001
/tmp/magicfs-store/blobs/blob-000000000002
``
在 store 目录中,元数据文件充当了一个微型的 inode 表和目录树,记录了每个文件的名称、inode 编号、大小、权限位和 blob ID。
``
{
"next_inode": 4,
"entries": {
"hello.txt": {
"ino": 2,
"mode": 420,
"size": 36,
"blobs": [
{
"blob": "blob-000000000001",
"offset": 0,
"len": 36
}
]
},
"notes.txt": {
"ino": 3,
"mode": 420,
"size": 18,
"blobs": [
{
"blob": "blob-000000000002",
"offset": 0,
"len": 18
}
]
}
}
}
``
路径 `notes.txt` 并不是数据实际存放的地方,它只是一个能让你到达 inode 3 的名称,而 inode 3 的元数据指向 `blobs/` 下的一个 blob 文件。因此重命名 `notes.txt` 只会修改目录元数据,而重写它则会创建一个新的 blob 并更新元数据指针。
## 文件系统作为一个请求循环
当你运行 `cat /magic/hello.txt` 时,`cat` 并不知道背后涉及 JSON 元数据和 blob 文件,因为它所做的只是调用 `open()` 和 `read()`,之后内核通过 VFS(https://www.kernel.org/doc/html/latest/filesystems/vfs.html)解析路径,最终操作落到挂载在 `/magic` 的文件系统上。
使用 FUSE,响应这些文件系统请求的代码运行在用户空间。内核驱动通过 `/dev/fuse` 发送请求消息,用户空间进程回复,而发起系统调用的应用程序则一直等待,直到内核得到答案。内核 FUSE 文档(https://www.kernel.org/doc/html/latest/filesystems/fuse.html)涵盖了协议,而 `fuser`(https://docs.rs/fuser)crate 则将相同的操作暴露为 Rust trait 方法。
一次读取的路径大致如下:
``
flowchart LR
A["cat /magic/hello.txt"] --> B["Linux VFS"]
B --> C["FUSE kernel driver"]
C --> D["magicfs userspace process"]
D --> E["metadata.json + local blobs"]
E --> D
D --> C
C --> B
B --> A
``
在请求日志中,`LOOKUP` 询问某个名称是否存在于目录中以及它映射到哪个 inode,`GETATTR` 询问与 inode 关联的元数据,`READ` 请求某个偏移量的字节,`WRITE` 发送某个偏移量的字节。而在文件打开后的生命周期中,`FLUSH`、`FSYNC` 和 `RELEASE` 也会出现,使得写入路径不再像简单的字节拷贝回调那么简单。
以下是写入 `notes.txt` 时的日志,裁剪为与打开、截断、写入、刷新和释放文件相关的请求:
``
[magicfs] READDIR ino=1
[magicfs] LOOKUP notes.txt -> ino=3
[magicfs] OPEN notes.txt ino=3 flags=0x8001
[magicfs] SETATTR ino=3 size=0 staged=true
[magicfs] WRITE notes.txt ino=3 offset=0 len=18 staged=true
[magicfs] FLUSH notes.txt ino=3
[magicfs] COMMIT notes.txt ino=3 size=18 blobs=1
[magicfs] COMMIT metadata entries=2
[magicfs] RELEASE notes.txt ino=3 flags=0x8001 flush=true
``
在这个日志中,`ls` 触发了 `READDIR`,而直接 `cat /magic/hello.txt` 可以在不先列出目录的情况下遍历路径。使用 `>` 进行 shell 重定向会以写入和截断模式打开文件,因此内核在发送字节之前会先发送大小变更。`WRITE` 处理器仅将新内容暂存在内存中,后端存储直到文件被 flush 或 sync 时才会改变。
一个文件系统通常需要先回答关于名称的问题,才能回答关于字节的问题——即这个名称是否存在于这个目录中,如果存在,它指向哪个文件。
Linux 在路径查找完成后基本不再关心文件名,因为内部它通过 inode 编号引用文件。在磁盘文件系统上,inode 是一条包含元数据和数据块指针的记录,而目录项将名称映射到 inode。这就是为什么重命名可以改变路径而不移动文件数据,也是为什么硬链接可以让同一个 inode 出现在多个名称下。
`magicfs` 将目录项和 inode 元数据保存在 `metadata.json` 中:
``
"notes.txt": {
"ino": 3,
"mode": 420,
"size": 18,
"blobs": [
{
"blob": "blob-000000000002",
"offset": 0,
"len": 18
}
]
}
``
`LOOKUP notes.txt` 处理器读取该映射并返回 inode 3,`GETATTR` 处理器则将条目转换为 `FileAttr`,这就是 `stat` 和 `ls -l` 能够工作的原因。根目录使用 inode 1,这是 FUSE 文件系统的常规根 inode。
排序问题在读和写处理器处理文件内容之前就出现了:如果一个新的 blob 到达后端存储,但 `metadata.json` 仍然指向旧的 blob,那么读取者会一直看到旧文件;而如果 `metadata.json` 指向一个从未到达磁盘的 blob,读取者会看到一个损坏的文件。`magicfs` 通过先写入 blob 再替换元数据来处理简单情况,并且元数据替换遵循通常的本地文件系统模式:写入临时文件,同步,重命名为 `metadata.json`,然后同步包含它的目录。
临时文件加重命名的模式避免了半写入的 JSON,但它不是日志。没有恢复过程或事务日志,文件系统在崩溃后无法确定每个正在进行的元数据更新是否已提交。
## 文件内容作为本地 blob
对于数据路径,`magicfs` 将每个提交的文件版本存储为一个不可变的 blob,并分配一个 ID。更完整的文件系统会将大文件拆分成块,并让元数据指向一个块列表,但每个文件一个 blob 可以使代码保持简短。
对于读取,元数据优先:给定 inode 3,文件系统找到 `notes.txt` 的条目,从中读取 blob ID,打开 `blobs/` 下对应的文件,并返回内核请求的字节范围。
``
inode 3
-> metadata entry for notes.txt
-> blob ID blob-000000000002
-> blobs/blob-000000000002
-> bytes returned to READ
``
对于写入,数据流向相反方向,但 `magicfs` 不会原地修改 blob。因为当内核发送 `WRITE` 时,文件系统只在内存中暂存新的文件内容,稍后当 `FLUSH` 或 `FSYNC` 到达时,它会写入一个新的 blob 并更新元数据指向它。
这个示例最终实现了一个小型的写时复制数据路径,尽管重写一个大文件中的一个字节不应该重写整个文件。更好的实现会将文件分块,跟踪脏块,只写入更改的块,然后提交指向新块列表的元数据更新。`magicfs` 跳过了这种复杂性,假设文件足够小,可以作为一个整体重写。
## 写入不是同步
像这样的 shell 命令看起来比它背后的文件系统工作要简单:
``
$ echo "remember the milk" > /magic/notes.txt
``
在 `magicfs` 内部,其工作更接近这样:
``
OPEN notes.txt for writing
SETATTR notes.txt size=0
WRITE bytes at offset 0
FLUSH because a file descriptor is closing
write content blob
replace metadata.json
RELEASE the open file
``
在正常的 Linux 文件系统上,`write(2)` 通常意味着内核接受了字节到内存中,而不一定意味着字节已经到达稳定存储。`fsync(2)`(https://man7.org/linux/man-pages/man2/fsync.2.html)是应用程序在想要将文件数据以及检索该数据所需的元数据刷新到存储设备时调用的。`fdatasync(2)` 类似,但可以跳过后续读取不需要的元数据。
FUSE 也会在文件描述符关闭时调用文件系统,因为 `flush` 是在关闭时调用的,并且重复的文件描述符意味着一个打开的文件可以有多个 flush。文件系统可以利用 `flush` 报告延迟的写入错误,但 `flush` 与 `fsync` 含义不同,`release` 在更晚的时候发生,当内核已完成打开的文件句柄时。
对于 shell 演示,`magicfs` 在 `FLUSH` 和 `FSYNC` 上都提交暂存的字节,这使得 `echo hello > /magic/notes.txt` 的行为符合人们的预期,同时代码仍然将 `fsync` 视为对持久文件数据和元数据的显式请求。调用 `fsync` 的数据库提出了一个比刚好关闭重定向文件的 shell 更具体的问题。如果 `WRITE` 已经返回成功后,后端 blob 写入失败,文件系统仍然必须决定在何处报告该错误——要么通过后续的 `fsync`,要么通过 `flush` 在关闭时报告错误,不过很多程序并不仔细检查关闭错误。
对于元数据,使用 `rename` 替换文件对于读取者是原子的,但原子替换不等同于断电后的持久性。因此,如果你关心新的 `metadata.json` 在崩溃后幸存,你需要同步新文件内容以及指向它的目录项。`magicfs` 通过先同步临时元数据文件再重命名,然后重命名后同步 store 目录来处理其本地存储的这一问题。
在代码中,这些规则体现在 blob 写入、元数据替换、`flush` 和 `fsync` 的顺序上,因为文件系统必须决定哪些字节存在、哪些名称指向它们、以及在成功同步后应用程序可以假设什么。
FUSE 的回复可以包含名称和属性的生存时间(TTL)值。在 TTL 过期之前,内核可以回答重复的 lookup 和 getattr 调用,而无需再次询问用户空间进程。这一点很重要,因为每次 `stat` 都从内核穿越到用户空间文件系统代价高昂。
相同的 TTL 也会影响正确性。`magicfs` 使用一秒钟的 TTL,这对于单进程演示来说没问题。但如果另一个进程或另一台机器可以更新同一个后端存储,那么在缓存过期之前,读取者可能会看到旧的文件大小或旧的 blob ID——除非文件系统主动使内核缓存的状态失效。
对于文件内容,`magicfs` 使用 FUSE 的直接 I/O 打开文件,这样读取会回到用户空间文件系统,而不是从页缓存中提供。这使示例更容易理解,但放弃了真实文件系统可能需要的缓存和预读。缓存策略很重要,因为它会改变调用者能够观察到的文件大小、inode 属性和文件内容。
## 我保留的缺点
该实现只支持一个目录,每个文件存储为一个本地 blob,因此重写一个字节会重写整个文件。没有日志、恢复扫描或清理因重写或 unlink 而残留的孤立 blob。此外,它没有实现锁定、`mmap`、扩展属性、真正的权限模型、稀疏文件、硬链接、符号链接或多客户端缓存失效。
该文件系统也没有建模后端存储是远程时出现的问题。网络故障、远程一致性规则、重试和认证都会改变读取何时成功、写入何时可以重试以及 `fsync` 可以诚实报告什么。而这个示例停留在本地磁盘,以便文章专注于文件系统调用。
日志或事务日志可以让恢复决定元数据更新是否已提交,分块可以避免重写整个文件,垃圾收集器可以找到没有元数据条目可达的 blob,更好的缓存失效可以防止多个读取者长时间看到过时的元数据。
使用 FUSE,Linux 会向文件系统提出一组固定的问题,而实现可以从它拥有的任何后端存储中回答这些问题。这意味着当元数据和文件内容存储在其他地方时,实现仍然需要定义 `lookup`、`write`、`flush`、`fsync` 和 `rename`。
我目前在 Tines 负责这些文件系统、沙箱和存储问题,以及许多比一篇博客文章更深入的系统相关工作。如果你感兴趣,我们正在招聘(https://www.tines.com/careers/jobs/6014045004/)。
相似文章
littlefs的设计
littlefs是一个专为微控制器设计的故障安全文件系统,具有掉电恢复能力、动态磨损均衡以及有限的RAM/ROM。它提供类似POSIX的API,并用C语言编写,以实现较小的内存占用。
UTFS:类似TAR的嵌入式系统文件系统(2025)
UTFS是一个小巧的嵌入式文件系统,受TAR格式启发,专为微控制器设计,支持基于字符串的文件名存储数据,将数据存储与应用程序逻辑分离。
@yifanxu_ephai: 在LayerFS跑一个RUST编写的DB Engine任务: 将 2.50 TB 的快照数据、30 个分支,压缩到仅 4.66 GB, 光是 main 分支就有 8.23 GB。 先吹个牛逼,我怕明天发布翻车,就吹不了了。 半年前我和VC…
Ephemeral AI Lab 宣布 LayerFS 1.0.0 正式发布,这是一款为 AI 智能体设计的文件系统基座,其 Rust 编写的 DB Engine 将 2.50 TB 快照数据和 30 个分支压缩到仅 4.66 GB,主打短暂工作区与共享持久历史。
Show HN:怀念移动的方块,所以我构建了一个真正的Linux磁盘碎片整理工具
一个为Linux构建的图形化磁盘碎片整理工具,使用Rust编写,可以显示文件系统块映射,并对ext4和FAT卷进行碎片整理,无需root权限,灵感来自Windows碎片整理工具。
我们通过删除文件系统使其速度提升了47倍
microsandbox将其缓慢的用户空间FUSE文件系统替换为内核挂载的EROFS磁盘映像,在文件系统操作上实现了几何平均47倍的速度提升,并消除了虚拟机/主机往返瓶颈。