LWN大作:NFS 的早期時代!

語言: CN / TW / HK

關注了就能看到更多這麼棒的文章哦~

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 深度文章以及開源社群的各種新近言論~