シンプルなファイル監視の問題

少し前、ある顧客から一見シンプルな課題を提示されました。 ファイルがscpでサーバーにアップロードされるワークフローがあり、アップロード完了後にサーバー上でアクションを実行したいというものでした。 最初に考えた解決策は、アップロードディレクトリを監視しCLOSE通知を待つためにinotifywaitユーティリティを使うというものでした。 `inotifywait`はファイル階層のアクセスや変更(ファイルの作成、書き込み、削除など)を監視できます。 この解決策は99%のケースで機能しましたが、時折inotifywaitがアップロード後のCLOSE通知を出さないことがありました。

通知が欠落する根本原因は、inotifywaitの内部実装にありました。 これはFreeBSD固有のライブラリ`libinotify.so`を使っており、Linuxのinotify インターフェースを`EVFILT_VNODE` kqueueフィルタを使ってユーザースペースで実装しています。 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はファイルディスクリプタ上で動作し、そのディスクリプタが参照するファイルしか監視できません。 つまりディレクトリ全体を監視するには次のことが必要です:

  1. ディレクトリを走査して各ファイルを開き、追跡用構造体を割り当て、kqueueに登録する
  2. ディレクトリ自体を監視し、ファイルの追加・削除時に再スキャンできるようにする
  3. ディレクトリ内の各ファイル名を追跡し、各ファイルのリネームも監視する

