简述

今天我介绍一个储宝的新成员--内核态客户端给大家认识认识。linux 内核是一个代码非常优雅精美的作品。我们就简单聊聊内核和客户端的关系,linux 内核的高手可以略过本文。

linux 内核的一个核心模块就是 VFS,它提供一系列的接口函数。存储驱动程序只需要实现这部分接口函数,就完成了数据的写入和读取。简单来说,就是 linux 内核像是一台精密的机器。想要成为它里面的一部分组件,你就必须精确地实现它暴露出来的 VFS 接口。

所以很形象地跟你说,cubefs 的内核客户端本质上就是对接了 VFS 的接口,然后把数据发送到存储服务器上面。当然,这个只是很简洁的说法。实际上,内核客户端的作者(这个作者不是我)通过参考 cubefs 的用户态客户端的实现,把副本模式的实现机制用内核 c 程序写了一遍,真的是非常了不起。这里面涉及的工作,包括了 json 包处理,数组,链表,内核特有的函数接口等等,真的是非常复杂。

在这里跟大家强调两点。第一点,实现 VFS 接口,对接上 linux 精美的内核。第二点,通过网络连接,对接上面 master,metanode,datanode 等其它组件。当然,可能还有一些小的遗漏,我们就不提了。

总体框架

介绍一个软件,我们不能不提整体框架。这样大家才有一个从上而下的全面了解机会。但是对于 cubefs 内核客户端来说,它和其它类似的文件系统都是一样的。就像是 ext3 和 nfs 这些文件系统,差别是 cubefs 内核客户端存放和读取数据是在 cubefs 的集群上面。所以大家可以略过这个介绍。

图片

上图中绿色的方块 cubefs client 就是 cubefs 内核客户端在内核系统中所处的位置。和其它的 linux 内核文件系统类似,它是 VFS 下面的一个子模块。

内核客户端模块

接下来我们开始深入内核客户端的各个模块。当然文章的篇幅是有限的,大家也不喜欢长篇大论,我们就重点讲一下其中的数据流过的模块。其它的如果大家感兴趣,可以自行阅读代码。想要了解一个软件,阅读它的源代码无疑是深入骨子的方法。当然,代价就是太让人头痛了。

Cubefs 内核客户端的模块包含如下几大部分:

图片

我们就重点讲讲上图中标上颜色的几个模块。当然,一个完整的驱动程序还包含其它的一些小细节,比如 json 解析,比如 b 树的操作等等。你没看错,内核客户端其实就是一个驱动程序。它是 cubefs 存储集群的 linux 驱动程序。

详细设计

模块初始化

其实内核客户端的模块初始化和别的模块初始化就是一样的,只是里面实现的内容是针对 cubefs 的功能。不感兴趣的读者可以忽略过去这章节。

从技术的角度来描述:介绍一个内核模块,不能不提到模块的 init 和 exit 函数。这部分代码和其它 linux 模块的写法一模一样,却是初学者看代码的最佳入口。Cubefs 内核客户端的模块初始化和卸载代码在 cfs_core.c 里面。初始化函数是 cfs_init,卸载函数是 cfs_exit。

这部分代码都是其它模块需要一开始就初始化的代码,对此我们就不再一一详述。

需要重点说明的是因为 RDMA 模块需要的缓存用户不一定需要。我们只能在用户挂载时开启了 enable_rdma 才知道创建缓存。所以该 RDMA 缓存初始化的实现放在创建挂载的函数里面。

VFS 接口

这个 VFS 的接口也不应该是我们介绍的重点。只是提到了存储驱动,就不能不说 VFS。其实,它的核心就是实现了 VFS 的一系列接口,然后就可以注册挂载到内核。就相当于你要制作一辆汽车,4 个轮子是必须的。VFS 的接口就相当于汽车的 4 个轮子。

大家可以忽略这章节的技术描述。

从技术的角度来描述:和其它对接 VFS 的文件系统类似,cubefs 内核客户端也实现一系列的函数接口。通过这些函数接口,cubefs 内核客户端就嵌入到 linux 内核,成为 VFS 下面的一个子模块。这部分函数挂载的定义不需要实现全部的函数,只需要实现其中主体部分。因为 linux 内核源码的更新会导致挂载的定义有所不同,我们只以 3.10 的内核对应的定义来介绍。其它版本的定义大同小异,只是稍微有所不同而已。

