一個簡單的檔案監控問題

不久前,一位客戶向我們提出了一個看似簡單的問題。他們的工作流程是透過 scp 將檔案上傳到伺服器,並希望在檔案上傳完成後,在伺服器上執行後續動作。他們最初的解決方案是使用 inotifywait 工具監控上傳目錄,等待 CLOSE 通知,收到通知後再執行動作。inotifywait 可以監控檔案階層的存取或修改,例如檔案建立、寫入、刪除等。此方案在約 99% 的情況下運作正常,但偶爾 inotifywait 會無法在檔案上傳後發出 CLOSE 通知。

遺漏通知的根本原因與 inotifywait 的底層實作有關。它使用了 FreeBSD 專屬的函式庫 libinotify.so,該函式庫利用 EVFILT_VNODE kqueue 過濾器,在使用者空間實作 Linux 的 inotify 介面。Linux 的 inotify 與 FreeBSD 的 EVFILT_VNODE 都是系統呼叫介面,能讓應用程式被動監控檔案或目錄的變更。

這兩種介面本身有相當大的差異;以下範例函式可監控指定目錄中的所有檔案,偵測檔案關閉事件並在事件發生時列印,功能與客戶需求大致相同。首先是基於 inotify 的版本:

void
watchdir_inotify(const char *dirname)
{
struct inotify_event *ev;
size_t evsz;
int ifd, wd;

ifd = inotify_init();
if (ifd < 0) err(1, "inotify_init");

wd = inotify_add_watch(ifd,dirname, IN_CLOSE);
if (wd < 0) err(1, "inotify_add_watch(%s)", dirname);

evsz = sizeof(*ev) + NAME_MAX + 1;
ev = malloc(evsz);
if (ev == NULL) err(1, "malloc");

/* Main loop. */
while (true) {
ssize_t n;

n = read(ifd, ev, evsz);
if (n < 0) err(1, "read");

if (ev->len > 0)
printf("CLOSE: %s\n", ev->name);
}
}

這段程式相當直觀:我們建立 inotify 描述元,要求它監控指定目錄的 IN_CLOSE 事件,然後進入迴圈從描述元讀取事件描述。此程式可輕易整合至較大事件迴圈,方法是使用 kevent()epoll() 等待 inotify 描述元有資料可讀。

以下則是使用 kqueue()EVFILT_VNODE 嘗試實作相同功能的版本:

struct entry {
LIST_ENTRY(entry) link;

char name[NAME_MAX + 1];
int fd;
bool seen;
};

LIST_HEAD(entry_list, entry);

static void
scan(int kq, DIR *d, struct entry_list *entries)
{
struct dirent *dp;
struct kevent kev;
struct entry *e, *tmp;
int error;

LIST_FOREACH(e, entries, link)
e->seen = false;

rewinddir(d);
while ((dp = readdir(d)) != NULL) {
int flags;

if (strcmp(dp->d_name, ".") == 0 ||
strcmp(dp->d_name, "..") == 0)
continue;

/* Search for an existing entry. */
LIST_FOREACH(e, entries, link) {
if (strcmp(e->name, dp->d_name) == 0) {
e->seen = true;
break;
}
}

if (e == NULL) {
int fd;

/* Not found, create a new entry. */
fd = openat(dirfd(d), dp->d_name, O_RDONLY);
if (fd < 0) {
if (errno == EOPNOTSUPP)
continue;
err(1, "openat(%s)", dp->d_name);
}

e = malloc(sizeof(*e));
if (e == NULL)
err(1, "malloc");

(void)strlcpy(e->name, dp->d_name, sizeof(e->name));
e->fd = fd;
e->seen = true;

LIST_INSERT_HEAD(entries, e, link);

flags = EV_ADD | EV_CLEAR;
} else {
flags = EV_CLEAR;
}

/* Watch the file for close events. */
EV_SET(&kev, e->fd, EVFILT_VNODE, flags,
NOTE_CLOSE | NOTE_CLOSE_WRITE |
NOTE_RENAME | NOTE_DELETE,
0, e);

error = kevent(kq, &kev, 1, NULL, 0, NULL);
if (error < 0)
err(1, "kevent");
}

/* Remove entries that were not seen. */
LIST_FOREACH_SAFE(e, entries, link, tmp) {
if (e->seen)
continue;

LIST_REMOVE(e, link);
close(e->fd);
free(e);
}

/* Add the directory itself to the queue. */
EV_SET(&kev, dirfd(d), EVFILT_VNODE,
EV_ADD | EV_CLEAR,
NOTE_WRITE,
0, 0);

error = kevent(kq, &kev, 1, NULL, 0, NULL);
if (error < 0)
err(1, "kevent");
}

