MAATRIX / Блог / inotify упёрся в лимит, и сборщик перестал видеть новые файлы

inotify упёрся в лимит, и сборщик перестал видеть новые файлы

MAATRIX

Сборщик файлов работал месяцами без единой жалобы, а потом часть клиентов начала писать: «загрузили файл, а в личном кабинете его нет». Не у всех, не всегда, без видимой закономерности — и это самое неприятное в диагностике. Разбираем, как обычный ENOSPC от inotify_add_watch() два дня маскировался под проблему с диском, пока не нашли точное совпадение цифр в /proc.

Что мы увидели: жалобы без единой зацепки

Сервис устроен просто: у каждого клиента своя директория для загрузки, внутри — подпапки по датам (/data/tenants/<id>/<yyyy-mm-dd>/). Отдельный процесс-сборщик держит inotify-вотчер на каждую такую подпапку и по событию IN_CLOSE_WRITE кладёт задачу в очередь на обработку. Схема отработанная, простая, без сюрпризов — до конца августа.

Тикеты начали приходить не лавиной, а по одному-два в час, и география была странной: у одних клиентов файлы терялись стабильно, у других — вообще не было проблем, у третьих файлы то появлялись с задержкой, то не появлялись совсем. На дашборде сборщика ничего не горело: процесс жив, health-check зелёный, CPU и память в норме. Первая мысль дежурного — «клиенты сами что-то делают не так» — продержалась около получаса, пока не собрали список ID клиентов из тикетов и не увидели: все они были подключены за последние три недели, то есть позже определённой даты.

Гипотезы, которые отбросили в первый час

Дальше пошёл стандартный чек-лист, и именно то, что все пункты оказались «зелёными», в итоге и указало на реальную причину.

  • Диск. df -h на сервере с директориями показывал больше 40% свободного места. Гипотеза про заполненный диск отпала сразу — но, забегая вперёд, само сообщение об ошибке потом снова про неё напомнит.
  • Сеть и хранилище. Директории лежат на локальном NVMe, не на NFS — исключили задержки монтирования и разрывы соединения.
  • Очередь обработки. Проверили глубину очереди в RabbitMQ (rabbitmqctl list_queues name messages) — сообщения не копились. Это было важно: значит, воркеры, которые *разбирают* очередь, ни при чём, потому что до очереди дело просто не доходило.
  • Зависший процесс. Посмотрели py-spy dump по PID сборщика — основной цикл событий крутился штатно, никаких блокирующих вызовов, поток не завис.
  • Права доступа. Проверили владельца и права на новых директориях — идентичны старым, stat ничего подозрительного не показал.

За час мы вычеркнули сеть, диск, очередь, права и зависание процесса — и остались ровно с одной зацепкой: проблема появилась примерно тогда же, когда выросло число подключённых клиентов. Тут и вспомнили про лимиты открытых файлов и подобные кернельные ограничения, которые обычно всплывают именно так — тихо, без падения процесса, просто «часть операций перестаёт срабатывать».

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Странная деталь в логах: «No space left on device» при свободном диске

Стандартный уровень логирования у сборщика — INFO, и там было пусто. Подняли уровень до DEBUG на одном инстансе и через несколько минут увидели повторяющуюся строку:

2026-08-27 14:12:03 DEBUG watcher: failed to add watch for
/data/tenants/4831/2026-08-27: OSError(28, 'No space left on device')

Код ошибки 28 — это ENOSPC. Первая реакция была «опять диск», но df -h только что показал десятки гигабайт свободными. Дело в том, что inotify_add_watch(2) возвращает ENOSPC в двух совершенно разных ситуациях: когда физически не хватает места на устройстве, где создаётся файл, — и когда у процесса (точнее, у пользователя, от которого он запущен) закончился лимит на количество активных inotify-вотчеров. Man-страница inotify(7) честно предупреждает про эту перегрузку кода ошибки, но в логах приложения выглядит это ровно как «кончилось место», и первые полчаса именно на этом расследование забуксовало.

Хуже того, ошибка логировалась на уровне DEBUG и перехватывалась внутри try/except без повторного выброса — сборщик просто продолжал работать дальше, как будто ничего не произошло. Директория оставалась без вотчера, файлы в неё падали, но событие IN_CLOSE_WRITE никогда не срабатывало, и задача в очередь не попадала. Никакого падения процесса, никакого алерта — только тихая дыра в покрытии.

Как считали watch'и и нашли точное совпадение с лимитом