首先是包含读写操作,以及 direct IO 的函数接口。

const struct address_space_operations cfs_address_ops = {
    .readpage = cfs_readpage,
    .readpages = cfs_readpages,
    .writepage = cfs_writepage,
    .writepages = cfs_writepages,
    .write_begin = cfs_write_begin,
    .write_end = cfs_write_end,
    .set_page_dirty = __set_page_dirty_nobuffers,
    .invalidatepage = NULL,
    .releasepage = NULL,
    .direct_IO = cfs_direct_io,
};

接下来是文件操作的接口:

const struct file_operations cfs_file_fops = {
    .open = cfs_open,
    .release = cfs_release,
    .llseek = generic_file_llseek,
    .aio_read = generic_file_aio_read,
    .aio_write = generic_file_aio_write,
    .mmap = generic_file_mmap,
    .fsync = cfs_fsync,
    .flush = cfs_flush,
};

以及文件 inode 的接口:

const struct inode_operations cfs_file_iops = {
    .permission = cfs_permission,
    .setattr = cfs_setattr,
    .getattr = cfs_getattr,
};

最后是超级块的接口和 cubefs 文件系统的定义:

const struct super_operations cfs_super_ops = {
    .alloc_inode = cfs_alloc_inode,
    .destroy_inode = cfs_destroy_inode,
    .drop_inode = cfs_drop_inode,
    .put_super = cfs_put_super,
    .statfs = cfs_statfs,
    .show_options = cfs_show_options,
};

struct file_system_type cfs_fs_type = {
    .name = "cubefs",
    .owner = THIS_MODULE,
    .kill_sb = cfs_kill_sb,
    .mount = cfs_mount,
};

其它的像是目录操作,以及目录 inode 的接口,链接文件的接口,特殊文件的接口,目录缓存等定义,我们就略过。 总的来说,实现内核文件系统就是实现这一系列的 VFS 接口。通过模块初始化时的配置,把这部分接口函数设置到内核,然后就对接上 linux 内核文件系统。

挂载流程

这个挂载流程也是非常标准的写法。就是 cubefs 模块加载到内核时,注册一个文件系统叫 cubefs。然后超级用户 root 就可以用 mount 命令来挂载网络存储。这个流程和别的文件系统的使用没区别。就像是汽车的品牌有很多,但是汽车的驾驶方法是一样的。这个挂载流程其实就相当于汽车的驾驶方法。

从技术的角度来描述:内核客户端在初始化函数 cfs_init 里面注册了 cubefs 类型的文件系统,其代码如下:

    ret = register_filesystem(&cfs_fs_type);
    if (ret < 0) {
        cfs_pr_err("register file system error %d\n", ret);
        goto exit;
    }

其中 cfs_fs_type 的定义如下:

struct file_system_type cfs_fs_type = {
    .name = "cubefs",
    .owner = THIS_MODULE,
    .kill_sb = cfs_kill_sb,
    .mount = cfs_mount,
};

其中 cfs_mount 是挂载时调用的函数,cfs_kill_sb 则是卸载时调用的函数。下面我们简单描述一下这个挂载的流程。 图片

其中的 cfs_fs_fill_super 就是填充超级块的函数。有了超级块的信息,就可以对挂载的目录进一步操作。

设置了超级块以后,对挂载目录的操作就通过 VFS 对接的函数,比如打开文件的 cfs_open, 关闭文件的 cfs_release,读取文件内容的 cfs_readpages,写入文件内容的 cfs_write_pages, cfs_write_begin, cfs_write_end,以及直写直读的 cfs_direct_io。

读写流程

数据的写入和读取可以说是存储文件系统的核心路径。就好像燃油车一样,油通过管道进入发动机,然后点燃引爆,最后产生动力。在内核客户端这里,这个最后两个详细设计的章节都是比较复杂的。如果有对这部分流程感兴趣的同学,可以对照着代码阅读。

简单地说,写入数据到达内核客户端有两种方式。第一种是数据已经存放到 page 页里面。第二种是数据还是用户空间,只是告诉驱动程序空间地址和长度。第二种方式就是俗称的直接写入存储的模式。

