28 июл. 2025 г.·5 мин. чтения

EMFILE: слишком много открытых файлов в Node — отладка в продакшне

Ошибки EMFILE «too many open files» в Node часто вызваны утечками дескрипторов в AI‑сгенерированных приложениях. Типичные причины и быстрые проверки в продакшне, чтобы подтвердить исправление.

EMFILE: слишком много открытых файлов в Node — отладка в продакшне

Что на самом деле означает «слишком много открытых файлов»

Ошибка обычно выглядит как EMFILE: too many open files (или ENFILE). Это означает, что в вашем приложении закончились файловые дескрипторы.

Файловый дескриптор — это небольшой хендл, который операционная система даёт процессу при открытии чего‑то: файл, сетевой сокет, файл логов или каталог. Когда процесс достигает своего лимита, новые открытия завершаются неудачей.

Это может ломать части приложения, которые вообще не кажутся «файловыми»: API‑вызовы, подключения к базе, загрузки, server‑side rendering, даже чтение конфигов. Поэтому одно и то же событие может выглядеть как случайные 500‑е, пока вы не увидите в логах реальное сообщение: EMFILE.

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

AI‑сгенерированные Node‑приложения больше склонны к утечкам ресурсов, потому что часто склеивают сниппеты без ясного жизненного цикла. Признаки: отсутствие finally, добавление листенеров на каждый запрос, стримы без обработчиков ошибок, или «быстрые фиксы», которые открывают новые соединения вместо повторного использования существующих.

Когда это происходит — снимите небольшой снэпшот сразу. Обычно этого достаточно, чтобы связать всплеск с конкретным кодом и трафиком:

  • окно времени (первый ошибка и пик)
  • какой endpoint, задача или worker были запущены
  • версия/коммит деплоя и изменения конфигурации
  • один полный stack trace и соседние логи
  • уровень трафика (нормальный, всплеск, или фоновые задания)

Как это проявляется в деплоях Node

EMFILE редко выглядит как чистый очевидный сбой. Большинство команд сначала замечают «случайные» 500‑е, застрявшие запросы или контейнер, который внезапно перестал принимать соединения, хотя CPU и память в порядке.

Две формы утечек встречаются в продакшне:

  • Утечки на запрос быстро приводят к падениям. Всплеск трафика вызывает ошибки за минуты, потому что каждый запрос оставляет открытым файл, сокет или watcher.
  • Медленные утечки проявляются поздно. Приложение работает часами или днями и затем падает после постоянного «капания» незакрытых ресурсов.

В логах и поведении это часто выглядит так:

  • всплески 5xx, которые восстанавливаются после перезапуска
  • сначала падают загрузки или обработка изображений (стримы быстро съедают дескрипторы)
  • ошибки базы или Redis, которые кажутся не связанными (новые сокеты не открываются)
  • «работает локально», но падает под реальным трафиком или cron‑задачами
  • один под «проклят», а остальные выглядят нормально

Перезапуски могут скрывать корень проблемы. Если платформа быстро перезапускает упавшие процессы, вы можете попасть в цикл: утечка растёт, приложение умирает, возвращается «здоровым», и утечка начинается снова.

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

Последняя проверка здравого смысла: если ошибка происходит сразу при старте при слабом трафике, возможно, вы просто упираетесь в низкий системный лимит дескрипторов. Если она появляется во время работы и усиливается на конкретных маршрутах или задачах (загрузки, скрейпинг, генерация PDF), это, скорее всего, утечка в приложении.

Обычные причины в AI‑сгенерированных Node‑приложениях

AI‑сгенерированные Node‑проекты часто работают в демо, а затем получают EMFILE, как только появляются реальный трафик, реальные файлы или долгоживущие задачи. Шаблон прост: что‑то открывает файл или соединение и не закрывает его, в результате процесс постепенно исчерпывает дескрипторы.

Частая триггерная ситуация — код обработки файлов, который использует стримы, но не закрывает их на всех путях. Например, хэндлер запроса открывает read stream, затем раньше возвращает ошибку и не вызывает destroy() или не ждёт finish/close.

