我第一次觉得 FUSE(Filesystem in Userspace)像是某种魔法,是在试图把一个云存储桶挂成服务器本地目录的时候。你大概率也遇到过类似需求:网盘里的数据想用rsync直接同步,HDFS 上的目录想用普通命令浏览,远程服务器的某个文件夹想直接拖进本地文件管理器。这些场景的共同点是:数据源和本地文件系统完全不同,但你希望它在用户看来就是一个普通目录,能被ls、cat、cp这些工具直接操作。FUSE 就是 Linux 提供的那层「通用转换器」,它允许普通用户态进程去实现一个完整的文件系统,而不是把逻辑写进内核。
这篇文章不打算堆源码和协议细节,而是想把 FUSE 从「内核态 vs 用户态」的模糊概念,拆解成一条从 VFS 到用户进程的通路,顺带分享我用它做过的事、踩过的坑。不管你是后端开发、大数据运维还是系统编程出身,理解 FUSE 都能让你在下一个「想把 X 变成文件夹」的需求面前少走很多弯路。
1. 为什么会有 FUSE:在内核态写文件系统到底有多痛
1.1 文件系统本质上是「路径到数据的映射服务」
先别被「文件系统」四个字吓到。抛开 ext4、XFS 这些具体实现,文件系统可以简化成一句话:你给我一个路径,我还你一份数据。至于数据存在哪、怎么组织、怎么加权限,都是实现细节。
Linux 通过 VFS(虚拟文件系统)统一了所有文件系统的对外接口。VFS 就像一栋大楼的物业,ext4、NFS、procfs 都是住在这栋楼里的不同商户。应用程序调用open()、read()、write()时,根本不关心底层是磁盘还是网络,VFS 会把这些调用转发给对应的文件系统实现。
问题出在「实现」这两个字上。传统文件系统一般跑在内核态,但内核态不是你想进就能进的。
1.2 内核态开发的几道高墙
如果你真的在内核里写过文件系统模块,一定对下面这些限制深有体会。
第一,调试成本极高一截。内核态代码出 bug,很可能直接 panic,整个系统当场死给你看。你没法用gdb像调试普通程序那样断点、打印、热重载,每次验证都得编译模块、重启机器或者祈祷虚拟机快一点。
第二,内核 API 不稳定。Linux 内核版本迭代快,内部接口经常变。同一个文件系统模块,换一个内核版本就得重新适配。企业项目里内核版本五花八门,维护成本让人头皮发麻。
第三,能用的库极其有限。内核态不能随便调用用户态的加密库、网络库、JSON 解析库。你想在文件系统里对接一个 HTTPS API?先自己实现 TLS 再说吧。
第四,许可证和权限问题。内核许可证是 GPL,很多公司不愿意让自家文件系统逻辑以 GPL 方式进入内核。普通用户也没有权限加载内核模块,更别提交到主线了。
你能不能在用户态绕过这些问题?当然能。那用户态文件系统该怎么和内核对接?这就要说到 FUSE 的历史定位了。
1.3 用户态文件系统不是新概念,FUSE 让它变得好用
用户态文件系统的思路其实很早就有了,Plan 9 的 9P 协议就是典型代表。Linux 下也有过不少尝试,但真正让它变成常规操作的是 FUSE 的成熟:开发者 Mikael Szeredi 在 2005 年左右把它合入内核 2.6.14,随后 libfuse 和fusermount工具让普通用户也能挂载自己的文件系统。
内核只需要提供一个稳定的「搬运通道」——把 VFS 请求打包发出去,再把用户态的响应结果送回来。剩下的文件系统逻辑,你想用 C 写、用 Python 写、甚至用 Go 写都行。内核态和用户态的区别,在这里第一次变得像点外卖一样清晰:内核负责把路修好,用户态负责开饭店。
2. 核心机制拆解:一次 read 调用是怎么从 VFS 跑到你的用户态进程的
2.1 一个经典场景:cat 一个 FUSE 挂载点里的文件
假设你挂载了一个 FUSE 文件系统在/mnt/fuse-test,然后在终端敲下cat /mnt/fuse-test/hello.txt。这一步会发生什么?
我画一个流程你会更直观:
| 步骤 | 参与方 | 发生了什么 |
|---|---|---|
| 1 | 应用进程 | cat调用open("/mnt/fuse-test/hello.txt") |
| 2 | VFS | 根据挂载表识别出该路径属于 FUSE 文件系统 |
| 3 | FUSE 内核模块 | 把 open 请求封装成协议消息,写入/dev/fuse的请求队列 |
| 4 | 用户态守护进程 | libfuse 的事件循环从/dev/fuse读取请求,分发到回调函数 |
| 5 | 用户态实现 | 你的代码处理lookup、getattr、open等回调,返回 stat 结构和文件句柄 |
| 6 | FUSE 内核模块 | 收到响应,更新 VFS 的 dentry 缓存和 inode 信息 |
| 7 | 应用进程 | open返回,继续执行read,重复上述请求循环 |
真正的cat命令在读取时还会发出read请求,这个操作同样要经历「应用进程 -> VFS -> FUSE 内核模块 -> /dev/fuse -> 用户态守护进程 -> 你的 read 回调 -> 返回数据」的完整往返。
2.2 为什么需要一套协议:内核根本不认识你的后端
你会不会好奇:FUSE 内核模块怎么知道如何对接网盘 API?答案是它不需要知道。FUSE 的策略非常聪明:内核只管传送带,不管货是什么。
当用户进程对挂载点发起系统调用时,VFS 发现这是 FUSE 文件系统,就会调用 FUSE 内核模块注册的操作函数。内核模块把这个请求翻译成一个结构化的消息,写入/dev/fuse这个虚拟字符设备。消息里包含节点号(nodeid)、操作码(opcode)、请求 ID、参数数据。
用户态守护进程就是打开/dev/fuse并不断read()写上来的那个进程。libfuse 帮我们封装好了这一层:它读取请求,解析出操作码,然后调用你注册的函数。你在函数里拿到路径、offset、size 这些参数,用你自己的方式(可能查数据库、可能调 HTTP API)得到结果,再通过结构体返回。libfuse 把返回值编码成响应消息,写回/dev/fuse,内核模块解析响应后,把结果交还给发起调用的进程。
所以 FUSE 本质上是一个双向协议通道。你不需要告诉内核你的数据从哪来,只需要按规定响应请求。
2.3 几个必须认识的角色:libfuse、fusermount 和 /dev/fuse
初次接触 FUSE 的人容易被三样东西搞混:内核模块、libfuse、fusermount。
/dev/fuse是内核模块暴露的设备节点,用户态进程通过它收发请求。libfuse是一个用户态库,封装了协议细节和事件循环,你只需要实现函数指针。fusermount是一个辅助工具,负责挂载和卸载操作。由于挂载文件系统通常需要特权,fusermount往往以 setuid root 的方式安装在系统上,让普通用户也能挂载。
这意味着,即使你完全不深入协议细节,只要会写几个回调函数,就能拥有一个真正能跑的文件系统。libfuse 已经把最麻烦的管道部分消化掉了。
3. 从零实现一个可挂载的 FUSE 文件系统
3.1 环境准备与最小骨架
动手之前,先把环境确认一遍。我以最常见的 Ubuntu/Debian 为例:
# 安装 libfuse 开发包 sudo apt update sudo apt install libfuse2 libfuse-dev pkg-config # 确认设备节点存在 ls -l /dev/fuse如果你的发行版默认装的是 fuse3,也可以安装libfuse3-dev,但下面演示用的 API 是经典 libfuse2 风格。原因很简单:libfuse2 的示例代码每个人都能看懂,结构也最经典;fuse3 的 API 在回调签名上做了调整,思想不变。你装的时候留意一下版本即可。
接下来的例子是 FUSE 官方的 hello world 变体:挂载后,目录下只有一个/hello.txt文件,内容固定是Hello, FUSE!。
3.2 用 libfuse 写一个 hello 文件系统
新建文件hello.c,内容如下:
#define FUSE_USE_VERSION 26 #include <fuse.h> #include <string.h> #include <errno.h> #include <fcntl.h> static const char *hello_str = "Hello, FUSE!\n"; static int hello_getattr(const char *path, struct stat *stbuf) { memset(stbuf, 0, sizeof(struct stat)); if (strcmp(path, "/") == 0) { stbuf->st_mode = S_IFDIR | 0755; stbuf->st_nlink = 2; return 0; } if (strcmp(path, "/hello.txt") == 0) { stbuf->st_mode = S_IFREG | 0644; stbuf->st_nlink = 1; stbuf->st_size = strlen(hello_str); return 0; } return -ENOENT; } static int hello_readdir(const char *path, void *buf, fuse_fill_dir_t filler, off_t offset, struct fuse_file_info *fi) { (void)offset; (void)fi; if (strcmp(path, "/") != 0) return -ENOENT; filler(buf, ".", NULL, 0); filler(buf, "..", NULL, 0); filler(buf, "hello.txt", NULL, 0); return 0; } static int hello_open(const char *path, struct fuse_file_info *fi) { if (strcmp(path, "/hello.txt") != 0) return -ENOENT; if ((fi->flags & O_ACCMODE) != O_RDONLY) return -EACCES; return 0; } static int hello_read(const char *path, char *buf, size_t size, off_t offset, struct fuse_file_info *fi) { size_t len; (void)fi; if (strcmp(path, "/hello.txt") != 0) return -ENOENT; len = strlen(hello_str); if (offset >= (off_t)len) return 0; if (size > len - offset) size = len - offset; memcpy(buf, hello_str + offset, size); return size; } static struct fuse_operations hello_oper = { .getattr = hello_getattr, .readdir = hello_readdir, .open = hello_open, .read = hello_read, }; int main(int argc, char *argv[]) { return fuse_main(argc, argv, &hello_oper, NULL); }这段代码很短,但已经覆盖了 FUSE 最核心的几个回调:
getattr负责返回路径对应的属性,目录还是文件、权限是什么、文件多大。VFS 拿到这个结构体后才会继续后面的操作。readdir负责列出目录内容,向filler函数传入.、..以及目录下的文件名。open负责打开文件时的检查,比如只读文件不允许写打开。read是最关键的数据回调,必须正确处理size和offset参数。很多人写的简单示例忽略 offset,结果文件稍大一点就读出乱码,后面踩坑部分我会再提。
编译和挂载:
# 编译 gcc hello.c -o hello $(pkg-config --cflags --libs fuse) # 创建挂载点并挂载 mkdir -p /tmp/fuse-hello ./hello /tmp/fuse-hello正常情况下,命令会返回,进程转入后台运行。这时候打开另一个终端验证:
ls -l /tmp/fuse-hello cat /tmp/fuse-hello/hello.txt你会看到hello.txt,读取内容输出Hello, FUSE!。用mount | grep fuse-hello也能看到这个挂载点。要卸载,执行:
fusermount -u /tmp/fuse-hello如果提示target is busy,说明有进程还在这个目录里,用fusermount -uz /tmp/fuse-hello强制延迟卸载。
3.3 调试挂载中的问题
我第一次跑这个示例的时候也踩过坑,最常见的是fuse: device not found。这通常意味着当前环境没有/dev/fuse,或者当前用户没有访问权限。检查设备节点,必要时把用户加入fuse组:
sudo usermod -aG fuse $USER如果挂载后行为怪异,可以用调试模式把内核发来的请求打出来:
./hello -d /tmp/fuse-hello-d会让进程保持前台运行,并打印所有请求和响应内容。你会看到内核一次cat操作背后到底发了多少个请求:LOOKUP、GETATTR、OPEN、READ、FLUSH、RELEASE,一个都不少。这比读任何文档都直观。
另外,想快速验证一个想法的时候,我经常用 Python 的fusepy写原型:
from fuse import FUSE, Operations class HelloFS(Operations): def getattr(self, path, fh=None): if path == '/': return dict(st_mode=0o40777, st_nlink=2) if path == '/hello.txt': return dict(st_mode=0o100644, st_nlink=1, st_size=13) raise FileNotFoundError(path) def readdir(self, path, fh): return ['.', '..', 'hello.txt'] def read(self, path, size, offset, fh): if path == '/hello.txt': return b'Hello, FUSE!\n' raise FileNotFoundError(path) if __name__ == '__main__': FUSE(HelloFS(), '/tmp/fuse-hello', foreground=True)比 C 版本更接近「用伪代码写文件系统」的体验。做原型验证非常划算,生产环境再考虑换 Go 或 Rust 实现。
4. 性能账本:用户态文件系统为什么慢,又该怎么优化
4.1 一次操作要付多少「通行费」
FUSE 慢是出名的,但到底慢在哪?我们需要算一笔账。
普通内核文件系统的read流程:应用进程调用read(),陷入内核,VFS 把请求交给具体文件系统,拿到数据后返回。整个过程中,数据主要在进程地址空间和内核页缓存之间流动。
同样的操作走 FUSE:应用进程陷入内核,VFS 把请求交给 FUSE 内核模块,模块生成协议消息写入/dev/fuse,然后用户态守护进程被唤醒,从设备读出消息,调用你的回调,执行完把结果写回设备,内核模块再唤醒发起调用的应用进程。一趟下来至少有两次完整的用户态-内核态上下文切换,还有两次设备读写和进程唤醒。上下文切换一次大约几十微秒,请求调度又可能引入延迟,所以 FUSE 的额外开销通常在几十微秒到上百微秒量级。如果后端是网络存储,那网络延迟会直接叠加上来。
这不是说 FUSE 不可用,而是说你要意识到:延迟底线在协议层面就被抬高了。好在我们有几招可以补。
4.2 实用的性能调优手段
下面这些调优选项我在实践中都试过,列表便于对照:
| 挂载选项 / 手段 | 作用 | 副作用与注意事项 |
|---|---|---|
-o max_read=131072 | 允许单次 read 请求读更大的数据块 | 如果后端单次返回能力有限,反而浪费缓存 |
-o direct_io | 绕过内核页缓存,read/write 直达用户态 | 适合大文件顺序流,但小文件随机读会明显变慢 |
-o writeback_cache | 允许内核合并写请求,延迟落盘 | 数据可见性变弱,外部直接改底层数据时不建议开 |
| 开启 splice 传输 | 减少用户态和内核态之间的数据内存拷贝 | 对大数据块有明显收益,需要 libfuse 支持 |
| 多线程事件循环 | 避免单个慢请求阻塞其他请求 | 用户态必须自己做好锁和状态同步 |
| 合理设置 attr_timeout | 减少 GETATTR 请求频率 | 设太大可能导致外部修改长期不可见 |
举个例子:一个网盘类后端,读延迟本身就有几十毫秒。如果你单线程运行 FUSE,一个慢读请求会卡住队列,后面所有操作都跟着遭殃。改成多线程之后,至少其他文件还能正常访问。这是性价比最高的一步,代价是要在用户态里保证getattr、readdir这些回调对共享数据的并发安全。
另一个容易被忽略的点是内核页缓存。对于重复读取的场景,FUSE 内核模块本身会把已读数据缓存起来,第二次cat可能根本不会打扰用户态守护进程。所以在做性能测试的时候,一定要先区分是热数据还是冷数据,别把页缓存命中的功劳记在文件系统实现头上。很多 FUSE 项目实测「还挺快」,其实是缓存给的底气。
4.3 什么时候不该用 FUSE
我也见过把 FUSE 用错地方的案例,比如用它实现一个高频小文件的元数据存储,结果每stat一次都要跑一遍完整请求循环。频繁的ls -l、随机的文件打开关闭、大量小文件读写,这些场景对 FUSE 来说是最不利的。
如果需求是「高性能」优先,你应该认真考虑:要么让数据留在内核文件系统里,要么用io_uring之类的原生异步方案,要么接受 FUSE 的延迟并大量依赖缓存和预取。FUSE 的设计目标本来就是「把不可能变成可能」,而不是「把慢的变快」。想清楚这一点,你选型的心态会稳很多。
5. 生态盘点:那些你已经用过的 FUSE 文件系统
5.1 你大概率每天都在间接使用 FUSE
很多人不知道,自己在用的不少基础功能就是这么实现的。最典型的是 Android 的/sdcard分区,内部就是通过 FUSE 做隔离的,让每个应用只能看到自己授权的目录范围,底层存储的用户态实现可以灵活替换。
Linux 桌面上,ntfs-3g可能是传播最广的 FUSE 文件系统之一。没有它,你插上一个 NTFS 移动硬盘时基本没法可靠写入。早期内核里虽有 NTFS 驱动,但支持不完整、稳定性堪忧。用户态重写之后,维护和分发都简单了,普通用户也能读写 NTFS 磁盘。
sshfs则是另一个经典。sshfs user@host:/remote/path /local/mnt之后,远程目录就像本地目录一样操作。跳板机、临时拿文件、容器里访问宿主机目录,我用过很多次。它不追求性能,追求的是「一条命令就有」。
5.2 对象存储、加密和叠加文件系统
云上最热闹的是把对象存储挂到本地。
s3fs:把 S3 桶挂载成目录,但 S3 本身是对象存储语义,rename、文件锁这些操作都需要额外处理,性能一般。goofys/rclone mount:通过放宽部分 POSIX 语义来换取更高性能,适合大文件读写和媒体处理。JuiceFS:用对象存储存数据、用其他数据库存元数据,对 FUSE 的读路径和缓存做了大量优化,已经接近可以服务于生产业务的程度。
加密类也很有意思。gocryptfs和cryfs都允许你把一个普通目录加密之后挂载成明文目录。你把密文目录同步到云盘,本地看到的是解密后的内容。对隐私敏感的自托管用户来说,这类工具几乎是刚需。
叠加类工具,比如unionfs-fuse,可以把多个目录合成一个视图:上层目录可写,下层目录只读,类似 Docker 镜像分层。在做自动化测试或者多版本资源管理的时候,这个思路极其好用。
5.3 大数据和分布式场景里的 FUSE
大数据领域其实也大量在用 FUSE。HDFS 有一个 FUSE 实现,允许你把 HDFS 上的数据挂载成普通目录,这样无需修改业务代码,就能用标准的文件 API 访问分布式存储。对很多跑批任务和临时分析来说,省掉一堆 SDK 依赖是巨大便利。
不过这里要提醒一句:FUSE 挂载分布式存储时,POSIX 语义和分布式系统的一致性天生会有摩擦。比如某节点上的文件刚写还没刷下去,另一个节点立刻访问可能读不到。你需要理解后端存储的最终一致性模型,否则会收到一批「灵异」bug。
6. 实战踩坑记录:从死锁到 transport endpoint disconnected
6.1 回调里访问自己挂载点,递归把自己锁死
这是 FUSE 新手最容易踩的大坑,我到现在还记得第一次遇到的恐惧。当时写了一个缓存型 FUSE,在getattr回调里想检查一下底层文件的最新状态,于是直接调用了stat("/mnt/myfuse/somefile")。结果就是:进程完全没有响应,程序卡死,栈空间被无限递归耗尽。
原因很简单:/mnt/myfuse本身就是当前 FUSE 进程提供的挂载点,你在回调里访问它,会再次向/dev/fuse发起一个 FUSE 请求,而当前请求还没返回,新的请求又进来了,于是无限套娃。
排查链路:先用top看进程 CPU 占用接近 100%,strace -p PID看到卡在某个文件操作上反复执行,最后加日志才发现是递归调用。解决方法是:用户态实现里永远只能访问底层数据源,不能访问自己的挂载点。想判断文件状态,用后端 API 去做。
6.2 属性缓存导致文件改了但ls一直看不到
FUSE 默认会对getattr的结果做缓存。如果你的实现里attr_timeout设得太大,比如 1.0 秒以上,那外部的文件大小、修改时间变化就不会立刻反映出来。表现在终端上非常迷惑:明明底层文件变了,挂载点里ls -l显示的还是旧大小,cat却能读出新内容。
排查的时候我一度怀疑是页缓存问题,后来发现页缓存没有启用,问题是属性缓存。把attr_timeout调小到 0 或设成0.01后,立即恢复正常。个人经验:如果做的是「外部会频繁改动」的透传型文件系统,属性缓存宁可不要;如果是存量很大、只读为主的数据,可以开大一点换性能。
6.3 守护进程退出后,挂载点变成 transport endpoint is not connected
这是线上环境最高频的事故。用户态守护进程因为代码 bug 崩溃、被 OOM killer 干掉,或者容器重启,挂载点目录就会进入「死掉」状态。你在里面执行任何命令都会报:
Transport endpoint is not connected第一次遇到这个错误时,我第一反应是磁盘坏了,后来发现是 FUSE 进程不在了。排查顺序:ps aux | grep 你的程序名看进程在不在,dmesg | tail看有没有相关日志,最后确认守护进程确实挂了之后收拾残局。
卸载这种故障挂载点,普通fusermount -u可能提示 busy。我常用的恢复流程:
# 强制惰性卸载:断开连接后立刻返回 fusermount -uz /mnt/fuse-test # 或者查看占用进程 lsof +f -- /mnt/fuse-test # 确认卸载干净后重新挂载最好的预防措施是给用户态守护进程加 supervisor:systemd 里配置Restart=on-failure,让进程挂了能自动拉起。否则业务方会一直看到目录可用性异常,很难排查。
6.4 权限、并发和协议版本的边角问题
- 普通用户挂载后,其他用户想访问,需要
allow_other选项。但这玩意不是想开就能开的,系统fusermount会检查/etc/fuse.conf里的user_allow_other配置。容器环境里挂载 FUSE 还需要确保容器有/dev/fuse设备和必要的挂载能力。 - 多线程模式下,如果用户态回调里共享了可变状态,一定要加锁。我在并发压力下遇到过
readdir和getattr同时操作同一个 map 导致空指针崩溃的情况。 - 读文件的
offset必须正确实现。很多示例代码只把整个文件内容返回,不处理偏移,导致大文件读一半就开始乱码。正确做法是严格按offset和size切片返回。
折腾完这些坑之后,我的习惯变成了:新项目先写 C 或 Python 原型确认逻辑,再用 Go/Rust 这类带内存安全特性的语言做生产实现,最后配上 systemd 守护和指标监控。FUSE 的魔力在于它把文件系统的门槛拉到了普通工程师触手可及的位置,但也因为门槛低,很多底层语义需要你自己上心。如果你只是想快速把远程目录变成本地文件夹,可以先跑通sshfs或rclone mount感受一下;一旦决定自己动手写,就从上面的 hello 例子开始,把/dev/fuse和 libfuse 之间的关系摸明白,后面的事情会顺很多。