无论是哪一种方式,驱动程序都是通过计算的方式,得到每次发送的最大长度(tiny 模式是 1MB,normal 模式是 128KB)。然后把这些数据一一发送出去。

不同的地方是,第一种方式每次只发送一个 4K 页面大小。第二种则是临时申请一个内存空间,把用户的数据拷贝到内核,然后再按照最大长度发送出去。

如果是对技术不感兴趣的同学,了解到上面的描述也就足够了。更不用去查看低下那个长长的数据模块流水图。

从技术的角度来描述:cubefs 的 IO 读写流程根据是否启动 direct,读写类型,随机或者顺序,普通或者小文件这些要素有所不同,但是其基本实现原理都是一致。大致的步骤都是先更新 cache,接着生成 extent 的列表,然后截取最大 1MB 的长度(小文件),或者 128KB(普通文件)的长度,发送写入或者读取请求,最后是接收响应报文,更新 cache 和 meta 信息(写流程的步骤)。

因为这些流程是大同小异,所以我们只介绍一下直接写的流程。

cubefs 内核客户端使用 cfs_direct_io 挂载到结构体 struct address_space_operations cfs_address_ops。其实现如下:

const struct address_space_operations cfs_address_ops = {
    .readpage = cfs_readpage,
    .readpages = cfs_readpages,
    .writepage = cfs_writepage,
    .writepages = cfs_writepages,
    .write_begin = cfs_write_begin,
    .write_end = cfs_write_end,
    .direct_IO = cfs_direct_io,
};

无论写还是读的入口函数都是 cfs_direct_io。该函数的真正实现则是 cfs_extent_direct_io。 在 cfs_extent_direct_io 里面,先是更新了一下 cache,然后根据 cache 信息创建一个 extent 信息的列表。然后根据文件操作类型,依次写或者读数据。其中写流程的数据流是把数据从用户空间拷贝到内核,然后通过网络 socket 发送出去。

其中用户写入数据的用户空间地址是下面的参数 iov,文件偏移是 offset,文件信息在 iocb 里面,长度则是 iov_length(iov, nr_segs)计算:

static ssize_t cfs_direct_io(int type, struct kiocb *iocb,
                 const struct iovec *iov, loff_t offset,
                 unsigned long nr_segs)

如果在支持 iov_iter 的内核版本里面,参数更加简洁。文件信息在 iocb,偏移是 offset,用户空间的起始地址,长度都在 iter 里面:

static ssize_t cfs_direct_io(struct kiocb *iocb, struct iov_iter *iter,
                 loff_t offset)
{
    struct file *file = iocb->ki_filp;
    struct inode *inode = file_inode(file);

    return cfs_extent_direct_io(CFS_INODE(inode)->es, iov_iter_rw(iter), iter, offset);
}

函数 cfs_extent_write_iter 实现了直接写的主要功能。直接写和顺序写同样分成 3 种类型:随机写,小文件模式,普通模式。 简要地说,直接读写的流程和顺序读写的流程有很多相似的地方。主要区别是直接读写是直接拷贝用户空间的数据到内核申请的内存。顺序读写则是通过 VFS 先把数据缓存到 page 里面。这个也就造成了直接读写的 socket 报文最大长度可能达到 1MB,顺序读写的数据报文只有 4KB(一个 page 的大小)。

图片

流水设计

这部分只是给那些喜欢技术细节的同学一个简单的讲解,大家可以略过。就好像喜欢汽车的人比比皆是,但是研究空气动力学来设计汽车外形的永远只是极少数人。

从技术的角度来描述:顺序写和顺序读都是流水式的流程。这两个有很多类似的,所以这里只描述一下顺序写的流程图。

每一个 cfs_extent_writer 都是一条顺序写的流水线。它重要的成员变量有:

struct cfs_extent_writer {
    struct list_head tx_packets; //发送队列
    struct list_head rx_packets; //接收队列
    atomic_t write_inflight; //流水线中正在处理的报文数量
    volatile unsigned flags; //本 writer 的状态
    struct cfs_extent_writer *recover; //修复流程
    //其余成员忽略
};

它包含的函数以及功能如下介绍:

