某日記

(後期)

平成25年6月28日(金曜日)

inotifyでディレクトリにフックかける話

Linuxにはinotifyって仕組みがあって便利って話なので、BSDでも実装できんかなと調べたけど、表題の件について、どうもLinuxの実装もいまいちだし、ちゃんと作ってもなかなか微妙で難しいね、という話。

BSDで似たようなことをやるにはkqueueを使うのだけれども、ディレクトリにフックを掛けたとしたら、そのディレクトリエントリに対する操作は検出できても、ディレクトリの内容物に対する操作を検出することはできない。

対してLinuxのinotifyは(前身のdnotifyもそうだけど)、ディレクトリにフックをかけると、その内容物に対するアクセスまで追跡してくれる(ただし、そのディレクトリ直下の内容物に対する変更だけであって、サブディレクトリの内容物までは追跡してくれない)。

たとえば、CentOS5でやってみると、次のように検出できる:

term A                                     term B
% mkdir a
% :>a/foo
% inotifywait -m -e modify a/
Setting up watches.
Watches established.
                                           % echo test >> a/foo
a/ MODIFY foo
つまり
  • ディレクトリ a と、空のファイル a/foo を作る
  • inotifywait(1) でディレクトリ a を監視
  • a/foo に追記する
  • notifywaitがそれを検出する
という流れ。

でも、UNIXのFSをちょっとでも知ってると、どうにもキナ臭さを感じるんですねコレ。「どうやって親に通知してるんだ」と。inode/vnodeは、FSのディレクトリ構造までは関知しないものなので、「inode/vnodeから親のディレクトリを見つける」というのは容易ではないはずなのですな。

というわけでLinuxのコードを読んでみる。正面口から、フックを掛けるAPIであるinotify_add_watch(2)を攻略する、というのもいいけれど、攻略対象への道のりが遠いので、そっちは斥候程度で済ませておく。斥候内容の詳細は省略するけれども、「内部的にはinotifyとdnotify がfsnotifyという仕組みで統一されている」ということが分かればよい。

めんどいので、実際にイベントを投げてる部分を探してみよう、ということで、素晴らしいコード検索ツールによって調査してみる:

% cd linux-3.9.6/fs
% grep -r fsnotify .
なんかいっぱい出る
実際のところ、いっぱい出てるのはfsnotifyのフレームワーク部分で、そこを除外するとあんまり多くない。目視で次の部分に当たりをつける:
% grep -r fsnotify .
...
./read_write.c:#include <linux/fsnotify.h>
./read_write.c:                 fsnotify_access(file);
./read_write.c:         fsnotify_modify(file);
./read_write.c:                 fsnotify_modify(file);
./read_write.c:                 fsnotify_access(file);
./read_write.c:                 fsnotify_modify(file);
./read_write.c:         fsnotify_access(in.file);
./read_write.c:         fsnotify_modify(out.file);
...

名前からして、fsnotify_modify()がさっきの例で実際にイベント投げてる部分だろうなあ、ということで見てみる:

ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_
t *pos)
{
...省略...
                if (file->f_op->write)
                        ret = file->f_op->write(file, buf, count, pos);
                else
                        ret = do_sync_write(file, buf, count, pos);
                if (ret > 0) {
                        fsnotify_modify(file);
                        add_wchar(current, ret);
                }
...省略...
}
どうもそうらしい。

じゃあfsnotify_modifyはどこかいな、と素晴らし(以下略)でinclude/linux/fsnotify.hに次のような定義があるのが分かる:

/*
 * fsnotify_modify - file was modified
 */
static inline void fsnotify_modify(struct file *file)
{
        struct path *path = &file->f_path;
        struct inode *inode = path->dentry->d_inode;
        __u32 mask = FS_MODIFY;
;
        if (S_ISDIR(inode->i_mode))
                mask |= FS_ISDIR;
;
        if (!(file->f_mode & FMODE_NONOTIFY)) {
                fsnotify_parent(path, NULL, mask);
                fsnotify(inode, mask, path, FSNOTIFY_EVENT_PATH, NULL, 0);
        }
}
(注:行頭のセミコロンは日記システムのバグ(というかrubyのバージョン上げて壊れてそのまま)で空行入れられなくなってるのを回避するために私が入れただけなので気にするな)