void
watchdir_kqueue(const char *dirname)
{
struct kevent kev;
struct entry_list entries;
DIR *d;
int error, kq;

LIST_INIT(&entries);

kq = kqueue();
if (kq < 0)
err(1, "kqueue");

d = opendir(dirname);
if (d == NULL)
err(1, "opendir(%s)", dirname);

/* Monitor all files already in the directory. */
scan(kq, d, &entries);

/* Main loop. */
while (true) {
error = kevent(kq, NULL, 0, &kev, 1, NULL);
if (error < 0)
err(1, "kevent");

if (kev.udata == NULL) {
/* Directory itself changed, rescan. */
scan(kq, d, &entries);
} else {
struct entry *e;

e = (struct entry *)kev.udata;

if ((kev.fflags &
(NOTE_RENAME | NOTE_DELETE)) != 0) {
/* File was renamed, scan. */
scan(kq, d, &entries);
}

if ((kev.fflags &
(NOTE_CLOSE | NOTE_CLOSE_WRITE)) != 0)
printf("CLOSE: %s\n", e->name);
}
}
}

我們可以立即看出此版本複雜許多。EVFILT_VNODE 運作於檔案描述元,且只能監控該檔案描述元所指向的檔案。這表示若要監控整個目錄,我們必須:

  1. 遍歷目錄並開啟每個檔案,為其配置追蹤結構並註冊至 kqueue;
  2. 監控目錄本身以便在檔案新增或移除時重新掃描;
  3. 追蹤目錄中每個檔案的名稱,包含監控每個檔案的重命名。

所有這些工作相當繁重。此外,對於大型目錄而言,由於每次新增或移除項目時都需要掃描目錄,效能會變差。再者,因為目錄中的每個檔案都需要被開啟,應用程式可能需要使用大量檔案描述元,增加核心記憶體使用量並可能觸及描述元限制。最後,kqueue 版本至少存在一個競爭條件:若 scp(或任何其他工具)正在向被監控目錄新增檔案,底層操作大致如下:

  1. 建立檔案並開啟以供寫入;
  2. 使用步驟 1 取得的檔案描述元將資料寫入檔案;
  3. 關閉檔案描述元。

你看出問題了嗎?步驟 1 會導致 watchdir_kqueue() 函式重新掃描目錄、開啟新檔案,並將其註冊至 kqueue。然而,同時上傳服務正在執行步驟 2 與 3。若它在註冊新檔案之前完成步驟 3,watchdir_kqueue() 就不會回報 CLOSE 事件,因為該事件發生在檔案註冊之前。這正是客戶遇到的問題。

實際上可透過以下指令在被監控目錄建立新檔案來觀察:

$ echo a > foo

相較於

$ dd if=/dev/random of=foo bs=1M count=128 

前者執行速度很快,觸發競爭條件的機率較高,而後者產生 CLOSE 通知的可靠性較高。無論如何,在這兩個方案中,使用 inotify 的方案明顯較佳:它更簡潔、更有效率,而且至少在 Linux 上沒有這種競爭條件。唯一的缺點是,直到最近 FreeBSD 都沒有原生 inotify 實作。上述提到的 libinotify.so 因設計關係也會遇到相同的競爭條件,因此即使我們能在使用它的軟體中實作 inotify 介面,這些錯誤仍無法避免。

尋求解決方案

