美文网首页go语言专题精选程序员
使用 Fuse 来进行 I/O 错误注入

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

作者: siddontang | 来源:发表于2018-06-02 14:02 被阅读206次

    在之前介绍 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 做更加复杂的控制。业内也有相关的一些开源实现,如果你对这块感兴趣,欢迎联系我 tl@pingcap.com

    相关文章

      网友评论

        本文标题:使用 Fuse 来进行 I/O 错误注入

        本文链接:https://www.haomeiwen.com/subject/rmrrsftx.html