でまあfsnotify_parent()ってのが、親にイベント投げてる関数だろうなあ、というのが分かるわけです。

fsnotify_parent()自体はごく薄いラッパーでfsnotify.hで定義されているけれども、その実体はfs/notify/fsnotify.cで定義されている__fsnotify_parent()で以下のとおり:

/* Notify this dentry's parent about a child's events. */
int __fsnotify_parent(struct path *path, struct dentry *dentry, __u32 mask)
{
        struct dentry *parent;
        struct inode *p_inode;
        int ret = 0;
;
        if (!dentry)
                dentry = path->dentry;
;
        if (!(dentry->d_flags & DCACHE_FSNOTIFY_PARENT_WATCHED))
                return 0;
;
        parent = dget_parent(dentry);
        p_inode = parent->d_inode;
;
        if (unlikely(!fsnotify_inode_watches_children(p_inode)))
                __fsnotify_update_child_dentry_flags(p_inode);
        else if (p_inode->i_fsnotify_mask & mask) {
                /* we are notifying a parent so come up with the new mask which
                 * specifies these are events which came from a child. */
                mask |= FS_EVENT_ON_CHILD;
;
                if (path)
                        ret = fsnotify(p_inode, mask, path, FSNOTIFY_EVENT_PATH,
                                       dentry->d_name.name, 0);
                else
                        ret = fsnotify(p_inode, mask, dentry->d_inode, FSNOTIFY_EVENT_INODE,
                                       dentry->d_name.name, 0);
        }
;
        dput(parent);
;
        return ret;
}
要するに、dentryの仕組みで親のディレクトリを参照しにいってる(dget_parentに注意)。BSD的にはこれはnamecacheに相当する。

で、最初に「いまいちだね」と書いたのはこの部分。dentryってのはファイルシステム上の実体ではなくてインカーネルメモリ上の空蝉でしかなくて、必ずしも全貌を正しく反映してるわけではない。より具体的には、「現在参照してるファイルを開いたときに行われた、nameiによるパス探索した結果」を反映してるだけでしかない。これが問題になるケースはハードリンクで親が複数存在する場合で、この場合、うまくアクセスをトラッキングできないことがある。

同じくCentOS5:

term A                                     term B
% mkdir a
% :>a/foo
% mkdir b
% ln a/foo b/
% inotifywait -m -e modify a/
Setting up watches.
Watches established.
                                           % echo write via a >> a/foo
a/ MODIFY foo
                                           % echo write via b >> b/foo
                                           % cat a/foo
                                           write via a
                                           write via b
つまり
  • ディレクトリ a と、空のファイル a/foo を作る
  • ディレクトリ b と、a/foo に対するハードリンク b/foo を作る
  • inotifywait(1) でディレクトリ a を監視
  • a/foo に追記する
  • notifywaitがそれを検出する
  • b/foo に追記する
  • notifywaitはそれを検出できない
  • 検出できてない方の追記も当然 a/foo に行われている
という流れ。そりゃ、dentry的にはa/fooとb/fooは何の関連性もないのだから仕方がない。dget_parent使う安易な方法ではこれが限界、ということになる。

とりあえず、実装の制約として仕方がないとはいえ、「ドキュメントのどこにもこの制限が書いてない」のは問題だと思います。特に、セキュリティ上の要請で変更監視を行ってるような場合には裏口ができてしまう可能性がある。まあ、リンクカウントの増減自体はトラッキングできるので、実際にクラックに使うのは難しいかもしれんが。

さて BSD 的にはこんな ad hoc な方法はおそらく reject されるだけなので、真面目にやるにはどうするか、というと、以下のとおり。便宜上 UFS 的なローカルファイルシステムとする。

まず、ディレクトリの監視を開始するとき:

  1. ディレクトリエントリを全部なめて「inode番号→vnode変換テーブル」にエントリがあるかどうかを調べる(NetBSDのUFSではufs_ihashget()でできる)。あったエントリについては、そのvnodeに「親が見てるぞ」フラグを立て、親への参照を登録する。親への参照は複数存在する可能性があるので、リストで管理する
  2. そのついでに、監視対象ディレクトリに含まれているエントリの全てのinode番号を全部in-coreで覚えておく。これをここでは監視対象可能性テーブルと呼ぼう。
(2)は次の操作で必要。

次に、ファイルを開くとき。監視されてるディレクトリがある場合には、VOP_LOOKUPで次のようにする:

  1. 開こうとしてる対象のinode番号が監視対象可能性テーブルにある場合には、VFS_VGETで取ってきたvnodeに対して、先ほどの(1)と同様に「親が見てるぞフラグ」を立て、親への参照を登録する

あとは、各VOPで「親が見てるぞフラグ」を参照して適切にイベントを投げる。この辺はkqueueの仕組みで良い。

まあ、これが正しい方法ではあるのだけれども、問題は登録にかかる計算量とカーネルリソース消費で、inotify ならどっちも監視ディレクトリ数に比例するコストで済むのだが、上記の方法は、監視ディレクトリの中に含まれている全てのオブジェクトの数に比例したコストがかかってしまう。それならkqueueで全部個別監視しても変わんないよね、ということになる。

どっちにしても、なんかアレ。 ハードリンクを廃止しよう(提案)

話が変わって、inotifyとdnotifyの違いがどこにあるかというと、前者は「inotify専用のファイルデスクリプタをinotify_init(2)で一つ作り、そこに監視対象のパスを追加してゆく」という形式なのに対し、後者は「監視対象をopen(2)で開いてfcntl(2)でフラグを立てる」という形式なところ。前者はkqueueに近い感じで、通知もファイルデスクリプタから得る(kqueueとは違って普通のread(2)システムコールで読んでくるようになってる)。後者はSIGIOで通知してた。

そういう形式なので、dnotifyの最大の問題は「監視中は監視対象のファイルデスクリプタを開きっ放しにしておかないといけない」という点で、これは次の二つの問題を生じる:

  • 監視対象が増えるとファイルデスクリプタテーブルがあふれる
  • 監視対象があるうちはファイルシステムをアンマウントできない
それ以外の問題としては、
  • SIGIOでシグナルが飛ぶだけなので、どのファイルに何が起こったのかまでは分からない
というのがあった。これらを解決する為にinotifyができたわけね。

そして、ここからが問題なのだが、kqueueのEVFILT_VNODEも、ファイルデスクリプタをフィルタのidentにしてるので、ファイルデスクリプタテーブルのサイズに制約される。まあ、登録したあとにcloseしても監視は続けられるので、

  • 全ての監視対象をopenして登録した後、すべてcloseする
か、または
  • 監視対象をopenし、identに使ってない番号のfdをdup2で取得してからkqueueに登録、closeする
  • これを全ての監視対象でおこなう
みたいなことをすれば、アンマウントで問題が起こることはないのだけれども。でも、どっちにしろfdtableが無闇に太るというのは変わらないので、あんまり良くはない。

これを解決するには、fdをidentで渡すんじゃなくてdataあたりで渡すようにすればいいので、まあそういうフィルタを新規に追加するか、あるいはEVFILT_VNODEなんかでfflagsにフラグを立てるとidentではなくdataを監視対象fdとして使う、みたいな拡張が必要かなあ、という気がする。

まとめ:

  1. Linuxのinotifyはdentryベースなので、監視が完全ではないし、それがドキュメント化されてない
  2. 同様の実装は、少なくともNetBSDでは受け入れられるか怪しい
  3. vnodeベースの真面目な実装は、それはそれで微妙
  4. kqueueのEVFILT_VNODEはAPIの欠陥でファイルデスクリプタテーブルのサイズ上限に制約されるので、何らかの対策が必要じゃないか

上の話の追記

「監視ディレクトリの中に含まれている全てのオブジェクトの数に比例したコストがかかってしまう」という話については、よく考えたら、ハードリンクされてるオブジェクトだけを監視対象可能性テーブルに入れれば良くて、ハードリンクなんて滅多にするものじゃないということを考えれば、少なくともカーネル内メモリについてはまず太る心配をしなくてよい、という気がしてきた。

あいかわらず監視対象ディレクトリのdirentを全部なめてリンクカウントとinoを調べないといけないので、登録時にはファイル数に比例したコストがかかるんだが、これはもう「監視する以上はそういうものである」として許容できると考えられる。