Дальше — проверка гипотезы цифрами, а не догадками. Сначала посмотрели системный лимит:

cat /proc/sys/fs/inotify/max_user_watches
8192
cat /proc/sys/fs/inotify/max_user_instances
128
cat /proc/sys/fs/inotify/max_queued_events
16384

max_user_watches — это суммарное число вотчеров, которое пользователь (не процесс, а именно UID) может держать открытыми одновременно, независимо от того, сколько у него запущено процессов или inotify-инстансов. Значение 8192 — это исторический дефолт ядра Linux, который многие дистрибутивы уже переопределяют, но на этом сервере он остался нетронутым с момента установки.

Дальше посчитали, сколько вотчеров реально держит сборщик. У процесса с открытым inotify-инстансом каждый файловый дескриптор, ссылающийся на anon_inode:inotify, хранит в /proc/<pid>/fdinfo/<fd> построчный список активных вотчеров:

PID=$(pgrep -f collector-watcher)
for fd in /proc/$PID/fd/*; do
  target=$(readlink -f "$fd")
  if [[ "$target" == anon_inode:inotify* ]]; then
    n=$(basename "$fd")
    echo "fd=$n watches=$(grep -c '^inotify' /proc/$PID/fdinfo/$n)"
  fi
done

Результат: один-единственный fd с 8192 строками inotify внутри. Ровно лимит, тютелька в тютельку. Это и было подтверждением: сборщик упёрся в потолок max_user_watches, и каждая попытка добавить 8193-й вотчер молча заканчивалась ENOSPC. Дальше проверили, когда именно был пройден этот порог — по внутренней метрике числа обслуживаемых директорий она пересекла отметку 8192 примерно за 36 часов до первых жалоб, что совпало по времени идеально.

Почему inotify не спас: архитектура «один watch на директорию»

Здесь стоит объяснить, почему лимит вообще был так легко пробит. У inotify нет встроенной рекурсии: нельзя поставить один вотчер на дерево каталогов и получать события из всех вложенных подпапок. Приходится явно вызывать add_watch() на каждую директорию, которую нужно отслеживать, и снимать вотчер, когда директория больше не нужна.

Схема с ежедневными подпапками на клиента такую рекурсию и провоцирует: у каждого активного клиента появляется новая директория раз в сутки, а старые не всегда снимались вовремя — снятие вотчера происходило по таймеру раз в несколько часов, а не сразу после последней записи. В результате число висящих вотчеров росло быстрее, чем предполагали при проектировании: не линейно от числа клиентов, а от числа клиентов, умноженного на количество дней хранения истории. Когда сервис на старте проектировали, лимит в 8192 казался огромным запасом — при полусотне клиентов и недельном окне это действительно так и было. Рост числа клиентов за последние два месяца этот запас съел, а большое количество мелких директорий и файлов — это вообще отдельная категория проблем, о которую спотыкаются независимо от inotify.

Отдельно стоит отметить, что ENOSPC от исчерпания max_user_watches — не единственный способ, которым inotify тихо теряет события. Есть ещё IN_Q_OVERFLOW: если события генерируются быстрее, чем приложение успевает их вычитывать из очереди ядра, часть событий просто выбрасывается, а приложению приходит одно специальное событие-маркер переполнения, которое легко пропустить, если не проверять его явно. В нашем случае причиной был именно лимит вотчеров, но при разборе логов пришлось сначала исключить и этот сценарий — искали IN_Q_OVERFLOW в дампе событий, не нашли ни одного.

Что откатили и починили в первые сутки

Первым делом подняли системные лимиты — с большим запасом, а не «впритык под текущую нагрузку»:

# /etc/sysctl.d/99-inotify.conf
fs.inotify.max_user_watches = 524288
fs.inotify.max_user_instances = 1024
fs.inotify.max_queued_events = 65536
sysctl --system
sysctl fs.inotify.max_user_watches
fs.inotify.max_user_watches = 524288

Применили без перезагрузки сервера — sysctl меняет значение в рантайме, но самому сборщику всё равно потребовался рестарт, чтобы он перечитал список директорий и заново расставил вотчеры на всё, что было пропущено за последние полтора суток. После рестарта число активных вотчеров выровнялось на актуальное количество директорий, ошибки ENOSPC в логах пропали.

Но поднятый лимит не возвращает файлы, которые уже пришли, пока вотчер отсутствовал. Пришлось поднимать список директорий за проблемный период и сверять его с внутренней таблицей обработанных файлов — там, где файл лежал на диске, но отметки об обработке не было, ставили задачу в очередь вручную. По объёму это оказалось около полутора тысяч файлов у 40 с небольшим клиентов — заметно меньше, чем можно было ожидать по числу тикетов, потому что часть клиентов просто ещё не успела заметить пропажу.

Что изменили в архитектуре и мониторинге навсегда

Поднятый лимит закрывает симптом, но не причину: при дальнейшем росте числа клиентов и директорий тот же сценарий повторится на новом потолке, просто позже. Поэтому изменения пошли по трём направлениям.

Первое — перестали проглатывать ошибку. Раньше исключение при добавлении вотчера ловилось и логировалось на DEBUG без каких-либо последствий:

# было
try:
    wd = inotify.add_watch(path, mask)
except OSError as e:
    logger.debug("watch add failed: %s", e)

Стало — с явной проверкой кода ошибки, метрикой и алертом на пейджер:

# стало
try:
    wd = inotify.add_watch(path, mask)
except OSError as e:
    if e.errno == errno.ENOSPC:
        inotify_watch_failures_total.inc()
        logger.critical("inotify watch limit hit for %s: %s", path, e)
    raise

Второе — добавили метрику текущего потребления лимита, а не только факта ошибки. Сборщик раз в минуту читает /proc/sys/fs/inotify/max_user_watches и сравнивает с числом реально открытых вотчеров, публикуя gauge inotify_watches_used рядом с inotify_watches_max. Алерт срабатывает при достижении 70% от лимита — это даёт время поднять sysctl заранее, а не по факту первых жалоб.

Третье — добавили страховочный механизм независимо от inotify. Раз в 15 минут отдельная задача обходит директории через os.scandir() и сверяет список файлов с таблицей обработанных — то, что найдено, но не отмечено, уходит в очередь повторно. Это не отменяет inotify как основной, быстрый механизм доставки событий, но закрывает и сценарий с исчерпанием лимита вотчеров, и IN_Q_OVERFLOW, и вообще любой будущий способ тихо потерять событие, о котором мы пока не думали. Такая избыточность стоит немного лишней нагрузки на диск, но у себя мы решили, что это разумная цена за то, чтобы больше не писать разбор инцидента по этому же поводу.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Как узнать, сколько inotify-вотчеров реально использует процесс?

Найдите PID процесса и переберите его файловые дескрипторы: те, что ссылаются на anon_inode:inotify, — это открытые inotify-инстансы. Для каждого такого дескриптора количество строк inotify в /proc/<pid>/fdinfo/<fd> и есть число активных вотчеров на этом инстансе. Сумма по всем инстансам одного UID не должна приближаться к fs.inotify.max_user_watches.

Почему ошибка выглядела как нехватка места на диске?

Потому что ядро Linux использует один и тот же код ошибки ENOSPC («No space left on device») и для реального переполнения файловой системы, и для исчерпания лимита inotify-вотчеров у пользователя. Разница видна только по контексту — если ошибка приходит именно от inotify_add_watch(), а df показывает свободное место, речь почти наверняка о лимите, а не о диске.

Можно ли просто заранее поднять лимит с огромным запасом и забыть про проблему?

Частично — да, разумный запас снижает риск повторения на годы вперёд. Но это лечит симптом, а не причину: если приложение не логирует и не считает ошибки добавления вотчера, следующее исчерпание лимита снова пройдёт незаметно, просто на большем масштабе. Мониторинг текущего потребления лимита нужен в любом случае.

Стоит ли вообще полагаться на inotify для отслеживания большого дерева директорий?

Для стабильного, заранее известного числа директорий — да, это лёгкий и быстрый механизм. Но если число отслеживаемых поддиректорий растёт вместе с бизнесом (новые клиенты, новые папки по датам), нужен либо плоский план каталогов с меньшим числом вотчеров, либо гибридная схема из этой статьи: inotify как основной канал плюс периодическая сверка как страховка. Это же имеет смысл проверить в связке с логами, по которым разбирают причину сбоя — часто именно уровень логирования, а не сам механизм, мешает заметить проблему вовремя.

Отличается ли эта проблема от нехватки inode на файловой системе?

Да, это разные лимиты на разных уровнях. Нехватка inode — это ограничение самой файловой системы на количество файлов и директорий, которые вообще можно создать на разделе, и она никак не связана с ядерным лимитом на число inotify-вотчеров конкретного пользователя. Оба лимита при этом объединяет одно: df -h их не показывает, и оба нужно проверять отдельными командами.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →