NFS-Ganesha源代码分析

finding-the-right-balance-security-vs-performance-with-network-storage-systems-14-638

NFSv4简要概述

NFS这个协议(NFSv2)最初由Sun Microsystems在1984年设计提出,由于存在一些不足,因此在随后由几家公司联合推出了NFSv3。到了NFSv4时,开发完全由IETF主导,设计目标是:

– 提高互联下的NFS访问和性能
– 提供安全性
– 更强的跨平台操作
– 方便后期扩展

我们可以看到NFSv4在缓存能力、扩展性、高可用性方面取得了很大的突破,放弃了之前版本的无状态性,采用了强状态机制,客户端和服务端采用了复杂的方式交互,由此保证了服务器端的负载均衡,减少了客户端或服务端的RTO。

在安全性方面,NFSv4采用了面向连接的协议,强制使用RPCSEC_GSS并且提供基于RPC的安全机制。放弃了之前版本中采用的UDP,采用了TCP。NFSv4支持通过次要版本进行扩展,我们可以看到在NFSv4.1支持了RDMA、pNFS范式以及目录委派等功能。

 

NFS-Ganesha

2007年左右,CEA的大型计算机中心每天都会产生10TB左右的新数据,CEA将这些数据放在由HSM组成的HPSS中,而这些HSM本身提供了NFS接口。但是开发者在生产环境中发现HSM和NFS的桥接仍旧有不少问题,因此开发者决心写一个新的NFS Daemon来让NFS接口更好的配合HPSS。

这个项目需要解决以上的问题之外,开发团队还指定了其他目标:

– 可以管理百万级别的数据缓存,从而来避免底层文件系统阻塞
– 除了可以对接HPSS以外,还可以对接其他文件系统
– 支持NFSv4,实现易适配(adaptability),易扩展,安全等特性
– 从根本上解决软件所带来的性能瓶颈
– 开源
– 支持Unix系统

由此NFS-Ganesha应运而生,它并不是用来替代内核版本的NFSv4,相反,NFS Ganesha是一个全新的程序,可能对比kernel版本的NFSv4,Ganesha的性能有所欠缺,但是基于user-space的方法会带来更多有意思的功能。

灵活的内存分配

首先,user-space的程序可以分配大量的内存让程序使用,这些内存可以用来建立软件内部缓存,经过测试,我们只需要4GB来可以实现百万级别的数据缓存。在一些x86_64平台的机器上,我们可以分配更大的内存(16 32GB),来实现千万级别的数据缓存。

更强的可移植性

如果NFS Ganesha是kernel-space的话,那样NFS Ganesha的内部结构只能适应一款特定的OS,而很难移植到别的OS上。另外考虑的是代码本身:在不同的平台上编译和运行的产品比在一个单一平台上开发的产品更安全。 我们开发人员的经验表明,只在单一平台上开发会让开发后期困难重重; 它通常会显示在Linux上不会轻易检测到的错误,因为资源不一样。

当然可移植性不单单指让NFS Ganesha可以运行在不同的OS上,能够适配不同的文件系统也是考量之一。在NFSv2和NFSv3中,由于语义设计上偏向Unix类的文件系统,因此基本不可能适配非Unix类的文件系统。这一情况在NFSv4中大有改观,NFSv4的语义设计出发点是让NFS能尽可能多地适配不同的文件系统,因此加强了文件/目录属性参数的抽象。Ganesha设计初衷是成为一个NFSv4通用服务器,可以实现NFSv4的所有功能,因此也需要适配各种文件系统。在内核中实现这一功能是不容易的(内和编程会有很多限制),然而在user-space中实现这一点会便捷一些。

更便捷的访问机制

内核中的NFSv4访问用户空间中的服务不是那么方便,因此其引入了rpc_pipefs机制, 用于解决用户空间服务的桥梁,并且 使用kerberos5管理安全性或idmapd守护程序来进行用户名转换。然而Ganesha不需要这些,它使用常规API来对外提供服务。

对接FUSE

由于NFS Ganesha是一个运行在用户空间的程序,因此它还提供了对一些用户空间文件系统(FUSE)的支持,可以让我们直接把FUSE挂载在NFS上而不需要内核的帮助。

 

NFS-Ganesha框架浅析

NFS Ganehsha是完全使用开源自由软件开发的,由于Linux上的系统编程开发资源巨大,因此开发起来比在其他Unix系统上更为便捷。

ganesha_arch

Figure 1 – NFS Ganesha分层架构图

由上图我们可以看到,Ganesha是一个基于模块的程序,每个模块都负责各自的任务和目标。开发团队在写代码之前就对每个模块进行了精心的设计,保证了后期扩展的便捷性。比如缓存管理模块只负责管理缓存,任何在缓存管理模块上做出的更改不能影响其他模块。这么做大大减少了每个模块间的耦合,虽然开发初期显得困难重重,但在中后期就方便了很多,每个模块可以独立交给不同开发人员来进行开发、验证和测试。

