详解 FUSE 用户态文件系统
原文链接 https://www.zido.site/blog/2021-11-27-filesystem-in-user-space/
用户态文件系统介绍
顾名思义,用户态文件系统就是运行在用户空间的文件系统,众所周知,在宏内核系统(像 Linux),文件系统代码都在内核中,典型的例子是想要挂载一个磁盘,你必须使用 Root 权限运行 mount
指令才行,而矛盾的地方在于,内核的发展是极其缓慢的,但是人们对定制文件系统的需求又日益剧增。于是 Linux 从 2.6.14 版本开始,支持了一个叫 FUSE 的内核模块。
FUSE (全称:Filesystem In Userspace.用户空间中的文件系统)是用户空间程序将文件系统导出到 Linux 内核的接口。FUSE 项目由两个组件组成:FUSE 内核模块(在常规内核存储库中维护)和用户空间库(libfuse)。
libfuse 也只是建议的参考实现,你也可以自行实现。
为什么一定要是用户态
- 不需要使用 root 用户去 mount 文件系统,如果你是非系统管理员,也能够 mount 一个用户态的文件系统。这催生了著名的 fuse-overlayfs,其在 rootless 容器化中扮演重要的角色,事实上 docker 当年一定要 root 守护进程的一个重要原因就是没有合适的用户态堆叠文件系统,导致架构不得不妥协,从而带来严重的安全隐患,再后来随着用户态文件系统的发展,docker 19.03 终于实验性地支持 rootless 模式
- 你不再需要去修改内核就能快速实现一个文件系统,例如非常著名的 sshfs,只要能连接 ssh ,你就能直接挂载一个远程的文件系统。FUSE 已经帮你做了很多的事情,你只需要专注于定制文件系统的基本逻辑即可。
用户态文件系统并不那么用户态
用户态文件系统并不那么的用户态,因为你的代码并不是真正在用户态去访问文件系统,它其实只是一层代理,当调用文件系统时,最先进去的仍然是 VFS,真正的文件 IO 操作最终还是由内核实现,而在用户态能做的,是作为一个守护进程,将内核传递的 fuse request 经由用户态进行处理,再调用 vfs 让内核去写数据。结构图如下:
但是这样的设计对于用户开发是有极大好处的:
- 用户无需关心文件系统底层细节,只需与 VFS 交互即可实现一个文件系统。
- 用户态崩溃不会影响到内核。
- 你只需要遵循协议,可以使用任意语言实现。
libfuse 的简单使用
libfuse 作为于内核 FUSE 模块通信的参考实现,其封装了所有内核请求。它将对用户暴露的 API 分为 low_level_api
和 high_level_api
,分别是:
- fuse.h,它是 lowlevel api 的再封装,基本屏蔽了大部分复杂性,只需要实现一部分 api 即可实现一个用户态文件系统,但是性能表现一般。大部分应用都不会选择使用 high level api 进行开发,毕竟用户态文件系统性能非常关键。
- fuse_lowlevel.h ,它是内核调用的浅封装转发,可控性更高,性能更好,但是需要对源码以及文件系统机制有一定了解。
我们使用 libfuse 官方的 hello.c 来测试一个小 demo,它就是使用 high_level_api
开发的:
$ gcc -Wall hello.c `pkg-config fuse3 --cflags --libs` -o hello
$ mkdir demo
$ ./hello demo
$ ls ./demo
hello
$ ls -al ./demo
drwxr-xr-x .
drwxr-xr-x ..
.r--r--r-- hello
$ cat ./demo/hello
Hello World!
$ touch ./demo/index.txt
touch: cannot touch './demo/index.txt': Function not implemented
$ echo 'a' > ./demo/hello
zsh: permission denied: ./demo/hello
$ fusermount -u ./demo
可以看到,代码自己模拟了一个叫 hello 的文件,并且可以读取其中的内容,但是无法写入,也无法在其中创建文件。
它的 fuse_ops
仅有几个有限的实现。
static const struct fuse_operations hello_oper = {
.init = hello_init,
.getattr = hello_getattr,
.readdir = hello_readdir,
.open = hello_open,
.read = hello_read,
};
通过 fuse_main
函数将 fuse_operations
传递到 libfuse
后,即可自动实现 daemon 化,大大缩减了开发成本,你仅需要关心几个有限的 fuse_operations
即可。
fuse 基本原理
FUSE 模块其实是一个简单的客户端-服务器协议,它的客户端是内核,用户态的守护进程就是服务端,内核模块会通过 vfs 暴露一个 /dev/fuse 的设备文件:
$ ls -l /dev/fuse
crw-rw-rw- root root 0 B Thu Nov 18 22:24:32 2021 /dev/fuse
这即是我们与 FUSE 内核模块交流的纽带。在我们的用户空间代码中,通过调用 open 函数获得文件描述符,凭此来读取请求并且写入响应来完成一次 IO 调用。不过需要注意的是,这个设备每次打开都会有一个不同的 session,第二次调用 open 的时候并不能访问到第一次调用 open 的资源。
仍然以上面的hello
为例,我们使用 strace ./hello ./demo
来看一下做了那些系统调用:
...
openat(AT_FDCWD, "/dev/fuse", O_RDWR|O_CLOEXEC) = 3'
...
mount("hello", "/home/zido/Projects/c/hello-fuse/demo", "fuse.hello", MS_NOSUID|MS_NODEV, "fd=3,rootmode=40000,user_id=1000"...) = -1 EPERM (Operation not permitted)
...
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fc6ded9fe50) = 32500
可以清晰的看到,其中主要是打开 /dev/fuse ,然后通过其文件描述符来调用 mount 函数,最终再通过 clone 实现 daemon 化。之后就是 FUSE 内核与应用程序之间的交互了。
fuse 协议
既然是客户端-服务器协议,那么与 http 协议一样,每一个从内核到用户空间的请求也都有一个请求头 fuse_in_header
,它的大小是固定的,定义如下:
struct fuse_in_header {
uint32_t len; /* 数据长度:包括 header 在内 */
uint32_t opcode; /* 操作码*/
uint64_t unique; /* 唯一请求 id */
uint64_t nodeid; /* 被操作的文件系统对象(文件或目录)的 ID */
uint32_t uid; /* 请求进程的 uid */
uint32_t gid; /* 请求进程的 gid */
uint32_t pid; /* 请求进程的 pid */
uint32_t padding;
};
这在 内核的 fuse.h 头文件和 libfuse 下的 fuse_kernel.h中都有定义。
接着在请求头之后,则紧跟着请求体,请求体的长度是可变的,它的具体类型可以通过 opcode
来进行判断,它表示了操作的类型,主要就凭借它来识别内核到底想要干嘛,你应该回复什么消息,所有的 opcode 在 libfuse 中均有 定义。
例如,如果 opcode 为 15 (FUSE_READ
),则后面紧跟着 fuse_read_in
结构体。不过为了更详细的讲解,我们拿一个更全面的 rename 操作来说明。rename 的 buf 结构长这样 {fuse_in_header}{fuse_rename_in}{oldname}{newname}
。它的解析过程略有意思:
- 读取 header,直接读取
fuse_in_header
,类似这样:struct fuse_in_header *in = (struct fuse_in_header *) buf;
- 判断 opcode 为 15,接下来读取
fuse_rename_in
类似这样:buf += sizeof(struct fuse_in_header);struct fuse_rename_in *arg = (struct fuse_read_in *) buf;
- 除此之外,它的结构还跟着 oldname 和 newname,分别这样读取
char* oldname = (((char*)buf) + sizeof(*buf));char* newname = oldname + strlen(oldname) + 1;
通过以上步骤,我们就完成了数据的读取,很多的 opcode 都有自己独特的读取方式,这个还是要根据 fuse.4 文档中的来,我这里只做了一点示例。
读取完请求之后,我们就需要做相应的处理然后写入请求了。如果我们只是简单的代理文件夹,那么可以就可以直接调用系统的 rename 函数了,因此,这里的处理函数就只需要这样就可以:
#include <fcntl.h>
#include <stdio.h>
void handle(void *buf) {
struct fuse_in_header *in = (struct fuse_in_header *) buf;
if in.opcode == 15 {
buf += sizeof(struct fuse_in_header);
struct fuse_rename_in *arg = (struct fuse_rename_in *) buf;
char* oldname = (((char*)buf) + sizeof(*buf));
char* newname = oldname + strlen(oldname) + 1;
//这一句才是实际处理,而 renameat 又调回了内核,因此你仍然无法接触任何内核的东西。但是你可以实现你自己的其他逻辑。
//当然实际情况一般都是代理其他文件夹,所以这里不可能这么写,只是做一个示例。
renameat(req, fuse_in_header->nodeid, oldname, arg->newdir, newname, 0);
}
}
这样就实现一个基本的改名函数,当然了,上面实现了请求消息解析,但是还缺少写入响应,所以内核还并不知道你处理的结果是怎样的。
fuse 协议的响应头叫 fuse_out_header
,长这样:
struct fuse_out_header {
uint32_t len;
int32_t error;
uint64_t unique;
};
它包含一个错误码和一个跟请求保持一致的唯一 id,用于标识是哪个请求的响应。接着在之后紧跟着响应体(如果有)。如果错误码不为 0,则不应该包含响应体。具体怎么写其实和req 的读取类似,就不再赘述了。
最后
即使你不是使用 C 语言,仍然可以参考 libfuse 自行实现,不过 GitHub
中已经有很多开源开发者做了,他们或是自行实现或是封装 libfuse。我这里列举自己比较关心的 Go
和 Rust
语言的相关库:
FUSE 是云原生与容器化的一个重要模块,它几乎是实现 rootless 文件系统的唯一选择,由此发展了 fuse-overlayfs 帮助容器实现 rootless 模式 。而对于我们普通开发者而言,我们也可以通过用户态文件系统来扩展自己想要的功能。
考古