面對 EVFILT_VNODE 的這些限制,FreeBSD 的檔案監控介面顯然不足,但前進方向並不明確。我們觀察其他作業系統如何處理檔案監控。

其他 BSD(OpenBSD、NetBSD 與 DragonflyBSD)也實作了 EVFILT_VNODE,但同樣受限於 EVFILT_VNODE 要求每個被監控檔案必須被開啟。NetBSD 在 Linux 系統呼叫相容層中實作 inotify 介面,但同樣建構於 EVFILT_VNODE 之上,因此繼承了許多相同限制與複雜度。

Linux 則有 inotify 介面。inotify 引入一種新型檔案描述元,讓使用者可監控檔案與目錄的檔案系統事件。關鍵在於,監控目錄會自動監控目錄中的所有檔案,以及監控開始後新增至目錄的任何檔案。與 EVFILT_VNODE 不同,應用程式無需開啟每個檔案或追蹤檔案名稱。

除了 inotify,Linux 還有 fanotify 介面,提供類似但功能更豐富的機制,例如攔截檔案存取。實際上,inotify 在開源軟體中似乎更常用,可能因為它較舊且使用較簡單。

在 macOS 上,可使用 `FSEvents` API 進行檔案監控。它包含 核心元件 與執行於使用者空間的系統守護程式,守護程式使用 /dev/fsevents 取得檔案系統存取事件資訊並發布給消費者。特別是,消費者不會直接從核心取得事件串流。此設計允許系統在使用者空間實作事件串流政策(例如如何處理遺失事件)與狀態管理,提供彈性同時保持核心端相對簡單,但對於我們 modest 的使用情境而言仍相當複雜且通用。

FSEvents 啟發,我們花了一些時間實作新的 kqueue 過濾器 EVFILT_FSWATCH,可在無需使用者空間守護程式的情況下提供部分相同功能。其構想是提供監控掛載點中所有檔案所有事件的方法,因為這在核心中相對容易實作。註冊過濾器時,應用程式也會提供指向結構陣列的指標,每個結構描述特定路徑上的一或多個事件。核心知道這些結構的配置,可在應用程式呼叫 kevent() 輪詢新事件時填入資料。它避開了 EVFILT_VNODE 的缺點,但我們僅實作到概念驗證階段,並未解決各種實際問題,例如處理遺失事件、有效率路徑過濾或存取限制。

為什麼選擇 inotify?

最終,我們決定在 FreeBSD 實作原生 inotify 介面。雖然 inotify 本身有一些缺點——無法遞迴監控檔案階層、對背壓與事件遺失的控制不佳等——但它被廣泛用於在 FreeBSD 上執行的軟體(因此需要 libinotify.so 相容 shim),且相對容易實作。此外,原生且相容的實作讓我們能在 Linuxulator 中「免費」使用 inotify,有助於許多 Linux 二進位檔直接在 FreeBSD 上執行。

事實上,缺乏原生 inotify 實作一直是許多 FreeBSD 使用者的痛點。有大量開源軟體使用它,包括基礎函式庫如 GLib 與 Qt(兩者都在其上提供更高階介面);有些軟體在編譯時選擇支援 EVFILT_VNODE 作為替代方案,但相當多應用程式並無此支援,直到最近,FreeBSD ports 樹中仍有超過 100 個 ports 直接引用使用者空間的 libinotify。

雖然 libinotify.so 非常實用,是在 FreeBSD 上啟用標準桌面軟體的關鍵部分,但上述 EVFILT_VNODE 的問題造成了許多困擾。除了促使我們展開調查的競爭條件之外,EVFILT_VNODE 的資源開銷在某些情況下也過高。例如,熱門檔案同步服務 syncthing 在使用 kqueue 監控大型目錄時會記錄警告,因為該情境下經常出現高 CPU 與記憶體使用量的報告。有了我們的原生 inotify 實作,這些問題將消失,不過仍需進一步工作才能讓 FreeBSD 的 inotify 符號可在 golang.org/x/sys/unix 套件中使用,進而讓 syncthing 的 inotify 檔案事件通知套件使用它。