Ещё частая причина — фоновые watchers, созданные scaffold‑ом. Прототип мог запустить наблюдатели файлов для hot reload, генерации миниатюр или синхронизации, а затем случайно запускать их в продакшне. Каждый watcher использует дескрипторы и может размножаться по воркерам.

Утечки, которые встречаются чаще всего

Эти распространённые виновники часто появляются в AI‑коде:

  • Стримы, открытые внутри циклов (импорт CSV, пакетная обработка изображений) без backpressure — сотни файлов открыты одновременно.
  • HTTP‑клиенты или raw TCP‑сокеты, которые остаются открыты навсегда, особенно когда ретраи создают новые соединения, но не закрывают старые.
  • Неправильная работа с пулом базы данных: соединения берут и не возвращают при ошибках (нет finally).
  • Порождаемые child processes для конвертации или скрейпинга, с оставшимися открытыми пайпами stdout/stderr.
  • Логгеры и писатели метрик, которые открывают новый файл на запрос или неправильно ротируют логи и держат старые хендлы.

Почему AI‑код усугубляет проблему

Сгенерированный код часто содержит много ранних возвратов и catch‑блоков, но без последовательной очистки. Он смешивает паттерны (callback, промисы, стримы) в одной функции, из‑за чего легко пропустить путь выхода.

Быстрые способы подтвердить, что это утечка FD (а не догадки)

Когда вы видите EMFILE too many open files Node, задайте один вопрос: вы упираетесь в низкий лимит или ваше приложение теряет дескрипторы со временем?

Сначала проверьте текущий лимит для процесса.

# In the same environment as the Node process
ulimit -n

# Per-process limits (replace PID)
cat /proc/PID/limits | grep -i "open files"

Далее измерьте, сколько FD сейчас открыто у процесса Node, затем проверьте ещё раз позже. Утечка выглядит как число, которое постоянно растёт, даже когда трафик стабилен.

# Count open file descriptors for the process
ls -1 /proc/PID/fd | wc -l

Если можете — снимайте выборки или строите график этого счётчика. Вы ищете ровный подъём, который не падает после завершения запросов.

Чтобы увидеть, что остаётся открытым, сделайте быстрый снэпшот lsof и ищите повторяющиеся элементы.

# High-level view of what the process is holding
lsof -p PID | head

# Quick pattern check (examples: uploads, temp files, sockets)
lsof -p PID | grep -E "(/tmp|uploads|\.log|TCP)" | head

Несколько типичных паттернов:

  • тысячи похожих имён временных файлов (загрузки не закрываются)
  • повторяющиеся лог‑файлы (пользовательский логгер переоткрывает)
  • много исходящих сокетов (HTTP‑клиенты не закрывают соединения)

Пошагово: изолировать и остановить утечку

Починить скрытые утечки соединений
Диагностируем сломанное авторство, пулы и логику повторных попыток, которые держат сокеты открытыми.

Воспринимайте EMFILE как проблему скорости: что‑то открывает дескрипторы быстрее, чем закрывает их. Цель — доказать, какой процесс и какая функция заставляют счётчик расти, затем выпустить минимальный безопасный фикс.

Начните с тайминга. Сопоставьте, когда начинаются ошибки, с пиковым трафиком, cron‑задачами, воркерами или пакетными задачами. Если это только во время ночного импорта — у вас уже сильный подозреваемый.

Затем посмотрите, что реально открыто. Утечка, вызванная загрузками, обычно даст много обычных файлов. Плохой паттерн HTTP‑клиента — много сокетов в похожих состояниях. Некоторые конфигурации логирования оставляют открытыми пайпы.

Практический поток «изолируй сначала»:

  • Найдите PID, который выдаёт ошибки, и следите за числом открытых FD каждые 10–30 секунд.
  • Снимите один снэпшот lsof и просканируйте наиболее повторяющиеся пути или удалённые конечные точки.
  • Отключайте по одному воркеру, задаче или фиче‑флагу и смотрите, перестаёт ли кривая FD расти.
  • Добавьте минимальные счётчики вокруг подозреваемого пути (opens vs closes на запрос/задачу) и логируйте только агрегаты.
  • Внедрите целевой фикс и подтвердите, что наклон кривой стал плоским при той же нагрузке.

