关注了就能看到更多这么棒的文章哦~
NFS: the early years
June 20, 2022
This article was contributed by Neil Brown
DeepL assisted translation
https://lwn.net/Articles/897917/
我最近因为一些原因需要对 NFS(网络文件系统)协议多年来的改动进行一下回溯和反思,而且发现这是一个很值得给大家讲讲的故事。这样的故事很容易被过多的细节所淹没,因为确实有非常多这样的细节,但确实有一个想法是比其他的那些更加明显和突出的。NFS 的最早期版本被描述为一个 "stateless (无状态的) "协议,这个术语我现在还偶尔听到有人会这么说。NFS 的大部分历史都是承认有 state 以及添加支持而逐步演进的。本文着眼于 NFS 早期阶段的演变(以及它对 state 的处理方式)。后续会有第二部分内容来一直讲述到当前情况。
我所说的 "state",就是指客户端和服务器端需要共同记住的那些信息,它们如果在一方发生了改变的话,就需要让另一方也产生改变。正如我们将看到的,state 中包含很多内容。其中一个很简单的例子就是文件的内容,当它被缓存(cache)在客户端一侧时,其目的要么是希望能不要进行 read requests,要么就是为了能把 write request 合并起来发出去。客户端需要知道什么时候所缓存的数据必须要被 flush 出去或者清除掉,从而让客户端和服务器端基本保持同步。另一种明显的 state 就是文件锁(file locks)了,对于这种锁,服务器和客户端必须始终针对客户端在某个时刻持有什么锁要能确保一致。每一方都必须能够发现另一方出现了 crash 的情况,从而能让 lock 被丢弃(discard)或恢复(recover)。
NFSv2 — the first version
据推测,在 Sun Microsystems 内部曾有一个 NFS 的 "version 1",但第一个公开出来的版本就是 version 2 了,它出现在 1984 年。该协议在 RFC 1094 中有介绍,但这个文档并不被视为权威性文件;相反,Sun 公司的实现本身定义了该协议。在同一时期,还有其他网络文件系统被开发出来,如 AFS(the Andrew File System)和 RFS(Remote File Sharing)。与这些系统相比,NFS 有一个明显的区别,那就是它很简单。有人可能会说,它太简单了,完全无法正确地实现一些 POSIX 语义。然而,这种简单性意味着它可以为许多常见的工作场景提供良好的性能。
20 世纪 80 年代初是 "3M Computer" 的时代,当时个人工作站的经常是只有 1M 字节的内存、1 MIPS 的处理能力和 1 M 像素(单色)的显示器。以今天的标准来看,这也太弱了,完全没法用,而且当时人们还认为一百万便士(10,000 美元)的价格是可以接受的。但这些就是 NFSv2 所必须能够运行的硬件环境,而且必须运行良好才能被人们接纳。历史表明,它足以完成这项任务。
Consequence of being "stateless"
NFSv2 协议没有对状态管理的明确支持。没有 "打开" 一个文件的概念,不支持 locking,也没有在 RFC 中提到任何 caching 机制。只有简单的、一个个互相独立的访问请求,所有这些都是利用文件句柄(file handle)来做的。
"file handle" 是 NFSv2 的最核心的把其他东西统一起来的机制:它是一个不透明的(opaque)、32 字节的文件标识符,在某个特定 NFS 服务器中,在所有时间内这些 file handle 都是固定且唯一的。NFSv2 允许客户端在一个特定目录(由其他的 file handle 来识别)中为某个指定的名字查找文件句柄,检查和改变这个文件句柄的属性(所有权、大小、时间戳等),并在某个文件句柄的某个偏移位置来进行数据块的读写。
为 NFSv2 选择的操作是尽可能要满足幂等的(idempotent)条件,也就是说如果某个请求被重复发送了,它在第二次或第三次执行中的结果将会是与第一次相同的。这对于在不稳定的网络上进行真正的无状态操作是个必要条件。NFS 最初是通过 UDP 实现的,UDP 不保证数据一定能到达,所以客户端必须准备好在没有得到回复时来重新发送请求。客户端不能知道是请求丢失了,还是回复丢失了,而真正的无状态服务器是不能记住某个特定请求是否已经被看到的,也就无法避免触发重复动作。因此,当客户端重新发送一个请求时,它可能会重复一个已经执行过的操作,所以必须是要 idempotent 的操作才行。
不幸的是,并不是所有 POSIX 下的文件系统操作都可以是 idempotent 的。一个很好的例子就是 MKDIR,如果这个名字的目录尚不存在,那么就应该创建一个目录;如果这个名字已经被使用了,即使本身就是一个目录了,也会需要返回错误。这意味着重复请求可能导致会导致返回错误。尽量减少这个问题的标准做法就是在服务器上实现一个重复请求缓存(DRC, Duplicate Request Cache)。这是对最近处理过的 non-idempotent 请求的历史记录,也包括返回的结果。实际上,这意味着客户端(必须要跟踪记录尚未收到回复的请求)和服务器都维护一个随时间不断变化的未完成的请求列表。这些列表符合我们对 "state" 的定义,所以最初的 NFSv2 实际上并不是无状态的,尽管根据规范来说它是无状态的。
由于服务器无法知道客户端何时能看到自己的回复,也就无法知道一个请求何时才能算是处理完成的,所以它必须使用一些启发式规则来把旧的 cache 条目丢弃掉。不可避免地会记住许多不需要记住的请求,并可能会丢弃一些很快就会需要的请求。虽然这显然不是最理想的方案,但经验表明,这对正常的工作负荷来说已经是相当有效了。
维护这个 cache 就需要服务器知道每个请求来自哪个客户端,所以它需要一些可靠的方法来识别客户。随着协议的发展,状态管理变得更加明确,我们会看到这种需求反复出现。对于 DRC 来说,所使用的客户端标识符是由客户端的 IP 地址和端口号得出的。当后续添加了 TCP 支持的时候,协议类型也就需要与主机地址和端口号一起用上了。由于 TCP 提供了可靠的传输,似乎不需要 DRC,但这并不完全正确。如果网络问题导致客户端和服务器在很长一段时间内无法通信,TCP 连接有可能 "中断(break)"。NFS 做好了无限期等待的准备,但是 TCP 不是这样的。如果 TCP 确实中断了连接,客户端就不能知道那些未决请求的状态了,它必须在一个新的连接上重新传输这些请求,这样服务器端就可能仍然看到重复的请求了。为了确保这一点,NFS 客户端要注意使用与先前连接相同的 source 端口来重新建立连接。
这个 DRC 机制并不完美,部分原因是由于启发式方法可能会在客户端实际收到回复之前就丢弃了这些条目,还有可能的原因是在服务器重启时没有保留这些内容,因此一个请求可能在服务器 crash 之前和之后都会被执行。在许多情况下,这只是个无伤大雅的小问题,如果 "mkdir" 偶尔返回 EEXIST (本来不应该有这个返回值),那么会有人真正受到影响吗?但是有一种情况就被证明是会有很大问题的,而且 DRC 根本没有对它进行处理,那就是独占创建(exclusive create)。
在 Unix 有文件锁的概念之前(因为它在 Edition 7 Unix 中没有,这正是 BSD 的基础),会经常使用 lock file。如果需要独占访问某些文件,比如/usr/spool/mail/neilb,惯例是应用程序必须首先创建一个相关名称的 lock file,比如/usr/spool/mail/neilb.lock。这必须是一个使用 O_CREAT|O_EXCL 标志的 "exclusive" 方式创建出来的,如果文件已经存在就会失败。如果某个应用程序发现它不能创建该文件,因为其他应用程序已经这样做了,它就会等待着重新尝试。
Exclusive create 天生就不是一个 idemotent 操作,而且 NFSv2 根本就不支持它。客户端可以进行查询,如果报告说没有现存的文件,他们就可以创建该文件。这个两步程序显然容易受到竞态冲突影响,所以并不可靠。NFS 的这一缺陷似乎并没有让它变得不受欢迎了,但多年来肯定有很多人在诅咒这一点。这也引出了一些创新,使用其他方法来创建 lock file。
一种方法是生成一个在所有客户端都是唯一的字符串(可能包括主机名、进程 ID 和时间戳),然后用这个字符串作为名称和内容来创建一个临时文件。这个文件需要(hard)link 来创建 lock file 的文件名。如果 hard link 成功了,就说明拿到了锁。如果失败了,也就是这个名字已经存在了,那么应用程序可以读取该文件的内容。假如文件内容与我们刚生成的唯一字符串相匹配,那么这个错误就是由于重传造成的,而且 lock 也已经获取到了。否则的话,应用程序就需要休眠并再次尝试。
不做状态管理的另一个不幸的后果是,文件在打开之后被 unlink 了。POSIX 对这些 unlink 但仍然保持打开状态文件没有什么意见,并会保证该文件能继续正常运行,直到它最终被关闭,此时该文件就会彻底消失。在 NFS 服务器看来,因为它不知道哪些文件是在哪个客户端上打开的,就很难做到这么好,所以 NFS 客户端的实现并不依赖于服务器的帮助。而是在进行 unlink 处理(删除文件)时,客户端会将这个已经打开的文件重命名为一些特殊且唯一的名字,如.nfs-xyzzy,然后在文件最终关闭时再删掉这个名字。这使得服务端无需跟踪客户端的状态,但对客户端来说偶尔会有一些不便。如果一个应用程序打开了某个目录中唯一的文件,unlink 该文件,然后试图删除该目录,那么最后一步将会失败,因为该目录此时还不是空目录,而是包含了一个带有特殊的.nfs-XX 名称的文件,除非客户端先把这个特殊文件名的文件移到父目录中,或者将 RMDIR 也改成一个重命名操作。在实践中,这种操作顺序是非常少见的,所以 NFS 客户端都不打算满足这个功能。
The NFS ecosystem
当我在上面说 NFSv2 不支持 file locking 时,其实只讲了故事的一半。也就是说这个说法是准确的,但并不完整。事实上,NFS 是一套协议的一部分,所有这些协议配合在一起使用的时候,可以提供更完整的服务。NFS 不支持锁,但有其他协议是支持锁的。可以与 NFS 一起使用的协议包括:
NLM(the Network Lock Manager)。其允许客户端对一个给定文件(使用 NFS 文件句柄来标明)请求一个 byte-range 的锁,并允许服务器授予(或不授予),可以是立即授予,也可以是今后授予。当然,这是一个明确的有状态协议,因为客户端和服务器必须为每个客户端维护相同的 lock 列表。
STATMON(the Status Monitor)。当一个节点–无论是客户端还是服务器端–crash 或以其他方式重启时,之前的所有暂时状态,如文件锁等都会丢失,所以它的对端就需要对此进行响应。服务器端将清除掉该客户端所持有的锁,而客户将试图重新获得丢失的锁。在 NLM 中选择的方法是让每一端都在可靠的存储设备中记录对端列表,并在重启时通知到所有对端;然后他们就可以自己进行清理。这项记录然后通知对等体的任务就是 STATMON 完成的了。当然,如果一个客户端在持有一个锁的时候崩溃了,并且没有重启,服务器就永远不会知道这个锁不再被人所持有了。这有时就会引入麻烦。
MOUNT。当挂载一个 NFSv2 文件系统时,你需要知道该文件系统 root 位置的文件句柄,而 NFS 没有办法提供。这是由 MOUNT 协议来处理的。该协议希望服务器能够跟踪记录哪些客户已经 mount 了哪些文件系统,因此可以把这些有用的信息报告出来。然而,由于 MOUNT 并不与 STATMON 交互,客户端可以重新启动也就是事实上 umount 了文件系统,并未告诉服务器端。虽然这种软件仍在记录当前 active mount 的列表,但没有人相信它们。
在后来的版本中,MOUNT 还会处理 security negotiation。服务器可能需要某种 cryptographic security(如 Kerberos)才能访问某些文件系统,这个要求会通过 MOUNT 协议传达给客户端。
RQUOTA(remote quotas)。NFS 可以报告文件和文件系统的各种属性,但有一个属性是未被支持的,那就是 quota。可能是因为这些是用户的属性,不是文件的属性。为了填补这一空白,就出现了 RQUOTA 协议。
NFSACL(POSIX draft ACL)。正如我们有 RQUOTA 来实现 quota 功能,我们也有 NFSACL 来实现访问控制列表(access control lists)。这允许检查 ACL 并进行设置(这一点跟 RQUOTA 不同)。
除了这些,还有其他一些协议只是松散地联系在一起,比如 "Yellow Pages",也被称为网络信息服务器(NIS),它可以让一组机器能有完全一致的用户名到 UID 的映射;"rpc.ugid",也可以提供帮助;甚至可能 NTP 也算,它确保了 NFS 客户端和服务器对当前时间的判断是一致的。无论如何,这些都不是 NFS 的真正组成部分,但却是让 NFS 如此繁荣的生态系统的一部分。
NFSv3 - bigger is better.
NFSv3 是在大约十年后(1995 年)出现的。这时,工作站的速度更快了(而且色彩更丰富了),磁盘驱动器也更大。32 位不够代表文件中的字节数、文件系统中的 block 数或文件系统中的 inode 数了,而 32 字节也不再足以代表文件句柄,因此这些 size 都被翻倍了。NFSv3 还获得了 READDIRPLUS 操作,用来获取一个目录中的所有 name 和文件属性,这样可以更有效地实现 ls -l。请注意,决定何时使用 READDIRPLUS 和何时使用更简单的 READDIR,并不是一件容易的事情。在 2022 年,Linux NFS 客户端仍然在采用启发式方法来进行改进。
有两个改动是专门跟 state 管理有关的,其中之一是解决上面讨论的 exclusive-create 的问题,另一个是帮助维护客户端数据的 cache。其中第一个对 CREATE 操作进行了扩展。
在 NFSv3 中,一个 CREATE 请求可以指定该请求是 UNCHECKED、GUARDED 还是 EXCLUSIVE 的。其中第一个是无论文件是否存在都会让操作成功。但是如果文件存在的话,第二种方式必须要失败,但它就像 MKDIR 一样,可能会因为重传而导致出现不应该出现的错误,所以它不是特别有用。EXCLUSIVE 则更有用一些。
EXCLUSIVE 这个创建请求会带有 8 个字节的每个客户端各不相同的标识(这是我们反复用到的方式),称为 "verifier"。RFC(RFC 1813)中建议说,"也许" 这个 verifier 可以包含客户的 IP 地址或其他一些独特的数据。Linux NFS 客户端使用了 jiffies 这个 internal timer 的四个字节以及发出请求的进程的 PID 的四个字节。服务器端需要在创建文件时将这个 verifier 采用原子操作方式存储到可靠的存储位置。如果服务器后来被要求再创建一个已经存在的文件,那么必须要对比存储中的客户端标识符和请求中的标识符,在匹配的情况下,服务器必须报告说 exclusive create 成功了,也就是判定这是之前请求的一次重复发起。
Linux NFS 服务器在其创建的文件的 mtime 和 atime 字段中会存储这个 verifier。NFSv3 协议承认这种可能性,并要求一旦客户端收到表示成功创建的回复,就必须发出 SETATTR 请求,从而让服务器这边可以把存储了 verifier 的这些文件属性改成正确的值。这个 SETATTR 步骤向服务器确认了一些非幂等的请求已经完成了,这样就应该可能对 DRC 实现有帮助了。
Client-side caching and close-to-open cache consistency
NFSv2 RFC 并没有描述客户端缓存,但这并不意味着实现方案里面也没有做任何缓存。他们必须要非常小心。只有当有充分的理由认为数据在服务器上没有变化时,cache 数据才是安全的。NFS 的实现方案里给客户端提供了两种方法,用来让其相信 cache 的数据是可以安全使用的。
NFS 服务器可以把一个文件的各种属性报告上来,尤其是 size 和最后更改时间。如果这些值跟以前的一样,那么基本可以判定文件内容没有改变。NFSv2 允许将更改的时间戳按微秒为单位来上报,但这并不意味着服务端也可以保持这种精度水平。甚至在 NFSv2 首次使用起来的二十年后,还有一些很重要的 Linux 文件系统只能按秒为精度单位来提供时间戳。因此,如果一个 NFS 客户端看到一个至少过去一秒钟的时间戳,然后读取数据,它就可以判定这些缓存数据是安全的,这样一直到它看到时间戳变化为止。如果它看到的时间戳是在 "当前时间" 的一秒钟之内,那么就不太好确定是否安全了。
NFSv3 引入了 FSINFO 请求,可以供服务器上报各种限制和设置信息,并包括了一个 "time_delta",这是假设文件修改时间以及其他时间戳中的时间精度应该到什么水平。这样客户端的 cache 维护就可以更精确一些。
如上所述,在看到文件的属性发生变化之前,可以安全地使用文件的缓存数据。客户端可能实现成不再查看文件的属性,因此就永远看不到文件变动了,但这是不允许的。确认数据安全的话,需要客户端进行属性检查的时机方面遵守两条规则:
第一条规则很简单:就是偶尔检查一下。协议中没有规定最小或最大的 timeout 时间,但大多数实现中都允许配置这些 timeout 值。Linux 默认的是三秒钟的超时,只要没有任何变动的话,超时时间就会成倍地增长,最长会增加到一分钟。这意味着客户端可以从缓存中提供最多 60 秒的数据,但不会更长了。第二条规则是建立在一个假设上,也就是多个应用程序永远不会同时打开同一个文件,除非它们使用了 locking 锁定或者都是只读访问。
在客户端打开一个文件时,它必须验证缓存中的数据(通过检查时间戳来判断),并丢弃那些它不能确定的数据。只要文件保持 open 状态,客户端就可以认为服务器上不会再发生变动了(除了它自己要求变动之外)。当它关闭文件时,客户端必须在关闭完成前将所有的变动传递到服务器上。如果每个客户端都这样做,那么任何一个打开文件的应用程序都会看到其他应用程序在这次打开之前其他客户端对文件进行 close 操作时完成的所有修改了,所以这种模式有时被称为 "close-to-open consistency"。
当使用 byte-range locking 的时候,也可以使用类似的基本模型,但 open 操作变成了客户端被授予锁的时刻,而 close 则是它释放锁的时刻。在被授予锁之后,客户端必须重新验证或清除锁范围内的任何已经缓存的数据,在释放锁之前,它必须将这个区域的缓存变化传递合并到服务器上。
由于上面说的都是依靠修改时间点来验证 cache 的,而这个时间戳在任何客户端写入文件时都会更新,因此逻辑上的含义是,当客户端写入文件时,它必须清除自己的 cache,因为时间戳已经改变了。在文件关闭(或这个区域被解锁)之前都是可以继续使用缓存的,但不能超过这个时间段。在使用 byte-range 锁时,这种需求尤其明显。一个客户可能会 lock 一个区域,进行写入,然后再 unlock。另一个客户端可能会 lock、写入和 unlock 另一个不同的区域,而写入请求正好发生在同一时间。任何一个客户端都不可能知道另一个客户端是否写了这个文件,因为时间戳是涵盖整个文件而不仅仅是一个范围的。所以他们都必须在下次打开或 lock 文件之前清除他们的相应 cache。
至少,在 NFSv3 中引入 weak cache consistencies(wcc)属性之前是没有办法判断的。在 NFSv3 WRITE 请求的回复中,在写请求之前和之后允许服务端上报一些属性(如 size 和时间戳),并且要求,如果它确实报告了这些属性,那么在这两组属性之间是没有发生其他 write 操作的。客户端可以使用这些信息来检测时间戳的变化是纯粹是由于它自己的写入导致的,还是由于其他客户端的写入导致的。因此,它可以确定它是否是唯一一个向文件写入的客户端(这是最常见的情况),并且在这种情况下,哪怕时间戳在变化了,也可以保留其 cache。在对 SETATTR 和修改目录的请求(如 CREATE 或 REMOVE)的回复中也是可以使用 Wcc 属性的,所以客户端也可以判断它是否是一个目录中唯一进行操作的一方,并相应地管理它的缓存。
这被称为 "weak" cache consistency,因为它仍然需要客户端偶尔来检查时间戳。强缓存一致性则要求服务器明确地告诉客户端这里即将发生改动,不过要等到 NFS 的后面的版本才会带有这个支持了。尽管是 weak 方式的,但它仍然是一个明显的进步,可以允许客户端保持对服务器状态的了解,因此是对无状态协议这个说法的又一个打击。
顺便说一句,Linux NFS 服务端并没有在对文件进行 write 时提供这些 wcc 属性。要做到这一点的话,它需要在收集文件属性以及进行写入时必须持有文件锁。从 Linux 2.3.7 开始,底层文件系统负责在写入过程中加锁,所以 nfsd 不能以原子操作方式来提供这些属性。不过,Linux NFS 确实为目录内的修改提供了 wcc 属性。
NFS - the next generation
这些早期版本的 NFS,都是在 Sun Microsystems 内部开发的。这些代码可供其他 Unix 厂商在他们的产品中使用,虽然这些厂商能够根据需要来调整实现,但他们不能改变协议,毕竟那是由 Sun 公司控制的。
随着新千年的到来,人们对 NFS 的兴趣不断增加,就出现了独立的第三方的实现。这导致了更多的开发者对如何改进 NFS 提出了意见,还是非常了解细节并且深思熟虑过的意见。为了满足这些开发者,同时又不至于引出分裂的危机,就需要一个机制可以用来听取和回答这些意见。这个机制的性质,以及在 NFS 协议的后续版本中出现的改动,将是我们后续需要讲述的主题。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
欢迎分享、转载及基于现有协议再创作~
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~