これらはすべてかなりの作業量です。 また、大規模なディレクトリでは、エントリの追加・削除時にディレクトリを走査する必要があるためパフォーマンスが悪化します。 さらに、ディレクトリ内の各ファイルを開く必要があるため、アプリケーションは多数のファイルディスクリプタを使用せざるを得ず、カーネルメモリ使用量の増加やディスクリプタ上限への到達を招く可能性があります。 最後に、kqueue版には少なくとも1つの競合状態があります。 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`通知を生成しやすい傾向があります。 いずれにせよ、2つの解決策のうちinotifyを使う方が明らかに優れています。 よりシンプルで効率的であり、少なくともLinuxではこのような競合状態は存在しません。 唯一の問題は、最近までFreeBSDにネイティブのinotify実装がなかったことです。 前述の`libinotify.so`は設計上同じ競合状態を抱えており、inotifyインターフェースを使うソフトウェアは実装できますが、これらのバグは避けられません。

解決策に向けた取り組み

EVFILT_VNODEのこれらの制約に直面し、FreeBSDのファイル監視インターフェースが不十分であることは明らかでしたが、進むべき道筋はすぐには見えませんでした。 他のオペレーティングシステムがファイル監視をどのように扱っているかを調べました。

他のBSD(OpenBSD、NetBSD、DragonflyBSD)もEVFILT_VNODEを実装していますが、各監視対象ファイルを開く必要があるため、同じ問題を抱えています。 NetBSDはLinuxシステムコール互換レイヤの一部としてカーネルでinotifyインターフェースを実装していますが、それもEVFILT_VNODE上に構築されており、VFSに直接統合されていないため、同様の制約と複雑さを引き継いでいます。

一方、Linuxにはinotifyインターフェースがあります。 inotifyは新しいタイプのファイルディスクリプタを導入し、それを使ってファイルシステムイベントのファイルやディレクトリを監視できます。 重要なのは、ディレクトリを監視すると、そのディレクトリ内の全ファイルと、監視開始後に追加されたファイルが自動的に監視対象になることです。 EVFILT_VNODEとは異なり、アプリケーションが各ファイルを開いたりファイル名を追跡したりする必要はありません。

inotifyの他に、Linuxにはfanotifyインターフェースもあり、ファイルアクセスのインターセプト機能など、より豊富な機能を提供します。 実際には、inotifyの方がOSSでより一般的に使われているようです。 古く、使いやすいためです。

MacOSでは、ファイル監視は`FSEvents` APIを使って行えます。 これはカーネルコンポーネントと、ユーザースペースで動作するシステムデーモンで構成され、/dev/fseventsを使ってファイルシステムアクセスイベント情報を取得し、コンシューマに公開します。 特に、コンシューマはカーネルから直接イベントストリームを取得しません。 この設計により、システムはユーザースペースでイベントストリームポリシー(ドロップされたイベントの扱い方など)や状態管理を実装でき、カーネル側を比較的シンプルに保ちつつ柔軟性を提供しますが、当初の控えめなユースケースに対してはかなり複雑で汎用的です。

FSEventsに触発され、ユーザースペースデーモンなしで同様の機能を提供する新しいkqueueフィルタEVFILT_FSWATCHの実装を試みました。 マウントポイント内の全ファイルの全イベントを監視する方法を提供する考えで、これはカーネルで比較的容易に実装できます。 フィルタ登録時に、アプリケーションは構造体の配列へのポインタも提供し、各構造体は特定パスの1つ以上のイベントを記述します。 カーネルはこれらの構造体のレイアウトを把握しており、アプリケーションがkevent()を呼び出して新規イベントをポーリングする際に構造体を埋めることができます。 EVFILT_VNODEの欠点を回避しますが、ドロップされたイベントの扱い、効率的なパスフィルタリング、アクセス制限などの実用的な問題には対処していませんでした。

なぜinotifyか?

最終的に、FreeBSDにネイティブのinotifyインターフェースを実装することにしました。 inotifyには再帰的なファイル階層の監視ができないこと、バックプレッシャーとイベントドロップの制御が不十分であることなど、独自の欠点がありますが、FreeBSD上で動作するソフトウェアで広く使われており(そのため`libinotify.so`互換性レイヤが必要)、比較的実装しやすいものです。 さらに、ネイティブで互換性のある実装により、Linuxulatorでinotifyを「無料で」使えるようになり、LinuxバイナリをFreeBSD上で直接動作させるのに役立ちます。

実際、ネイティブのinotify実装がないことは、多くのFreeBSDユーザーにとって痛手でした。 GLibやQt(どちらもinotify上に高レベルインターフェースを提供)などの基盤ライブラリを含む大量のOSSがinotifyを使っています。 一部のソフトウェアはコンパイル時に選択可能な代替としてEVFILT_VNODEをサポートしていますが、多くのアプリケーションはサポートしておらず、最近までFreeBSD portsツリーの100を超えるポートがユーザースペースのlibinotifyを直接参照していました。

libinotify.soは非常に有用で、FreeBSD上での標準デスクトップソフトウェアの有効化に重要な役割を果たしていますが、前述のEVFILT_VNODEの問題は多くのフラストレーションを引き起こしていました。 この調査のきっかけとなった競合状態の他に、EVFILT_VNODEのリソースオーバーヘッドが一部のケースで許容できないレベルでした。 例えば、人気のファイル同期サービスであるsyncthingは、kqueueを使って大規模ディレクトリを監視すると、高いCPU・メモリ使用量が頻繁に報告されるため警告をログに記録します。 ネイティブのinotify実装により、これらの問題は解消されますが、FreeBSDのinotifyシンボルをgolang.org/x/sys/unixパッケージで利用可能にし、syncthingのinotifyベースのファイルイベント通知パッケージで利用できるようにするさらなる作業が必要です。

FreeBSDに2つの競合するインターフェースを持つのではなく、EVFILT_VNODEを必要な機能をサポートするよう拡張できれば良かったのですが、それにはkevent()がカーネルからファイル名をエクスポートする方法が必要であり、ユーザースペースがディレクトリを走査してファイル名を追跡する必要がなくなります。 つまり、単に我々の問題に適合しないのです。 そこで、inotifyをネイティブに実装し、特にLinuxとソース互換性を保って既存のコードがFreeBSD上でそのまま動作するようにすることを目標にしました。

inotifyインターフェース

前述のwatchdir_inotify()の例は、インターフェースの大部分を示しています。 3つのシステムコールで構成されます: 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では、openat(2)に似た追加のシステムコールinotify_add_watch_at(2)を提供しており、ディレクトリディスクリプタ(またはAT_FDCWD定数)と相対パスを受け取ります。 これはCapsicumサンドボックス内でのinotify利用を可能にするためです。サンドボックス化されたプログラムはディレクトリディスクリプタなしにファイルシステムアクセスを許可しないためです。

`/usr/share/examples/inotify/inotify.c`のサンプルプログラムは、inotifyが報告できるさまざまなイベントを示すより完全な例を提供します。 コマンドラインで指定された各パスにwatchを追加し、受信した全イベントを報告するだけです。 inotify-toolsパッケージは同様のユーティリティを提供します。FreeBSD 15.0では、これらおよび他のサードパーティパッケージがネイティブのinotify実装をそのまま利用します。

数ヶ月にわたる開発、テスト、コードレビューを経て、FreeBSD向けinotify実装は開発ブランチにマージされ、FreeBSD 15.0および14.4で利用可能になります。 Linuxとの完全な互換性を目指していますが、2つのカーネルの設計差異により、動作に若干の違いがあります。 大半のアプリケーションはこれらに気づかないと予想されます。

主な違いは、複数のハードリンクを持つファイルの扱いです。 ファイル自体は名前を持たず、名前とinode番号からなるディレクトリエントリによって参照されることを思い出してください。 inode番号はファイルシステム内でファイルを一意に識別します。 したがって、ディレクトリ内の異なるエントリが同じ基礎ファイルを示す可能性があり、ファイルは複数のディレクトリに名前を持つことができます。 以下の例でこれを説明します:

ここでは、創造性に富んでlink1link2という名前の2つのハードリンクを持つファイルがあります。 アプリケーションが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


別のシェルから `/tmp/dir2/link2` にアクセスするとします:


$ cat /tmp/dir2/link2
a
inotifyプログラムは、これらのアクセスが `/tmp/dir1` 経由でなかったにもかかわらず、`cat(1)`からのアクセスを報告します:
{
"__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"
}
]
}

2つ目の例では、単一のディレクトリしかありません:

ここで、`dir`ディレクトリが監視されている場合、どちらのハードリンク経由のファイル操作でも、各リンクごとに1つずつ、合計2つのイベントが生成されます:

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

次に、`cat(1)`で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マウント経由のアクセスも報告されます。

まとめ

最後に、FreeBSDのinotify実装はLinuxとの互換性を最大限に目指しているため、Linuxulatorサポートの追加は容易でした。 つまり、inotifyを使うLinuxバイナリがFreeBSD上で動作するようになります(当然、他の機能が不足していないことが前提です)。 多くのアプリケーションが何らかの形でinotifyを利用しているため、これはFreeBSDのエミュレーションレイヤにとって大きな前進となります。

Klaraは、ストレージプラットフォームや仮想化環境からカーネル開発、パフォーマンスエンジニアリングまで、あらゆる規模のFreeBSDインフラの導入、最適化、サポートを組織に提供します。 FreeBSDインフラのモダナイゼーションと保守を検討している組織は、深いカーネルとシステムの専門知識に裏打ちされたFreeBSD開発、サポート、特化型ソリューションについてKlara Inc.に頼ることができます。