Для временной инструментализации держите всё простым и безопасным. Для маршрута загрузки считайте, сколько стримов вы создаёте и сколько из них испускают close или end. Для воркера fetch логируйте, сколько ответов вы начинаете и сколько полностью потребляете.

Если отключение одного воркера останавливает рост через минуты — вы, скорее всего, нашли утечку. Если рост продолжается, возможно, источников несколько или общая библиотека используется повсюду.

Ловушки, которые поддерживают утечку

EMFILE остаётся, когда очистка происходит только на успехе.

Хендл файла, сетевой сокет или курсор создаётся, затем выбрасывается исключение, и шаг закрытия не выполняется. Если вы тестируете только сценарии успеха, вы пропустите утечку.

Обычные виновники

Эти вещи часто встречаются в AI‑коде, потому что копируются паттерны без защитных частей:

  • Нет try/finally вокруг открываемых ресурсов — ошибки обходят закрытие.
  • Стримы без обработчиков error, где путь ошибки пропускает очистку.
  • Наблюдатели файлов fs.watch или chokidar, оставшиеся включёнными в продакшне.
  • Новый клиент БД, создаваемый на каждый запрос вместо использования пула.
  • Шатдаун, который игнорирует SIGTERM или не ждёт очистки, из‑за чего старые соединения висят во время деплоя.

Конкретный пример, как это бьёт

Представьте endpoint загрузки, который читает временный файл, отправляет в сторидж и затем удаляет временный файл. При таймауте загрузки она падает на середине. Если код не закрывает read stream в finally, дескриптор временного файла может остаться открытым. Делайте это часто — сервер упирается в лимит.

Хорошая проверка — намеренно вызвать путь ошибки (отменить запрос, симулировать таймаут) и проверить, перестаёт ли счётчик открытых FD расти после окончания запроса.

Как читать подсказки по тому, что осталось открытым

Самый быстрый путь — смотреть, что реально открыто, а не то, что вы подозреваете.

Снимите выборки одного процесса несколько раз с интервалом ~минуты:

  • Если FD растёт при низком трафике — вы охотитесь за утечкой.
  • Если он прыгает скачками — смотрите на планировщик задач (cron), воркеров очереди или фоновые джобы.

Паттерны, которые указывают на источник

То, что вы видите в lsof, часто быстро сужает поиск:

  • Много socket: исходящие HTTP‑вызовы, подключения к БД, Redis, вебхуки, прокси, или отсутствующие таймауты.
  • Много pipe: дочерние процессы (инструменты для PDF, конвертации, ffmpeg), где stdout/stderr не сливается или процесс не собирается.
  • Много обычных файлов: загрузки, временные файлы, лог‑файлы, read streams, где close не срабатывает.
  • Много похожих путей: цикл открывает один и тот же тип ресурса снова и снова.

После определения доминирующего типа сопоставьте с таймингом. Если пик совпадает с тиков очереди — фокус на воркере, не на веб‑хэндлере. Если доминируют сокеты — проверьте пуллы и таймауты. Если файлы — проверьте парсинг загрузок и любую работу с createReadStream/createWriteStream.

Безопасные меры на время поиска решения

Стабилизировать воркеры и загрузки
Аудитируем загрузки, задачи с PDF и воркеры очередей — там чаще всего начинается EMFILE.

Обычно нужны две параллельные ветки: держать сервис в сети и выиграть время на поиск утечки.

Повышение лимита открытых файлов может снизить число падений, но рассматривайте это как временное решение. Если утечка растёт, более высокий лимит просто отложит аварию. Меняйте по одному параметру, фиксируйте поведение до/после и сигнализируйте, если FD продолжает расти.