Ganesha的核心模块

Memory Manager: 负责Ganesha的内存管理。
RPCSEC_GSS:负责使用RPCSEC_GSS的数据传输,通常使用krb5, SPKM3或LIPKEY来管理安全。
NFS协议模块:负责NFS消息结构的管理
Metadata(Inode) Cache: 负责元数据缓存管理
File Content Cache:负责数据缓存管理
File System Abstraction Layer(FSAL): 非常重要的模块,通过一个接口来完成对命名空间的访问。所访问的对象随后会放置在inode cache和file content cache中。
Hash Tables:提供了基于红黑树的哈希表,这个模块在Ganesha里用到很多。

内存管理

内存管理是开发Ganesha时比较大的问题,因为大多数Ganesha架构中的所有模块都必须执行动态内存分配。 例如,管理NFS请求的线程可能需要分配用于存储所请求结果的缓冲器。 如果使用常规的LibC malloc / free调用,则存在内存碎片的风险,因为某些模块将分配大的缓冲区,而其他模块将使用较小的缓冲区。 这可能导致程序使用的部分内存被交换到磁盘,性能会迅速下降的情况。

因此Ganesha有一个自己的内存管理器,来给各个线程分配需要的内存。内存管理器使用了Buddy Malloc algorithm,和内核使用的内存分配是一样的。内存分配器中调用了madvise来管束Linux内存管理器不要移动相关页。其会向Linux申请一大块内存来保持高性能表现。

线程管理

管理CPU相比较内存会简单一些。Ganesha使用了大量的线程,可能在同一时间会有几十个线程在并行工作。开发团队在这里用到了很多POSIX调用来管理线程,让Linux调度进程单独处理每一个线程,使得负载可以覆盖到所有的CPU。

开发团队也考虑了死锁情况,虽然引入互斥锁可以用来防止资源访问冲突,但是如果大量线程因此陷入死锁状态,会大大降低性能。因此开发团队采用了读写锁,但是由于读写锁可能因系统而异,因此又开发了一个库来完成读写锁的转换。

当一个线程池中同时存在太多线程时,这个线程池会成为性能瓶颈。为了解决这个问题,Ganesha给每一个线程分配了单独的资源,这样也要求每个线程自己处理垃圾回收,并且定期重新组合它的资源。同时”dispatcher thread”提供了一些机制来防止太多线程在同一时间执行垃圾回收;在缓存层中垃圾回收被分成好几个步骤,每个步骤由单独代理处理。经过生产环境实测,这种设计时得当的。

哈希表

关联寻找功能在Ganesha被大量使用,比如我们想通过对象的父节点和名称来寻找对象元数据等类似行为是很经常的,因此为了保证Ganesha整体的高性能,关联寻找功能必须非常高效。

为了达到这个目的,开发团队采用了红黑树,它会在add/update操作后自动冲平衡。由于单棵红黑树会引发进程调用冲突(多个进程同时add/update,引发同时重平衡),如果加读写锁在红黑树上,又会引发性能瓶颈。因此开发团队设计了红黑树数组来解决了这个问题,降低了两个线程同时访问一个红黑树的概率,从而避免了访问冲突。

大型多线程守护程序

运行Ganesha需要很多线程同时工作,因此设计一个大型的线程守护程序在设计之初尤为重要,线程分为以下不同类型:

dispatcher thread: 用于监听和分发传入的NFS、MOUNT请求。它会选择处于最空闲的worker线程然后将请求添加到这个worker线程的待处理列表中。这个线程会保留最近10分钟内的请求答复,如果在10分钟内收到相同指令(存在哈希表并用RPC Xid4值寻址),则会返回以前的请求回复。

worker thread: Ganesha中的核心线程,也是使用最多的线程。worker线程等待dispatcher的调度,收到请求后先对其进行解码,然后通过调用inode cache API和file content API来完成请求操作。

statistics thread: 收集每个module中的线程统计信息,并定期用CSV格式记录数据,以便于进一步处理。

admin gateway: 用于远程管理操作,包括清楚缓存,同步数据到FSAL存储端,关闭进程等。ganeshaadmin这个程序专门用于与admin gateway线程交互。

缓存处理

在上文中提到,Ganesha使用了大片内存用于建立元数据和数据缓存。我们先从元数据缓存开始讲起。metadata cache存放在Cache Inode Layer(MDCache Layer)层 。每个实例对应一个命名空间中的实例(文件,符号链接,目录)。这些Cache Inode Layer中的实例对应一个FSAL中的对象,把从FSAL中读取到的对象结构映射在内存中。

Cache Inode Layer将元数据与对应FSAL对象handle放入哈希表中,用来关联条目。初版的Ganesha采用’write through’缓存策略来做元数据缓存。实例的属性会在一定的时间(可定义)后过期,过期后该实例将会在内存中删除。每个线程有一个LRU(Least Recently Used) 列表,每个缓存实例只能存在于1个线程的LRU中,如果某个线程获得了某个实例,将会要求原线程在LRU列表中释放对应条目。

每个线程需要自己负责垃圾回收,当垃圾回收开始时,线程将从LRU列表上最旧的条目开始执行。 然后使用特定的垃圾策略来决定是否保存或清除条目。由于元数据缓存应该非常大(高达数百万条目),因此占满分配内存的90%(高位)之前不会发生垃圾回收。Ganesha尽可能多得将FSAL对象放入缓存的‘树型拓扑’中,其中节点代表目录,叶子可代表文件和符号链接;叶子的垃圾回收要早于节点,当节点中没有叶子时才会做垃圾回收。

File Content Cache数据缓存并不是独立于与Inode Cache,一个对象的元数据缓存和数据缓存会一一对应(数据缓存是元数据缓存的‘子缓存’),从而避免了缓存不统一的情况。文件内容会被缓存至本地文件系统的专用目录中,一个数据缓存实例会对应2个文件:索引文件和数据文件。数据文件等同于被缓存的文件。索引文件中包含了元数据信息,其中包含了对重要的FSAL handle。索引文件主要用于重建数据缓存,当服务器端崩溃后没有干净地清掉缓存时,FSAL handle会读取索引文件中的信息来重建元数据缓存,并将其指向数据文件,用以重建数据缓存实例。

当缓存不足时,worker thread会查看LRU列表中很久未被打开的实例,然后开始做元数据缓存回收。当元数据缓存回收开始时,数据缓存的垃圾回收也会同时进行:在回收文件缓存实例时,元数据缓存会问询数据缓存是否认识该文件实例,如果不认识则代表该数据缓存已经无效,则元数据回收正常进行,并完成实例缓存回收;如果认识,对应的文件缓存以及数据缓存均会被回收,随后对应的元数据缓存也会被回收。这样保证了一个数据缓存有效的实例不会被回收。

这种方式很符合Ganesha的架构设计:worker线程可以同时管理元数据缓存和数据缓存,两者一直保持一致。Ganesha在小文件的数据缓存上采用’write back’策略,如果文件很大的话则会直接读取,而不经过缓存;可以改进的地方是可以把大文件分割成部分放入缓存中,提高读写效率。

FSAL(File System Abstraction Layer)

FSAL是相当重要的模块。FSAL本身给Inode Cache和File Content Cache提供了通用接口,收到请求后会调用具体的FSAL(FSAL_SNMP, FSAL_RGW等)。FSAL中的对象对应一个FSAL handle。由于FSAL的语义设计与NFSv4很相似,因此开发和可以自己编写新的FSAL API来适配Ganesha。Ganehsa软件包还提供了FSAL源代码模板。

 

一个栗子

介绍了许多NFS Ganesha的内部构造,这边通过一个NFS Ganesha对接Ceph RGW的例子来阐述一下代码IO:

ganesha_workflow

Figure 2 – NFS Ganesha workflow

以open()为例来,如上图所示。首先用户或者应用程序开始调用文件操作,经过系统调用 sys_open(),到达虚拟文件系统层 vfs_open(),然后交给 NFS 文件系统nfs_open()来处理。NFS 文件系统无法操作存储介质,它调用 NFS 客户端函数nfs3_proc_open() 进行通信,把文件操作转发到NFS Ganesha服务器。

Ganesha中监听客户端请求的是Dispatcher这个进程:其中的nfs_rpc_get_funcdesc()函数通过调用svc_getargs()来读取xprt(rpc通信句柄)中的数据,从而得到用户的具体请求,然后将这些信息注入到reqdata这个变量中。随后Dispatcher这个线程会把用户请求-reqdata插入到请求队列中,等待处理。

Ganesha会选择一个最空闲的worker thread来处理请求:通过调用nfs_rpc_dequeue_req()将一个请求从等待队列中取出,随后调用nfs_rpc_execute()函数处理请求。Ganesha内部自建了一个请求/回复缓存,nfs_dupreq_start()函数会在哈希表中寻找是否有一样的请求,如果找到,则寻找到对应回复,然后调用svc_sendreply()将回复发送给客户端,从而完成一个请求的处理。

如果Ganesha没有在哈希表中找到一样的请求,nfs_dupreq_start()这个函数会在缓存中新建一个请求,随后调用service_function(),也就是nfs_open()。FSAL(filesystem abstract layer)收到nfs_open()调用请求后,会调用fsal_open2()函数。由于我们已经在初始化阶段,在ganesha.conf指定了FSAL为RGW,并且在FSAL/FSAL_RGW/handle.c文件下我们已经重定向了FSAL的操作函数,因此fsal_open2()实际会调用rgw_fsal_open2(),通过使用librgw来进行具体操作。请求完成后,回复会插入到对应哈希表中,与请求建立映射,随后回复通过svc_sendreply()发送给客户端。由此完成了sys_open()这个函数的调用。

1 Comment

发表评论

电子邮件地址不会被公开。 必填项已用*标注