cfs_extent_writer_new //创建流水线
cfs_extent_writer_release //释放流水线
cfs_extent_writer_flush //刷入流水线
cfs_extent_writer_request //发送写请求
extent_writer_tx_work_cb //处理 request 的报文
extent_writer_rx_work_cb //等待 reply 的报文
extent_writer_recover //修复流程

其主要功能的示意图如下: 图片

通过并发处理两个流水线 extent_writer_tx_work_cb 和 extent_writer_rx_work_cb,就实现了报文的整体流水线处理方式。主要实现的功能是第一个流水线发送报文,然后在第 2 条流水线等待该报文的响应。

因为引入了修复流程,所以这个流水线变得比较复杂。为了简化流程,我们在修复流程中不再使用流水线的方式,而是每次发送一个报文,都等待该报文成功返回。这种方式效率不如流水线方式,但是简化了设计和代码,也就减少错误的故障。在修复流程是少数场景中,这种设计方案是合理的。

实践

了解一个软件,阅读代码是深入到骨头的方法,但是这个太浪费时间精力了。所以,一种简便的方式就是我们动手去搭建一个环境,使用这个软件,从而得到亲身体会。用一用软件,永远都是最快的学习方法。就好像一辆车,我们不需要去学习它里面的科学知识,我们只要会开车就达到目的。

在进行编译和装载之前,感兴趣的读者可以去阅读源码中的文档 Chinese_readme 和 readme 这两个文章,里面详细说明了内核模块的编译和使用方法。为了文章的完整,我们在这里简单描述一下编译,装载,和试用。

编译

我希望用内核态客户端的同学能够自己进行编译。因为不同的内核会有细节方面的差异,而且我们的软件只支持其中的一部分内核版本。实在是没有精力去适配很多内核版本。这个也是其它的内核存储客户端的做法。希望大家谅解。当然,这部分我们验证好的 linux 内核,在 readme 文档里面有详细记录。

在 cubefs/client_kernel 目录下,运行命令./congfigure。这条命令会根据当前系统的配置,生成一个宏定义文件 config.h,里面包含了一些编译用到的宏。这个只需要在第一次编译时需要用到。后续编译环境没有变化,就不需要重新运行。这个和其它 linux 函数编译是一样的。

接着运行 make,就生成了内核模块 cubefs.ko。如果不需要 RDMA 的接口,也可以通过命令 make no_rdma 来得到不带 RDMA 接口的模块。目前内核客户端RDMA接口对接的不是发布的release-3.4.0-beta_rdma,而是一个内部未公开的版本。

清理编译的命令是:make clean。

装载

比如加载一个 master 地址:10.177.182.171:17010,10.177.80.10:17010,10.177.80.11:17010 卷名称是 whc,用户是 whc。

加载 cubefs 模块的命令:

mount -t cubefs -o owner=whc,dentry_cache_valid_ms=5000,attr_cache_valid_ms=30000 //10.177.182.171:17010,10.177.80.10:17010,10.177.80.11:17010/whc /mnt/cubefs

试用

在上面的命令运行成功以后,读者就可以在目录/mnt/cubefs 下面进行文件的各种操作。当然,这个的前提是服务端整套系统都搭建完毕。

结尾

内核客户端是一个崭新的软件,是 cubefs 软件新增的功能。目前它还只提供了有限的功能和应用。将来可能会有别的同学完善它的 EC 模式的实现,以及解决其中的 bug。需要经过一段时间的打磨,软件才会成熟。

在内核客户端的基础上面,我们可以进一步提供内核 RDMA 的功能,最后是 GDS 的功能。此外,内核客户端因为不需要 fuse 进行数据的转发,在性能上面有优势。当然因为内核使用 4K 的 page 大小进行发送报文,导致测试出来的速率实际没有很大提升,但是在大块文件的直接读写方面却是能够提供有优势的性能。

作者介绍:

Wu Huocheng, CubeFS Maintainer, 当前在 CubeFS 项目参与内核态客户端和 RDMA 开发工作。

文章摘要【20-30 字左右】:

本文简单地介绍了 cubefs 内核态客户端的实现机制,是一个浅显的技术读物。我们只是很简洁地介绍整个数据的逻辑框架。感兴趣的同学可以参考流水线图,深入研究代码。

在github上编辑