Низкорискованные меры:

  • Перезапускайте быстро, но убедитесь, что логи сохраняются для отладки.
  • Делайте фейл‑хелс‑чек при достижении порога FD, чтобы инстанс роатировался.
  • Ограничьте скорость для эндпоинта или джобы, которая вызывает всплески FD.
  • Отключите подозрительную фичу за флагом (загрузки, обработка изображений, генерация PDF) до релиза фикса.

Таймауты — ещё одна полезная страховка. Многие AI‑приложения забывают их, поэтому медленные исходящие HTTP‑вызовы, запросы в БД или консьюмеры могут накопиться и держать сокеты открытыми дольше, чем ожидается. Установите разумные значения по умолчанию и ограничьте число ретраев.

Также сделайте шутдаун предсказуемым: перестаньте принимать новые запросы, завершите текущую работу, закройте HTTP keep‑alive агенты, закройте пулы БД и остановите воркеры.

Реалистичный пример: воркер загрузок, который держал файлы открытыми

Команда выпустила AI‑сгенерированный воркер загрузок, который принимал пачки PDF, извлекал текст и сохранял результаты. В тестах работало, а в продакшне он падал через час с EMFILE.

Воркеры использовали fs.createReadStream() для каждого PDF и пихали его в парсер. В счастливом пути стрим закрывался и хендл освобождался. Но на пути ошибки (повреждённый PDF, таймаут, исключение парсера) код возвращал результат раньше и не чистил стрим. Ещё хуже — не было обработчика ошибок стрима, и некоторые ошибки вообще не попадали в catch.

Что изменили в патче

Фикс был маленьким, но строгим: при любом сценарии нужно закрывать файловые хендлы.

  • Прикрепили обработчики error ко всем стримам.
  • Использовали единый контрольный поток, гарантирующий очистку (например, try/finally).
  • В блоке очистки вызывали destroy() для стримов, которые могли остаться открытыми.

Упрощённый пример выглядел так:

const rs = fs.createReadStream(path);
try {
  await parsePdf(rs); // throws on bad PDFs
} finally {
  rs.destroy(); // safe even if already ended
}

Доказательство в продакшне, что проблема решена

Они отслеживали одну метрику: число открытых файловых дескрипторов у процесса Node.

До патча счётчик FD рос при каждом всплеске и не возвращался к базовой линии. После патча он поднимался кратковременно при пиках загрузок и затем снова опускался. Это было реальным подтверждением, что утечка устранена.

Быстрая чек‑листа, чтобы подтвердить фикc в продакшне

Получить быстрый второй взгляд
Поделитесь одним снимком lsof и логами — мы скажем, куда смотреть дальше.

Вам не нужно гадать, починили ли вы EMFILE. Нужно провести несколько проверок при реальном трафике.

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

  • Счётчик FD стабилизируется, а не растёт: небольшие колебания нормальны; постоянный рост — нет.
  • Нет EMFILE в течение полного цикла трафика: наблюдайте за периодом пика и затем более спокойным временем.
  • Пулы соединений возвращаются после всплесков: активные соединения и очередь запросов должны сходиться к базовому уровню.
  • Гладкий шутдаун действительно закрывает ресурсы: перезапустите один инстанс и убедитесь, что старый процесс корректно выходит.
  • Оповещение по FD: выставьте алерт заметно ниже системного лимита, чтобы регрессии обнаруживались рано.

Если счётчик FD стабилен, а ошибки продолжаются, проверьте системные лимиты (ulimit), сайдкары или другие процессы на том же хосте.

Дальше, если проблема возвращается

Если вы подняли лимиты и задеплоили патч, но EMFILE снова появляется, скорее всего, за этим стоит структурная проблема. Два паттерна требуют глубокого копания: код, который открывает файлы во многих местах без явного владельца, и скрытые фоновые циклы (pollers, watchers, воркеры), которые работают вечно и медленно накапливают дескрипторы.

Что собрать, чтобы быстро дать диагностику

