Долгоживущий процесс, который при каждом запросе заново открывает конфиг и сверяет время его изменения, тратит силы впустую. Чаще всего файл не менялся, а диск всё равно дёргается. Хочется обратной схемы: процесс спокойно работает и спит, а ядро само толкает его в плечо ровно в тот момент, когда кто-то сохранил правку. Именно для этого в Linux существует inotify - подсистема уведомлений о событиях в файловой системе, появившаяся ещё в ядре 2.6.13 в 2005 году и с тех пор ставшая стандартным инструментом для так называемого hot-reload, горячей перезагрузки данных без остановки сервиса.
Идея hot-reload проста на словах и коварна в деталях. Веб-сервер подхватывает обновлённый сертификат без рестарта. Демон применяет новый конфиг, не разрывая открытые соединения. Сборщик пересобирает проект, как только вы нажали "сохранить" в редакторе. Во всех этих сценариях кто-то должен надёжно поймать факт изменения файла. Опрос в цикле через stat решает задачу грубо и затратно. inotify решает её точно и почти бесплатно по ресурсам, но требует понимания того, как именно файлы меняются на диске.
Чем опрос файла в цикле проигрывает событийной модели уведомлений
Самый наивный подход к отслеживанию изменений - это polling, периодический опрос. Программа раз в секунду вызывает stat на интересующем файле и сравнивает поле mtime с запомненным значением. Если время правки выросло, файл считается изменённым. Работает, спору нет. Но цена такого решения растёт линейно от числа наблюдаемых объектов и обратно пропорциональна интервалу опроса.
Посчитаем. Сервис следит за сотней конфигурационных файлов с интервалом в одну секунду. Это сто системных вызовов stat ежесекундно, около 8,64 миллиона вызовов за сутки. Большинство вернёт ровно тот же mtime, что и в прошлый раз. Чистая работа вхолостую. Уменьшить интервал до 100 миллисекунд ради скорости реакции означает удесятерить нагрузку. Увеличить ради экономии - получить заметную задержку между сохранением файла и реакцией приложения.
Событийная модель переворачивает логику. Приложение один раз сообщает ядру список интересующих путей и засыпает на чтении специального дескриптора. Пока ничего не происходит, процесс не потребляет процессорное время вообще. Как только файл меняется, ядро формирует событие, и спящий процесс мгновенно просыпается. Ноль вызовов вхолостую, реакция в пределах долей миллисекунды.
Как устроены дескриптор inotify, watch-точки и поток событий
В основе подсистемы лежат три вызова. Первый создаёт сам экземпляр inotify и возвращает обычный файловый дескриптор, с которого потом читаются события. Второй регистрирует наблюдение за конкретным путём с набором интересующих масок. Третий снимает наблюдение. Вот минимальный каркас на C, показывающий запуск экземпляра и постановку файла под наблюдение:
#include <sys/inotify.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
int fd = inotify_init1(IN_NONBLOCK);
if (fd < 0) { perror("inotify_init1"); return 1; }
int wd = inotify_add_watch(fd, "/etc/myapp/config.yaml",
IN_CLOSE_WRITE | IN_MOVED_TO);
if (wd < 0) { perror("inotify_add_watch"); return 1; }
/* далее цикл чтения событий */
return 0;
}
Возвращённое значение wd называется watch-дескриптором, это небольшое целое число, идентифицирующее конкретное наблюдение внутри экземпляра. Когда событие приходит, в его структуре будет именно этот номер, по нему приложение понимает, какой из множества наблюдаемых путей затронут. Каждое событие приходит в виде структуры фиксированной части и переменного хвоста с именем файла:
struct inotify_event {
int wd; /* watch-дескриптор */
uint32_t mask; /* битовая маска произошедших событий */
uint32_t cookie; /* уникальная метка для связки move-событий */
uint32_t len; /* длина поля name вместе с нулями */
char name[]; /* имя файла, если следили за каталогом */
};
Чтение из дескриптора возвращает сразу пачку таких структур подряд, потому что за один read ядро может отдать несколько накопившихся событий. Разбирать буфер нужно аккуратно, шагая по нему с учётом переменной длины каждой записи. Вот цикл чтения и разбора, который и составляет сердце любого наблюдателя:
char buf[4096] __attribute__((aligned(__alignof__(struct inotify_event))));
ssize_t len = read(fd, buf, sizeof(buf));
for (char *p = buf; p < buf + len; ) {
struct inotify_event *ev = (struct inotify_event *)p;
if (ev->mask & IN_CLOSE_WRITE)
reload_config(); /* применяем новый конфиг */
p += sizeof(struct inotify_event) + ev->len;
}
Размер буфера выбран с запасом. Минимально безопасное значение для одного события считается как размер структуры плюс максимальная длина имени плюс единица, что обычно укладывается в несколько килобайт. Брать меньше рискованно: если имя файла не помещается в буфер, чтение вернёт ошибку.
Почему слежение за каталогом надёжнее слежения за самим файлом
Здесь начинается самое важное и наименее очевидное. Кажется логичным повесить наблюдение прямо на нужный файл и ждать события IN_MODIFY. На практике это путь к тихим сбоям, и причина в том, как именно текстовые редакторы и многие программы сохраняют данные.
Большинство приличных редакторов не пишут поверх существующего файла. Они применяют атомарную замену: создают временный файл рядом, записывают в него новое содержимое, а затем одним вызовом rename переименовывают временный поверх старого. Такой приём гарантирует, что файл никогда не окажется в полузаписанном состоянии, даже если питание пропадёт посреди операции. Но для наблюдателя это означает катастрофу. inotify следит за инодой, внутренним объектом файловой системы, а не за именем. После rename старая инода, на которую был повешен watch, отвязывается от имени, и новый файл живёт уже на другой иноде. Наблюдение продолжает висеть на осиротевшей старой иноде, которая больше ни с чем не связана, и события по нужному пути перестают приходить вовсе.
Эксперимент с Vim показывает это наглядно. Если запустить inotifywait в каталоге и сохранить уже существующий файл, поток событий при атомарном сохранении выглядит так: создание временного файла 4913, его открытие, изменение атрибутов, закрытие на запись, удаление этого временного файла, затем MOVED_FROM для старого имени, CREATE для нового, открытие, модификация и финальное CLOSE_WRITE. Цифра 4913 - это характерный пробный файл, которым Vim проверяет права на запись в каталог. Вся эта пляска означает, что watch, повешенный на исходный файл, после переименования становится бесполезным.
Решение проверено практикой больших проектов. Наблюдать нужно за каталогом, в котором лежит файл, а не за самим файлом. Тогда приложение получает события CREATE и MOVED_TO с именем интересующего файла в поле name и фильтрует их по этому имени. При слежении за каталогом атомарная замена выглядит как приход нового файла с известным именем, и это ровно тот момент, когда конфиг готов к чтению. Разработчики k9s столкнулись с этим в чистом виде: их наблюдатель следил только за файлом темы, и после того как редактор писал во временный файл и переименовывал его поверх целевого, наблюдение за инодой переставало работать, потому что слежение шло за нижележащей инодой.
Какое именно событие сигнализирует о готовности файла к чтению
Выбор маски событий решает, сработает ли hot-reload вовремя и без ложных срабатываний. Соблазн использовать IN_MODIFY велик, ведь буквально это "файл изменился". Но IN_MODIFY срабатывает на каждую запись в файл. Если программа пишет конфиг порциями, наблюдатель получит десяток событий и попытается перечитать файл, пока тот ещё не дописан до конца. В результате приложение прочитает обрезанные данные.
Правильный сигнал - это IN_CLOSE_WRITE. Он приходит ровно один раз, в момент, когда последний дескриптор, открывавший файл на запись, закрылся. Иными словами, писавший процесс закончил и отпустил файл. Это единственный надёжный момент, когда содержимое гарантированно целостно. Анализ событий Vim это подтверждает прямо: команду пересборки следует запускать по событию CLOSE_WRITE для исходного файла, поскольку оно происходит ровно один раз в обоих случаях и файл становится читаемым сразу после него.
Для атомарной замены через переименование к IN_CLOSE_WRITE добавляют IN_MOVED_TO. Связка из этих двух масок покрывает оба распространённых способа сохранения: прямую запись поверх и атомарную замену. Программы вроде Sublime используют ещё более коварный вариант: вызывают rename без удаления исходного файла, из-за чего инструмент слежения видит единственное событие MOVED_TO и воспринимает его как появление совершенно нового файла. Если наблюдатель не обрабатывает MOVED_TO, такое сохранение он попросту проспит.
Отдельная тонкость касается событий перемещения. Маски IN_MOVED_FROM и IN_MOVED_TO снабжаются полем cookie, ненулевой меткой, которая связывает их в пару. Документация подсистемы предупреждает прямо: эти два события не гарантированно идут подряд в потоке, и для их сопряжения нужно полагаться именно на cookie. Если файл просто переместили внутри наблюдаемого дерева, по cookie можно понять, что это та же сущность сменила место, а не исчезла и появилась заново.
Лимиты ядра на число наблюдений и борьба за память
inotify не бесплатен в смысле системных ресурсов, и об этом приложения часто узнают в самый неподходящий момент. Каждое наблюдение занимает память в ядре и удерживает наблюдаемую иноду в кеше. Поэтому существуют лимиты, прописанные в трёх параметрах ядра. Их можно посмотреть в каталоге procfs:
cat /proc/sys/fs/inotify/max_user_watches # макс. число watch на пользователя
cat /proc/sys/fs/inotify/max_user_instances # макс. число экземпляров на пользователя
cat /proc/sys/fs/inotify/max_queued_events # глубина очереди событий
Исторически параметр max_user_watches был установлен в скромные 8192 ещё при появлении подсистемы в 2005 году и не менялся полтора десятилетия. Для современных нагрузок этого мало до смешного. В обсуждении ядерного патча приводился показательный список значений, которые реальные проекты вынуждены выставлять вручную: vscode использует 524288, Dropbox рекомендует 100000, lsyncd доходит до 2000000, code42 ставит 1048576. Когда наблюдатель пытается повесить watch сверх лимита, вызов добавления возвращает ошибку с кодом ENOSPC, и без знания причины разработчик долго гадает, почему уведомления приходят выборочно.
Что произошло с лимитом дальше, стоит знать. Патч в ядро привязал значение по умолчанию к объёму памяти машины: по образцу поведения epoll максимум стал подстраиваться под доступную память, занимая не более одного процента адресуемой памяти в диапазоне от 8192 до 1048576. На практике система с восемью и более гигабайтами памяти получает максимальное значение 1048576. Цена памяти за наблюдение невелика, около 80 байт на структуру отслеживания иноды на 64-битных архитектурах, но настоящая нагрузка скрыта глубже. Как отметил один из мейнтейнеров, расход памяти от watch-точек определяется в основном инодами каталогов, которые они удерживают в кеше, и в пределе миллион наблюдений способен закрепить в кеше до гигабайта данных.
Поднять лимит вручную можно через sysctl, временно до перезагрузки и постоянно через конфигурационный файл:
# временно, на текущую сессию
sudo sysctl -w fs.inotify.max_user_watches=524288
# постоянно, переживёт перезагрузку
echo fs.inotify.max_user_watches=524288 | sudo tee /etc/sysctl.d/40-inotify.conf
sudo sysctl --system
Тут поджидает ещё одна засада, о которой предупреждают пользователи свежих дистрибутивов. На Ubuntu 24.04 настройка из привычного файла может молча не примениться, потому что в каталоге /usr/lib/sysctl.d/ присутствует файл 30-tracker.conf, который переопределяет значение меньшим, а файлы из всех каталогов загружаются в порядке имён. Поэтому после правки стоит перечитать актуальное значение из procfs и убедиться, что оно стало таким, как задумано.
Что подсистема не умеет и где hot-reload требует подстраховки
У inotify есть честно очерченные границы, игнорировать которые опасно. Подсистема не работает рекурсивно сама по себе. Поставить одно наблюдение на каталог и автоматически получать события из всех его подкаталогов нельзя. Глубокое дерево приходится обходить вручную, навешивая отдельный watch на каждый каталог, а при появлении новых вложенных каталогов оперативно добавлять watch и на них. Именно отсюда растёт прожорливость по числу наблюдений у инструментов вроде сборщиков фронтенда, следящих за тысячами файлов проекта.
Очередь событий тоже не бездонна. Её глубина задаётся параметром max_queued_events. Если события приходят быстрее, чем приложение успевает их читать, очередь переполняется, и ядро ставит особое событие IN_Q_OVERFLOW. Это сигнал тревоги: часть изменений потеряна безвозвратно, и наблюдатель не знает, какие именно. Грамотный hot-reload обрабатывает переполнение как команду на полную пересинхронизацию, перечитывая всё наблюдаемое состояние с нуля, а не пытаясь восстановить пропущенное по крупицам.
Отдельная мина связана с сетевыми и виртуальными файловыми системами. inotify полагается на уведомления от ядерного уровня виртуальной файловой системы, и для сетевых монтирований вроде NFS события об изменениях, сделанных на другой машине, попросту не приходят. Файл правит удалённый узел, локальное ядро об этом не уведомляется, наблюдатель молчит. В таких сценариях честнее вернуться к опросу или использовать механизмы самой сетевой файловой системы.
Наконец, типичный практический совет, рождённый болью многих разработчиков: давить дребезг событий. Одно сохранение файла редактором, как видно из разбора событий выше, способно породить целую серию уведомлений. Без защиты приложение перечитает конфиг несколько раз подряд за доли секунды. Лекарство называется debouncing: после первого события наблюдатель выжидает короткую паузу, скажем 50 или 100 миллисекунд, собирает в неё все последующие события и применяет перезагрузку один раз, когда поток уведомлений утих. Этот приём превращает шумный поток событий в одно осмысленное действие и убирает львиную долю странных багов hot-reload.
Собранная вместе картина выглядит стройно. Создать экземпляр inotify, повесить наблюдение на каталог с нужным файлом, ждать связку IN_CLOSE_WRITE и IN_MOVED_TO, фильтровать по имени, гасить дребезг короткой паузой, обрабатывать переполнение очереди как сигнал к полной пересинхронизации и помнить про лимиты ядра. Шесть правил, каждое из которых выстрадано чьим-то ночным дежурством, превращают капризную на первый взгляд подсистему в надёжный фундамент для горячей перезагрузки в собственном демоне или сервере. И тогда процесс действительно спит, пока ядро не толкнёт его в плечо ровно в нужный момент.