使用 Fuse 来进行 I/O 错误注入

在之前介绍 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]

你可能感兴趣的:(使用 Fuse 来进行 I/O 错误注入)