FreeBSD 若有兩個互相競爭的介面並不理想,我們原本希望能以某種方式擴充 EVFILT_VNODE 來支援所需功能,但要做到這點,我們需要讓 kevent() 能將檔案名稱從核心匯出——如此就不需要使用者空間掃描目錄與追蹤檔案名稱——這表示它根本不適合我們的問題。因此我們設定目標是原生實作 inotify,並特別保持與 Linux 的原始碼相容,以確保現有程式碼可在 FreeBSD 上正常運作。

inotify 介面

先前 watchdir_inotify() 的範例已展示大部分介面,它包含三個系統呼叫:inotify_init()inotify_add_watch()inotify_rm_watch()。它們分別用來建立 inotify 描述元、開始監控檔案或目錄,以及停止監控檔案或目錄。對 inotify 描述元呼叫 close(2) 會隱含停止與其相關的所有監控。

inotify_add_watch() 系統呼叫接受 inotify 描述元、路徑與事件遮罩,並回傳 watch descriptor,這實際上是可傳遞給 inotify_rm_watch() 的偽檔案描述元。特別是,每個被監控物件都會取得與 inotify 描述元相關聯的唯一 watch descriptor。這些 watch descriptor 不受檔案描述元資源限制影響,因此可同時監控許多目錄。不過,它們受 vfs.inotify.max_user_watches sysctl 定義的全域限制約束。

FreeBSD 提供額外的系統呼叫 inotify_add_watch_at(2),類似 openat(2),接受目錄描述元(或 AT_FDCWD 常數)與相對路徑。這是為了讓 inotify 能在 Capsicum 沙箱 中使用,因為沙箱程式不允許在沒有目錄描述元的情況下進行檔案系統存取。

/usr/share/examples/inotify/inotify.c 中的範例程式提供了更完整的範例,展示 inotify 可回報的不同事件。它所做的只是為命令列上列出的每個路徑新增 watch,並回報收到的所有事件。inotify-tools 套件提供類似工具;在 FreeBSD 15.0 中,這些以及其他協力廠商套件將可直接使用我們的原生 inotify 實作。

經過數個月開發、測試與程式碼審查,我們為 FreeBSD 實作的 inotify 已 併入 開發分支,並將在 FreeBSD 15.0 與 14.4 中提供。它旨在與 Linux 完全相容,但由於兩者核心設計差異,行為上會有一些小差異。我們預期絕大多數應用程式不會注意到這些差異。

主要差異在於處理多重硬連結檔案的方式。回想一下,一個檔案可以有多個名稱,因為檔案本身不包含名稱,而是由目錄項目(包含名稱與 inode 編號)參照。inode 編號在檔案系統中唯一識別檔案。因此,目錄中的不同項目可能參照同一個底層檔案,而一個檔案可以在多個目錄中擁有名稱。以下範例說明此概念:

這裡我們有一個具有兩個硬連結的檔案,名稱分別為 link1link2(命名相當有創意)。假設應用程式使用 inotify 監控 dir1 的事件。若某程序透過 link2 開啟檔案,會發生什麼事?在 Linux 上,文件記載的行為是 inotify 不會回報開啟事件,因為它只監控透過被監控目錄進行的存取。然而在 FreeBSD 上,透過任一硬連結的存取都會產生 inotify 事件,而事件中記錄的名稱會是被監控目錄中的連結名稱。

使用 範例程式 我們可以看到實際運作。

$ mkdir /tmp/dir1 /tmp/dir2 
$ echo a > /tmp/dir1/link1
$ ln /tmp/dir1/link1 /tmp/dir2/link2
$ inotify --libxo json,pretty /tmp/dir1


現在,假設我們從另一個 shell 存取 /tmp/dir2/link2


$ cat /tmp/dir2/link2
a
inotify 程式會回報這些來自 cat(1) 的存取,即使它們並非透過 /tmp/dir1 進行:
{
"__version": "1",
"events": [
{
"wd": 1,
"event": "IN_OPEN",
"name": "link1"
},
{
"wd": 1,
"event": "IN_ACCESS",
"name": "link1"
},
{
"wd": 1,
"event": "IN_CLOSE_NOWRITE",
"name": "link1"
}
]
}

