一个简单的文件监控问题
不久前,一位客户向我们提出了一个看似简单的问题。他们有一个工作流程:文件通过 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");
/* 主循环。 */ 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;
/* 搜索现有条目。 */ LIST_FOREACH(e, entries, link) { if (strcmp(e->name, dp->d_name) == 0) { e->seen = true; break; } }
if (e == NULL) { int fd;
/* 未找到,创建新条目。 */ 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; }
/* 监视文件的关闭事件。 */ 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"); }
/* 移除未被看到的条目。 */ LIST_FOREACH_SAFE(e, entries, link, tmp) { if (e->seen) continue;
LIST_REMOVE(e, link); close(e->fd); free(e); }
/* 将目录本身添加到队列。 */ 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);
/* 监视目录中已有的所有文件。 */ scan(kq, d, &entries);
/* 主循环。 */ while (true) { error = kevent(kq, NULL, 0, &kev, 1, NULL); if (error < 0) err(1, "kevent");
if (kev.udata == NULL) {
/* 目录本身发生变化,重新扫描。 */
scan(kq, d, &entries);
} else {
struct entry *e;
e = (struct entry *)kev.udata;
if ((kev.fflags &
(NOTE_RENAME | NOTE_DELETE)) != 0) {
/* 文件被重命名,扫描。 */
scan(kq, d, &entries);
}
if ((kev.fflags & (NOTE_CLOSE | NOTE_CLOSE_WRITE)) != 0) printf("CLOSE: %s\n", e->name); } } }
我们立即可以看出,这要复杂得多。EVFILT_VNODE 作用于文件描述符,只能监视该文件描述符所引用的文件。这意味着要监视整个目录,我们必须:
- 遍历目录并打开每个文件,为其分配跟踪结构并将其注册到 kqueue;
- 监视目录本身的变化,以便在添加或删除文件时重新扫描;
- 跟踪目录中每个文件的名称,包括监视每个文件的重命名。
所有这些工作量都相当大。此外,对于大型目录,由于每次添加或删除条目时都需要扫描目录,性能会很差。此外,由于需要打开目录中的每个文件,应用程序可能需要使用大量文件描述符,增加内核内存使用,并可能触及描述符限制。最后,kqueue 版本至少存在一个竞态条件:如果 scp(或任何其他工具)正在向被监视目录添加新文件,底层操作大致如下:
- 创建文件并打开以进行写入;
- 使用步骤 1 中获得的文件描述符将数据写入文件;
- 关闭文件描述符。
你看出问题了吗?步骤 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 实现的,而不是直接集成到 VFS 中,因此继承了许多相同的限制和复杂性。
而 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` 兼容层),且实现起来相对简单。此外,原生的兼容实现让我们可以在 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 描述符关联的唯一监视描述符。这些监视描述符不受文件描述符资源限制的约束,允许同时监视多个目录。但是,它们受 `vfs.inotify.max_user_watches` sysctl 定义的全局限制约束。
在 FreeBSD 中,我们提供了一个额外的系统调用 `inotify_add_watch_at(2)`,类似于 openat(2),它接受一个目录描述符(或 `AT_FDCWD` 常量)和一个相对路径。这是为了支持在 Capsicum 沙箱 中使用 inotify,因为沙箱程序不允许在没有目录描述符的情况下进行文件系统访问。
`/usr/share/examples/inotify/inotify.c` 中的示例程序提供了一个更完整的示例,演示了 inotify 可以报告的不同事件。它所做的只是为命令行上列出的每个路径添加监视,并报告收到的所有事件。inotify-tools 包提供了类似的工具;在 FreeBSD 15.0 中,这些以及其他第三方包将开箱即用我们的原生 inotify 实现。
经过数月的开发、测试和代码审查,我们为 FreeBSD 实现的 inotify 已并入 开发分支,并将在 FreeBSD 15.0 和 14.4 中提供。它旨在与 Linux 完全兼容,但由于两个内核的设计差异,在行为上存在一些细微差别。我们预计绝大多数应用程序不会注意到这些差异。
主要差异在于如何处理具有多个硬链接的文件。回想一下,一个给定的文件可以有多个名称,因为文件本身不包含名称,而是由目录条目引用,目录条目由名称和 inode 号组成。inode 号在其文件系统中唯一标识文件。因此,目录中的不同条目可能引用同一个底层文件,并且一个文件可以在多个目录中拥有名称。以下示例说明了这一点:

这里我们有一个具有两个硬链接的文件,分别命名为 link1 和 link2,出于创造力考虑。假设一个应用程序使用 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 开发、支持和专注的解决方案。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.