在之前介绍 SystemTap 的文章中,我提到了我们使用 SystemTap 做了很多 I/O 错误注入的工作,但也有一些局限,譬如:
- Delay 的时间如果过长,就可能导致 SystemTap 出错。
- 不支持动态调节,如果需要将 delay 的时间从 100 ms 调整到 200 ms,只能重新启动 SystemTap 脚本。
- 不能很好的支持精确的控制,譬如对某一个文件进行限流控制,对另一个文件进行错误注入。虽然能做,但脚本写起来并不简单。
虽然 SystemTap 很简单,但有时候,我们需要另一套机制。这里,我们参考了 Namazu,使用了 Fuse 对 I/O 进行错误注入。
什么是 Fuse
Fuse 是一个用户态文件系统框架。得益于 Fuse 简单的 API,很多其他的文件系统都是基于 Fuse 来开发的,自然,我们也可以基于 Fuse 来开发一个自己的 I/O injection 文件系统。虽然 Fuse 能在很多操作系统上面使用,但这里我们仍然聚焦在 Linux。
下图是 Fuse 架构,主要参考 To FUSE or Not to FUSE: Performance of User-Space File Systems 这篇 Paper:
Fuse 包含两个部分 - kernel 和用户态 daemon。内核部分是一个 Linux 的内核模块,它会在 Linux 的 VFS 上面注册一个 Fuse 的文件系统驱动。这个 Fuse 驱动可以认为是一个 proxy,会将请求给转发到后面的用户态 daemon 上面。
Fuse 内核模块也会注册一个 /dev/fuse
的块设备,这个就是 kernel 和用户态 daemon 交互的接口。通常 Daemon 会从 /dev/fuse
上面读取到 Fuse 的请求,处理并且将数据写回到 /dev/fuse
。一个简单的 Fuse 流程如下:
- 应用程序在挂载的 Fuse 的文件系统上面进行操作。
- VFS 会将操作转发到 Fuse 的 kernel driver 上面。
- Fuse 的 kernel driver 分配一个 request,并且将这个 request 提交到 Fuse 的 queue 上面。
- Fuse 的用户态 daemon 会从 queue 里面通过读取
/dev/fuse
将这个 request 取出来并且处理。这里需要注意,处理 request 的时候仍然可能进入 kernel,譬如可能将 request 发到 Ext4 去实际处理。 - 当请求处理完毕,Daemon 会将结果写回到
/dev/fuse
。 - Fuse 的 kernel 标记这个 request 结束,然后唤醒用户应用程序。
Go Fuse
上面可以看到,如果我们要实现自己的文件系统,主要就是实现我们自己的 daemon,而这个其实就是搞定 Fuse 的 User-Kernel 协议就可以。这里我不准备详细介绍 Fuse 的协议,以及 Fuse 的底层实现,后面有机会可以再写一篇。而是会直接切入,讲讲如何用 Fuse。
得益于 Fuse 的广泛使用,几乎所有的语言都有 Fuse 的支持了,在 Go 里面,两个比较知名的项目,一个是 go-fuse,另一个是 fuse,这里,我是用 go-fuse 来说明如何构建一个 zip 文件系统。
首先我们生成一个简单的 zip 文件,压缩之前目录如下:
a/
a/a.log
b.log
A.log 和 b.log 的内容都是 “123”。然后我们希望,将这个 zip 文件挂载到一个目录,能让我们按照普通的文件访问方式来访问这个 zip 文件。譬如,我们挂载到目录 m,可以进行如下操作:
➜ m ls
a b.log
➜ m cat b.log
123
➜ m cat a/a.log
123
那么这个是如何做到的呢?使用 go-fuse,我们可以非常简单的做到。我们仅仅需要实现自己的一个文件系统,然后挂载到某一个路径下面就可以了,首先来看看挂载的代码:
root, _ := zipfs.NewArchiveFileSystem("test.zip")
opts := &nodefs.Options{
AttrTimeout: time.Second,
EntryTimeout: time.Second,
}
state, _, _ := nodefs.MountRoot("m", root, opts)
state.Serve()
上面我们使用 nodefs 的 MountRoot 函数将一个 zip 文件系统挂载到了路径 “m” 上面。那么这个 zip 文件系统是如何实现的呢?这里,我们要做的就是使用 Go 自带的 archive/zip
包解析出 zip 里面的目录结构,一个简单的例子:
r, _ := zip.OpenReader("./test.zip")
defer r.Close()
for _, f := range r.File {
log.Printf("name: %s, is dir %v", f.Name, f.FileInfo().IsDir())
}
然后我们根据 zip 包的目录结构,先用 nodefs.NewDefaultNode()
创建一个 root node,再依次递归,不断的调用 node 的 NewChild
函数生成一个文件目录树。具体的代码在 zipfs,代码比较简单,这里不再详细描述。
Hook I/O
可以看到,使用 go-fuse 我们可以非常方便的构建自己的文件系统,那么对于我们的 I/O failure injection 文件系统来说,要做什么事情呢?其实也就是非常简单,就是能 hook 到所有的 I/O 操作,然后在里面注入错误就可以了。这个,在 go-fuse 里面,我们可以创建一个 Loopback 的文件系统就可以了。Loopback 的文件系统,就是将我们所有的 I/O 请求给重新发送到实际底层的文件系统上面。这个比较类似于对一个目录进行 soft link,然后你再这个 soft 目录里面进行的任何操作也会影响到实际的原始目录。
Namazu 已经帮我们提供好了好了封装,就是 hookfs,在 hookfs 里面,它 hook 了大部分的 I/O 操作(后面我会逐渐添加完善),我们仅需要的是实现自己的接口,任何的 I/O 操作就能调用到我们自己的对应函数里面进行处理了。
Delay Example
下面,我们来使用 hookfs 做一个简单的 delay 功能,默认任何的 read,我们都会 delay 1s,当然,也可以动态调整。
因为我们是想 delay read,所以我们需要实现下面的接口:
type HookOnRead interface {
// if hooked is true, the real read() would not be called
PreRead(path string, length int64, offset int64) (buf []byte, err error, hooked bool, ctx HookContext)
PostRead(realRetCode int32, realBuf []byte, prehookCtx HookContext) (buf []byte, err error, hooked bool)
}
代码比较简单:
type MyHookContext struct{}
type MyHook struct {
dur time.Duration
}
func (h *MyHook) PreRead(path string, length int64, offset int64) ([]byte, error, bool, hookfs.HookContext) {
time.Sleep(h.dur)
return nil, nil, false, MyHookContext{}
}
func (h *MyHook) PostRead(realRetCode int32, realBuf []byte, prehookCtx hookfs.HookContext) (buf []byte, err error, hooked bool) {
return realBuf, nil, false
}
我们在 PreRead
里面 sleep 了特定的时间,然后我们启动 hookfs:
h := &MyHook{
dur: time.Second,
}
fs, _ := hookfs.NewHookFs(originPath, mountPath, h)
fs.Serve()
上面默认的 delay 时间是 1s,然后我们可以添加一个 HTTP 服务来动态修改 delay 时间,当然这里没考虑 data race 问题:
go func() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
h.dur, _ := time.ParseDuration(r.FormValue("dur"))
})
http.ListenAndServe("127.0.0.1:8080", nil)
}()
然后我们来看实际效果,我们将 /tmp/b
目录给挂载到 /tmp/a
目录,该目录里面有一个文件 “a.log”:
time cat /tmp/a/a.log
124
cat /tmp/a/a.log 0.00s user 0.00s system 0% cpu 1.002 total
可以看到,上面的 cat
耗时 1s。然后我们动态修改时间,在继续:
curl http://127.0.0.1:8080\?dur\=2s
time cat /tmp/a/a.log
124
cat /tmp/a/a.log 0.00s user 0.00s system 0% cpu 2.002 total
我们将耗时改成了 2s,实际 cat
也耗时 2s 了。
注意:如果我们不测试了,需要调用 fusermount -u /tmp/a
。
Epilogue
使用 Fuse,我们可以非常方便对 I/O 进行模拟。当然,上面仅仅是一个非常简单的例子,实际的模拟功能会非常的强大,譬如进行限流,注入错误,修改数据等,甚至集成 Lua 做更加复杂的控制。业内也有相关的一些开源实现,如果你对这块感兴趣,欢迎联系我 [email protected]。