inotify и fanotify: отслеживание событий файловой системы¶
В обычной модели программа узнаёт об изменении файла, только когда сама его прочитает. Этого достаточно, пока файлы
меняет одна программа, но картина ломается, как только в игре участвуют несколько процессов или внешние действия:
редактор должен заметить, что файл переписала git checkout; антивирус — что в /tmp появился новый исполняемый
файл; backup-демон — что директория изменилась за последние десять минут.
Старый ответ — periodic stat() (polling), но он плох: масштабируется как O(N) по числу файлов на каждый интервал,
не видит быстрых rename → modify → rename и пропускает события между опросами. Linux отвечает на это двумя
specialized kernel-механизмами: inotify (общего назначения) и fanotify (повышенные привилегии, mount-scope,
permission events).
Зачем это нужно¶
- IDE и редакторы — auto-reload файлов, изменённых снаружи (VSCode, IntelliJ, vim watcher).
- Антивирусы — сканировать каждый открываемый или новый исполняемый файл (ClamAV, Sophos, McAfee).
- Incremental backup — резервное копирование только изменённого с прошлой сессии.
- Feature flags / config reload — перезагрузить конфиг сразу после
vim app.yaml, а не раз в минуту. - Container runtimes — отслеживать изменения в bind-mount каталогах хоста.
- Hot reload — webpack-dev-server, nodemon, cargo-watch, gunicorn
--reload. - Indexers — Spotlight-подобные системы (recoll, baloo) перестраивают индекс только по changed-файлам.
Все эти задачи объединяет одно: реакция на событие должна происходить за миллисекунды, а сам watcher не должен систематически дёргать ФС.
Эволюция API¶
| Поколение | Версия ядра | Состояние |
|---|---|---|
| dnotify | Linux 2.4, 2001 | устарело: сигналы вместо fd, per-dir, неудобно |
| inotify | Linux 2.6.13 | стандарт для редакторов и IDE |
| fanotify | Linux 2.6.36 | mount-wide, permission events, AV-классы |
dnotify передавал события через сигналы (SIGIO): подписчик получал лишь номер сигнала и должен был сам делать
stat() всех файлов в каталоге, чтобы понять, что именно изменилось. Один сигнал на каталог — ни о каком mass
watching речи не шло. Замена пришла с inotify, который ввёл file-descriptor-based API и структурированные события.
inotify¶
API¶
inotify оперирует тремя сущностями:
- inotify instance — открытый fd, через который читаются события.
- watch descriptor (wd) — int, идентификатор подписки на конкретный путь.
- event mask — битовая маска интересующих событий.
int fd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
int wd = inotify_add_watch(fd, "/tmp/watched", IN_CREATE | IN_MODIFY | IN_DELETE);
// ... epoll/poll/read на fd ...
inotify_rm_watch(fd, wd);
close(fd);
При наступлении события ядро формирует запись struct inotify_event и кладёт в очередь fd:
struct inotify_event {
int wd; /* watch descriptor */
uint32_t mask; /* event mask */
uint32_t cookie; /* для связи IN_MOVED_FROM ↔ IN_MOVED_TO */
uint32_t len; /* длина name[] (с \0 и padding) */
char name[]; /* имя файла (если событие на содержимом каталога) */
};
cookie — ненулевой идентификатор, через который связываются парные события переименования (IN_MOVED_FROM
+ IN_MOVED_TO); если значения cookie совпадают — это один и тот же rename.
Базовые события¶
| Маска | Когда генерируется |
|---|---|
IN_ACCESS |
файл был прочитан |
IN_MODIFY |
в файл записали |
IN_ATTRIB |
изменились метаданные (chmod, chown, timestamps, link count) |
IN_OPEN |
файл открыт |
IN_CLOSE_WRITE |
закрыт открытый для записи fd |
IN_CLOSE_NOWRITE |
закрыт открытый только на чтение fd |
IN_CREATE |
в каталоге создан новый объект |
IN_DELETE |
объект в каталоге удалён |
IN_DELETE_SELF |
удалён сам отслеживаемый объект |
IN_MOVED_FROM |
объект переименован: «откуда» |
IN_MOVED_TO |
объект переименован: «куда» |
IN_MOVE_SELF |
сам watched-объект был переименован |
IN_Q_OVERFLOW |
очередь событий переполнилась — события потеряны |
IN_IGNORED |
watch стал недействительным (объект удалён или fs размонтирована) |
Дополнительные модификаторы: IN_ONLYDIR (отказать, если путь не каталог), IN_DONT_FOLLOW (не разрешать symlink),
IN_EXCL_UNLINK (не доставлять события для unlink'нутых, но открытых файлов), IN_MASK_ADD (объединить с
существующей маской), IN_ONESHOT (одноразовый watch).
Поток данных¶
process A kernel process B (watcher)
┌────────────┐
│ write(fd…) │
└─────┬──────┘
│ syscall
▼
┌─────────────────────────────────────────┐
│ VFS │
│ notify_change / fsnotify_modify │
└───────────────────┬─────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ fsnotify backend (inotify) │
│ ─ ищет watchers на этом inode/dir │
│ ─ формирует struct inotify_event │
│ ─ кладёт в per-instance event queue │
└───────────────────┬─────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ event queue │
│ [ev1][ev2][ev3] ... │ ◀── read(inotify_fd) ──┐
│ max_queued_events (~16384 default) │ │
└─────────────────────────────────────────┘ │
│
┌──────┴──────┐
│ epoll_wait │
│ → read() │
│ → разобрать │
│ struct │
└─────────────┘
Полный пример¶
Простейший watcher, печатающий события на /tmp/watched:
#include <sys/inotify.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#define BUF_LEN (1024 * (sizeof(struct inotify_event) + NAME_MAX + 1))
static const struct { uint32_t mask; const char *name; } EVENTS[] = {
{IN_CREATE, "CREATE"},
{IN_DELETE, "DELETE"},
{IN_MODIFY, "MODIFY"},
{IN_MOVED_FROM, "MOVED_FROM"},
{IN_MOVED_TO, "MOVED_TO"},
{IN_ATTRIB, "ATTRIB"},
{IN_OPEN, "OPEN"},
{IN_CLOSE_WRITE, "CLOSE_WRITE"},
{IN_CLOSE_NOWRITE, "CLOSE_NOWRITE"},
{IN_Q_OVERFLOW, "Q_OVERFLOW"},
};
int main(int argc, char **argv) {
if (argc < 2) { fprintf(stderr, "usage: %s <path>\n", argv[0]); return 1; }
int fd = inotify_init1(IN_CLOEXEC);
if (fd < 0) { perror("inotify_init1"); return 1; }
int wd = inotify_add_watch(fd, argv[1],
IN_CREATE | IN_DELETE | IN_MODIFY | IN_MOVED_FROM | IN_MOVED_TO |
IN_ATTRIB | IN_OPEN | IN_CLOSE_WRITE);
if (wd < 0) { perror("inotify_add_watch"); return 1; }
char buf[BUF_LEN] __attribute__((aligned(8)));
for (;;) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n <= 0) { if (errno == EINTR) continue; perror("read"); break; }
for (char *p = buf; p < buf + n; ) {
struct inotify_event *ev = (struct inotify_event *)p;
printf("wd=%d cookie=%u ", ev->wd, ev->cookie);
for (size_t i = 0; i < sizeof(EVENTS)/sizeof(*EVENTS); ++i)
if (ev->mask & EVENTS[i].mask) printf("%s ", EVENTS[i].name);
if (ev->len > 0) printf("name=%s", ev->name);
putchar('\n');
p += sizeof(*ev) + ev->len;
}
}
return 0;
}
cc watch.c -o watch
./watch /tmp/watched &
touch /tmp/watched/foo # → CREATE OPEN ATTRIB CLOSE_WRITE name=foo
echo hi > /tmp/watched/foo # → OPEN MODIFY CLOSE_WRITE name=foo
mv /tmp/watched/foo /tmp/watched/bar # → MOVED_FROM ... MOVED_TO (одинаковый cookie)
rm /tmp/watched/bar # → DELETE name=bar
Ограничения inotify¶
- Не recursive. Watch ставится на конкретный каталог. Чтобы следить за деревом, программа сама делает
nftw()иinotify_add_watchна каждую subdir — и потом обрабатыватьIN_CREATEдля нового каталога, добавляя watch на него (с гонкой между walk и появлением файлов). max_user_watches./proc/sys/fs/inotify/max_user_watchesограничивает число watch descriptors на user uid. Дефолт — 8192–65536 в зависимости от дистрибутива; для IDE на больших monorepos этого не хватает, и тогда возвращаетсяENOSPC. Стандартный фикс:sysctl fs.inotify.max_user_watches=524288.max_queued_events. При переполнении очереди ядро доставляет одно событиеIN_Q_OVERFLOWи теряет всё, что не успел прочитать watcher. После переполнения корректная стратегия — пересканировать дерево.- Symlinks. По умолчанию следит за target.
IN_DONT_FOLLOWпринуждает работать с самим symlink. - Не работает на сетевых ФС. Изменения, сделанные на удалённой стороне NFS/SMB, не приходят локальному inotify — события генерируются только на хосте, где произошёл VFS-вызов.
- bind mounts. Watch ставится по inode, поэтому события приходят независимо от того, через какой mountpoint открыт файл — это иногда удивляет.
Утилиты¶
fanotify¶
Что добавляет fanotify¶
inotify подписывается на путь (inode); fanotify — на mountpoint или filesystem, и опционально может блокировать операцию до решения watcher'а. Это делает его пригодным для:
- on-access антивирусных сканеров (FAN_OPEN_EXEC_PERM);
- HSM (hierarchical storage manager): перехватить чтение и «дотянуть» данные с tape;
- audit-систем уровня файловой системы.
API¶
int fan = fanotify_init(FAN_CLOEXEC | FAN_NONBLOCK | FAN_CLASS_CONTENT,
O_RDONLY | O_LARGEFILE);
fanotify_mark(fan,
FAN_MARK_ADD | FAN_MARK_MOUNT, /* per-mount watch */
FAN_OPEN | FAN_CLOSE_WRITE,
AT_FDCWD, "/");
// ... epoll/read ...
В отличие от inotify, события приходят как struct fanotify_event_metadata, в котором ядро уже открыло fd
интересующего файла:
struct fanotify_event_metadata {
__u32 event_len;
__u8 vers;
__u8 reserved;
__u16 metadata_len;
__aligned_u64 mask;
__s32 fd; /* открытый ядром fd объекта события */
__s32 pid; /* pid, сделавший операцию */
};
Получая fd напрямую, watcher избегает классической гонки «имя → файл»: к моменту, когда antivirus откроет файл по имени, имя могло уже указывать на другой объект. С fd этой проблемы нет.
Классы и режимы¶
| Класс | Назначение |
|---|---|
FAN_CLASS_NOTIF |
только уведомления (как inotify, но per-mount) |
FAN_CLASS_CONTENT |
permission на content access (AV, audit) |
FAN_CLASS_PRE_CONTENT |
permission до содержания (HSM: подтянуть данные с tape) |
При смешанной подписке события доставляются в порядке: PRE_CONTENT → CONTENT → NOTIF. Это даёт правильный порядок для AV-стека: сначала HSM подтянет данные, потом AV их просканирует.
Permission events¶
process X kernel fanotify daemon (AV)
┌────────────┐
│ open(F, R) │
└─────┬──────┘
│ syscall
▼
┌─────────────────────────────────────┐
│ VFS │
│ fsnotify_perm(FAN_OPEN_PERM) │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ fanotify queue │
│ блокирует процесс X │
│ событие с fd и pid │ ─── read(fan_fd) ────▶ ┌────────────┐
└─────────────────────────────────────┘ │ AV scans │
│ file via fd│
└──────┬─────┘
│
┌─── write({fd, FAN_ALLOW}) ─┘
│
▼
┌─────────────────────────────────────┐
│ fanotify decision │
│ → ALLOW: open() возвращает ok │
│ → DENY: open() возвращает -EPERM │
└──────────────┬──────────────────────┘
│
▼
возврат из open()
fanotify_response.response = FAN_ALLOW | FAN_DENY принимается ядром; если daemon вышел молча, ядро возвращает
дефолтное значение по истечении таймаута. Bad-poll-loop в AV напрямую транслируется в зависший open()
пользовательского процесса — отсюда требование высокой надёжности и низкого latency.
Привилегии и capabilities¶
| Операция | Нужна capability |
|---|---|
FAN_CLASS_NOTIF per-inode (since Linux 5.1) |
CAP_SYS_ADMIN опц. |
FAN_MARK_MOUNT |
CAP_SYS_ADMIN |
FAN_MARK_FILESYSTEM (since Linux 4.20) |
CAP_SYS_ADMIN |
| permission events | CAP_SYS_ADMIN |
FAN_REPORT_FID (since Linux 5.1) |
без CAP |
С Linux 5.1 появился FAN_REPORT_FID: событие содержит не fd, а filesystem id + handle, что позволяет работать
без CAP_SYS_ADMIN и без открытия файла на каждое событие — экономия дескрипторов на массовых нагрузках.
Recursive по mount¶
FAN_MARK_MOUNT подписывает на все файлы, доступные через данный mountpoint:
Это даёт рекурсивный watch одним системным вызовом — главное преимущество перед inotify, который требует обхода дерева и добавления watch на каждую directory.
Минимальный fanotify-монитор¶
Простейший трассер всех open() на корневой ФС (требует sudo):
#define _GNU_SOURCE
#include <sys/fanotify.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
int main(void) {
int fan = fanotify_init(FAN_CLOEXEC | FAN_CLASS_NOTIF,
O_RDONLY | O_LARGEFILE);
if (fan < 0) { perror("fanotify_init"); return 1; }
if (fanotify_mark(fan, FAN_MARK_ADD | FAN_MARK_MOUNT,
FAN_OPEN | FAN_CLOSE_WRITE, AT_FDCWD, "/") < 0) {
perror("fanotify_mark"); return 1;
}
char buf[4096];
for (;;) {
ssize_t n = read(fan, buf, sizeof(buf));
if (n <= 0) { perror("read"); break; }
struct fanotify_event_metadata *m = (void *)buf;
for (; FAN_EVENT_OK(m, n); m = FAN_EVENT_NEXT(m, n)) {
char path[PATH_MAX] = "?";
if (m->fd >= 0) {
char proc_link[64];
snprintf(proc_link, sizeof(proc_link),
"/proc/self/fd/%d", m->fd);
ssize_t r = readlink(proc_link, path, sizeof(path) - 1);
if (r > 0) path[r] = '\0';
close(m->fd);
}
printf("pid=%d mask=0x%llx %s\n",
m->pid, (unsigned long long)m->mask, path);
}
}
return 0;
}
readlink("/proc/self/fd/N") — стандартный способ восстановить путь из открытого fd, потому что fanotify
передаёт именно дескриптор. Каждый m->fd обязательно нужно закрыть, иначе быстро упрётесь в RLIMIT_NOFILE.
inotify vs fanotify¶
| Свойство | inotify | fanotify |
|---|---|---|
| Granularity | per-watch (inode/path) | per-mount, per-FS, per-inode (5.1+) |
| Recursive | вручную (walk + per-dir watch) | FAN_MARK_MOUNT / FAN_MARK_FILESYSTEM |
| Возвращает FD | нет, только имя | да, ядро уже открыло fd |
| Permission events | нет | да (FAN_*_PERM с 2.6.36, расширения 5.x) |
| pid инициатора | нет | да |
| Привилегии | unprivileged | часто требует CAP_SYS_ADMIN |
| Лимиты | max_user_watches |
fanotify_groups, fanotify_marks |
| Сетевые ФС | не работает | работает (mount-level, NFS/SMB) |
| Поддержка ядра | Linux 2.6.13+ | Linux 2.6.36+, активно развивается |
| Главное применение | редакторы, IDE, hot reload | антивирусы, HSM, audit |
Архитектурно оба механизма строятся поверх одного fsnotify backend в ядре, но представляют разные политики и разные граничные условия использования.
┌──────────────────────────┐
│ fsnotify backend (ядро) │
│ hooks в VFS: │
│ ─ fsnotify_modify │
│ ─ fsnotify_open │
│ ─ fsnotify_close │
│ ─ fsnotify_perm │
└─────┬────────────┬───────┘
│ │
┌───────────┘ └────────────┐
▼ ▼
┌─────────────┐ ┌─────────────┐
│ inotify │ │ fanotify │
│ group │ │ group │
│ per-inode │ │ per-mount /│
│ per-process│ │ per-FS │
└─────────────┘ └─────────────┘
│ │
▼ ▼
struct inotify_event struct fanotify_event_metadata
(имя + маска) (fd + маска + pid)
│ │
▼ ▼
watcher process watcher с CAP_SYS_ADMIN
(IDE, hot reload) (AV, HSM, audit)
Реальные продукты¶
- VSCode, IntelliJ, JetBrains-семейство — inotify. На больших monorepo упираются в
max_user_watchesи показывают баннер «file watcher limit reached». - systemd path units —
PathChanged=,PathModified=,PathExistsGlob=. Реализованы поверх inotify внутри systemd. - incrond — cron-like демон, запускающий команду по inotify-событиям.
/etc/incron.d/*в формате<path> <mask> <command>. - ClamAV on-access scanner — fanotify permission events, FAN_OPEN_PERM, блокирует
open()до завершения скана. - Sophos / McAfee Endpoint — тот же подход: fanotify в FAN_CLASS_CONTENT.
- HSM в Lustre, IBM Spectrum Scale — fanotify FAN_CLASS_PRE_CONTENT для прозрачной подкачки данных.
- inotify-tools —
inotifywaitиinotifywatchдля CLI-сценариев и тестирования. - chokidar / watchdog / fsnotify (Go) — кроссплатформенные библиотеки watcher'ов, на Linux используют inotify.
- systemd-journald — следит за
/var/log/journalчерез inotify, чтобы подхватить новые binary log файлы.
Производительность¶
- Память на watch. Каждый inotify watch стоит ~1 KB в ядре. 100 000 watches на пользователя — порядка 100 MB kernel memory. fanotify mount-level подписки несравнимо дешевле.
- Throughput. При тысячах событий в секунду эффективнее читать
read()большим буфером и разбирать его в цикле (как в примере выше), а не вызыватьread()на каждое событие. - Q_OVERFLOW recovery. При получении
IN_Q_OVERFLOWединственный правильный путь — пересканировать всё дерево (или mount). Игнорировать переполнение — потерять консистентность. - Batch обработка. Координировать события: rename выглядит как пара MOVED_FROM/MOVED_TO с одинаковым cookie; обработка по одному ведёт к ложным удалениям.
- Дебоунс. Сохранение файла редактором часто генерирует серию: CREATE tmp → MODIFY → CLOSE_WRITE tmp → MOVED_FROM tmp → MOVED_TO file. Если реагировать на каждый — будет лишняя работа. Стандартный приём: собирать события за окно 50–200 мс и применять последнее состояние.
Когда что выбирать¶
- inotify: редакторы, hot reload, CLI-утилиты, обычные пользовательские задачи без необходимости блокировать операции.
- fanotify: enterprise security (AV, audit), HSM, mount-wide мониторинг, когда recursive walk слишком дорог и есть рутовые права.
- polling через
stat(): только если ничего из вышеперечисленного недоступно (например, удалённый NFS-volume, Windows-share без notification API). Используйте увеличенный интервал и отдельный поток.
Связанные темы¶
- Файловые дескрипторы — inotify/fanotify instance возвращается как обычный fd
- Основы файловых систем — VFS-уровень, где сидят fsnotify-хуки
- Открытые файлы и процессы —
lsof,/proc/<pid>/fdдля отладки watcher'ов - Операции с директориями —
nftw/opendirдля recursive walk перед подпиской inotify - FUSE — пример FS, где inotify работает только на стороне daemon, не клиентов
Источники¶
- inotify(7) — man7.org
- fanotify(7) — man7.org
- fsnotify — kernel.org documentation
- LWN: fanotify, three years on
- inotify-tools on GitHub
man 2 inotify_init,man 2 inotify_add_watch,man 2 fanotify_init,man 2 fanotify_mark