До внесения больших изменений соберите небольшой набор доказательств из падающей среды:

  • короткий лог‑окно вокруг первого EMFILE (с метками времени и уровнем трафика)
  • PID, версия Node, ограничения контейнера и текущее значение nofile
  • снэпшот открытых FD для PID (число и доминирующие типы: файлы, сокеты, пайпы)
  • недавние детали деплоя и что изменилось
  • форма нагрузки (загрузки, обработка изображений, cron, вебхуки, консьюмеры очередей)

Затем воспроизведите нагрузку, похожую на продакшн, в течение 10–15 минут и смотрите, растёт ли счётчик FD. Постоянный рост почти всегда означает утечку.

Если кодовая база сгенерирована ИИ и вы не можете быстро найти «владельца» для каждого стрима/сокета, целенаправленный аудит часто быстрее чем бессистемное патчение. FixMyMess (fixmymess.ai) как раз создан для таких случаев: диагностирует и чинит сломанные AI‑сгенерированные Node‑прототипы, находит утечки, ужесточает пути очистки и делает приложение устойчивым к продакшен‑нагрузке.

Часто задаваемые вопросы

Что на самом деле значит «EMFILE: too many open files» в Node?

Это значит, что ваш процесс Node достиг лимита файловых дескрипторов. Дескрипторы не только для «файлов»: они охватывают сетевые сокеты, пайпы, каталоги и стримы, поэтому ошибка может проявляться как сбои базы данных, сломанные HTTP‑запросы или случайные 500.

Почему EMFILE появляется через часы или дни, а не сразу?

Обычно это указывает на утечку: что‑то открывается многократно и не закрывается на всех путях, особенно на путях ошибки. Если ошибка происходит сразу при старте при низком трафике, возможно просто настроен слишком низкий лимит открытых файлов для процесса или контейнера.

Как понять, что это утечка, а не низкий лимит ОС?

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

Как быстро подтвердить утечку FD в продакшне?

Следите за количеством открытых FD для PID Node в течение нескольких минут и затем снова через некоторое время. Утечка выглядит как то, что значение не возвращается к базовому уровню после завершения запросов или задач, даже если оно поднимается во время пиков.

Какие самые распространённые причины EMFILE в AI‑сгенерированных Node‑приложениях?

Часто это стримы, не уничтоженные при ошибках, отсутствие finally при открытии соединений или файлов, и наблюдатели (watchers), запущенные в продакшне. Маршруты загрузок, обработка PDF/картинок и воркеры очередей особенно проблемные, потому что они быстро открывают много дескрипторов.

На что смотреть в выводе lsof, чтобы найти источник?

Смотрите, что остаётся открытым: много обычных файлов — загрузки или временные файлы; много сокетов — исходящие HTTP, база, Redis; много пайпов — дочерние процессы (ffmpeg, конвертеры), где stdout/stderr не читаются. Сопоставление доминирующего типа с таймингом обычно резко сужает область поиска.

Почему перезапуски или автоскейлинг делают EMFILE случайным?

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

Что можно сделать, чтобы сервис держался, пока я исправляю проблему?

Остановите рост FD: временно отключите подозрительный маршрут или задачу, ограничьте скорость для горячей точки, или выставьте рестарт при достижении порога FD. Поднятие лимита открытых файлов даёт буфер, но не заменяет исправление — оно лишь оттянет момент аварии.

Какой самый безопасный паттерн программирования, чтобы EMFILE не вернулся?

Сделайте очистку неизбежной: когда вы открываете стрим, сокет или берёте соединение из пула, гарантируйте его закрытие в finally или эквивалентном блоке. Также явно обрабатывайте ошибки стримов — неработанные ошибки часто пропускают шаги очистки.

Как подтвердить, что фикc реальный после деплоя?

Отслеживайте один простой сигнал: число открытых FD для процесса Node должно подниматься во время пиков и возвращаться близко к исходному уровню, а не расти бесконечно. Если код сгенерирован ИИ и вы не можете быстро найти владельца каждого стрима/сокета, FixMyMess может провести бесплатный аудит и часто исправляет утечку и упрочняет приложение.