在第二個範例中,我們只有單一目錄:

這裡,如果監控目錄 dir,則透過任一硬連結對檔案進行的任何操作都會產生兩個事件,每個連結各一個:

$ mkdir /tmp/dir 
$ touch /tmp/dir/link1
$ ln /tmp/dir/link1 /tmp/dir/link2
$ inotify --libxo json,pretty /tmp/dir

然後再次使用 cat(1) 存取其中一個連結,會產生以下輸出:

{ 
"__version": "1",
"events": [
{
"wd": 1,
"event": "IN_OPEN",
"name": "link2"
},
{
"wd": 1,
"event": "IN_OPEN",
"name": "link1"
},
{
"wd": 1,
"event": "IN_ACCESS",
"name": "link2"
},
{
"wd": 1,
"event": "IN_ACCESS",
"name": "link1"
},
{
"wd": 1,
"event": "IN_CLOSE_NOWRITE",
"name": "link2"
},
{
"wd": 1,
"event": "IN_CLOSE_NOWRITE",
"name": "link1"
}
]
}

此行為源於 FreeBSD 核心追蹤檔案名稱的低階細節,與 Linux 有相當大的差異。

具體來說,在 Linux 中,inotify 事件與特定開啟檔案控制代碼綁定,該控制代碼包含用來存取底層 inode 的連結名稱。因此,當檔案描述元用於存取檔案時(例如呼叫 read(2)),Linux 可以使用與描述元相關聯的名稱產生事件。相較之下,FreeBSD 不會將檔案名稱儲存在檔案描述中,因此我們的 inotify 實作需要回退至搜尋名稱快取,以找出給定 inode 的名稱。這種架構差異導致上述行為分歧。

實際上,硬連結並不常見,我們預期使用者不會注意到此行為差異。在開發期間,我們針對實作執行了多個協力廠商測試套件,沒有任何套件因這項差異而回報問題。

FreeBSD inotify 實作也能妥善處理透過 NFS 伺服器進行的檔案存取。也就是說,若被監控目錄透過 NFS 匯出並由用戶端存取,伺服器會產生 inotify 事件,可透過 DTrace 觀察:

# dtrace -n 'vfs::vop_inotify:entry {stack();}' 

dtrace: description 'vfs::vop_inotify:entry ' matched 1 probe
CPU ID FUNCTION:NAME
0 79459 vop_inotify:entry
kernel`VOP_INOTIFY_APV+0x129
kernel`vop_read_post+0x1b4
kernel`VOP_READ_APV+0xcd
kernel`nfsvno_read+0x313
kernel`nfsrvd_read+0xf04
kernel`nfsrvd_dorpc+0x549
kernel`nfssvc_program+0x1054
kernel`svc_run_internal+0x144f
kernel`svc_thread_start+0xb
kernel`fork_exit+0xa3
kernel`0xffffffff81b176ee

同樣地,inotify 可與 FreeBSD 的 nullfs 檔案系統互通,nullfs 允許將任意目錄掛載至檔案階層中的其他位置。也就是說,可以監控 nullfs 掛載點中的目錄,並接收透過「下層」存取的事件。反之,監控下層目錄也會回報透過 nullfs 掛載進行的存取。

總結

最後,我們的 inotify 實作旨在盡可能與 Linux 相容,因此輕鬆加入 Linuxulator 支援。也就是說,使用 inotify 的 Linux 二進位檔現在可以在 FreeBSD 上運作(當然前提是沒有其他缺少的功能)。相當多應用程式會以某種方式使用它,因此這應該是 FreeBSD 模擬層的重要進展。

Klara 協助各規模組織部署、最佳化與支援 FreeBSD 基礎架構,從儲存平台、虛擬化環境到核心開發與效能工程。希望現代化與維護 FreeBSD 基礎架構的組織,可仰賴 Klara Inc. 提供的 FreeBSD 開發、支援,以及以深厚核心與系統專業知識為後盾的專注解決方案。