MAATRIX / Блог / Кончились file descriptors: симптомы, которые сбивают с толку

Кончились file descriptors: симптомы, которые сбивают с толку

MAATRIX

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

Симптомы, которые сбивают с толку

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

  • Веб-сервер или бэкенд периодически отвечает 500 с логом вроде EMFILE: too many open files или Too many open files in system — но не всегда, а волнами.
  • Клиент не может подключиться к базе или к внешнему API: connection refused или таймаут, хотя сама база жива и отвечает на пинг с другой машины.
  • Сервис вдруг перестаёт резолвить DNS-имена — резолвер тоже открывает сокет, то есть тратит дескриптор, и при исчерпанном лимите падает именно на этом шаге, а не на "основной" логике.
  • Nginx или другой прокси пишет в лог accept() failed (24: Too many open files) — новые подключения просто не принимаются, будто сервер перегружен, хотя load average в норме.
  • Приложение не может создать временный файл или записать лог, хотя место на диске есть.

Общая черта всех этих симптомов: они выглядят как проблемы разных подсистем (файлы, сеть, DNS), потому что в Linux всё это — файлы в широком смысле. Сокет, открытый файл, pipe, epoll-инстанс — каждый получает файловый дескриптор из одного и того же лимита на процесс. Когда лимит упирается в потолок, ломается первая операция, которая попыталась открыть новый дескриптор — и это может быть что угодно, в зависимости от порядка событий. Поэтому ошибка "плавает" и не воспроизводится стабильно одним и тем же способом.

Ключевой диагностический признак, который отличает эту проблему от бага в коде: CPU и память в норме, а ошибки — про "не могу открыть/подключиться/принять", причём разного рода. Если видите именно такое сочетание — проверяйте дескрипторы раньше, чем начнёте читать код по третьему кругу.

Настоящая причина: лимит файловых дескрипторов

В Linux у каждого процесса есть лимит на количество одновременно открытых файловых дескрипторов — RLIMIT_NOFILE, он же ulimit -n. Это не общесистемный лимит на все процессы сразу (тот тоже есть, fs.file-max, но обычно куда выше и упирается в него редко) — это лимит именно на один процесс.

Дескриптор тратится на:

  • каждый открытый файл (включая лог-файлы, которые процесс пишет непрерывно);
  • каждое сетевое соединение — входящее и исходящее, включая соединения к БД, к Redis, к внешним API;
  • каждый сокет в состоянии TIME_WAIT, пока ядро его не закроет;
  • pipe между процессами;
  • inotify-вотчеры, epoll/kqueue-инстансы, которые использует событийный цикл (Node.js, nginx, Go-рантайм).

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

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

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

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

Диагностика: смотрим что реально происходит

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

# найти PID нужного процесса
pgrep -f имя_процесса
# или
systemctl status myapp.service | grep "Main PID"

Считаем открытые дескрипторы двумя независимыми способами — они должны давать близкие числа:

lsof -p PID | wc -l
ls /proc/PID/fd | wc -l

Смотрим текущий лимит именно у этого процесса (не в текущем шелле — это разные вещи, если сервис запущен через systemd или другого родителя с другим лимитом):

cat /proc/PID/limits | grep "Max open files"

Вывод выглядит так:

Max open files            1024                 4096                 files

Первое число — мягкий лимит (soft limit), реально действующий на процесс прямо сейчас. Второе — жёсткий (hard limit), потолок, до которого процесс может поднять себе лимит сам. Если число из lsof -p PID | wc -l приближается к мягкому лимиту (условно, больше 80-90%) — вы нашли причину.

Полезно также посмотреть, из чего состоит список открытых дескрипторов — сколько из них сокеты, сколько обычные файлы:

lsof -p PID | awk '{print $5}' | sort | uniq -c | sort -rn

Если основная масса — IPv4/IPv6 (сокеты), скорее всего дело в соединениях, которые не закрываются. Если много REG (обычные файлы) — вероятна утечка открытых файловых хендлов в коде (не закрытые логи, дескрипторы после чтения конфигов и т.п.).

Отдельно стоит проверить сокеты в TIME_WAIT на уровне системы — иногда именно они формируют основную массу:

ss -s
ss -tan state time-wait | wc -l

Если TIME_WAIT-сокетов тысячи — вопрос не только в лимите на процесс, но и в том, как часто приложение открывает новые короткоживущие соединения вместо переиспользования уже открытых (см. про коннекшн-пулинг).

Типичные причины исчерпания дескрипторов

На практике почти все случаи сводятся к двум сценариям, и их важно различать, потому что фикс разный.

Сценарий 1: слишком низкий лимит для нормальной нагрузки. Дескрипторы растут вместе с трафиком, но потом стабилизируются и держатся плюс-минус на одном уровне (плавают вокруг какого-то значения, не растут бесконечно). Это значит, что приложению просто нужно больше лимита, чем дефолтный 1024 — само по себе оно ничего не "теряет". Типичный случай — nginx с большим числом keep-alive соединений, или бэкенд с большим пулом к базе и внешним интеграциям.

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

  • HTTP-клиент создаётся на каждый запрос и не закрывается (не переиспользуется keep-alive соединение);
  • соединение к БД или к Redis не возвращается в пул после использования — например, забыли close()/release() в ветке обработки ошибки;
  • файл открыт для записи лога, но хендл не закрывается при ротации;
  • вотчер (inotify, fs.watch) создаётся повторно без удаления старого;
  • дочерние процессы или сокеты создаются, но не дожидаются wait()/close() при завершении.

Это тот же по сути паттерн, что при утечке памяти: ресурс не освобождается вовремя, счётчик растёт, рано или поздно упирается в лимит. Разница в том, что дескрипторы кончаются жёстко и внезапно (лимит — целое число, превысили — сразу ошибка), тогда как нехватка памяти обычно проявляется постепенным замедлением и OOM Killer'ом. Диагностика похожая: снять метрику во времени и посмотреть, растёт она монотонно или выходит на плато.

Чтобы отличить сценарий 1 от сценария 2, снимите число открытых дескрипторов несколько раз за сутки, включая ночные часы низкой нагрузки:

while true; do
  date +"%H:%M:%S"; ls /proc/PID/fd | wc -l
  sleep 3600
done

Если ночью число падает почти до нуля активности, а днём растёт и падает вместе с трафиком — это лимит. Если число растёт весь день независимо от трафика и не падает даже ночью — это утечка.

Как повысить лимит правильно

Если диагностика показала сценарий 1 (лимита просто мало под реальную нагрузку), поднимаем его на трёх уровнях — системном, пользовательском и уровне конкретного сервиса.

Для сервиса, запущенного через systemd (это большинство современных бэкендов и прокси), правильный способ — не трогать глобальные лимиты, а задать LimitNOFILE прямо юниту:

sudo systemctl edit myapp.service

В открывшемся файле переопределения:

[Service]
LimitNOFILE=65536

Применяем и проверяем:

sudo systemctl daemon-reload
sudo systemctl restart myapp.service
cat /proc/$(systemctl show -p MainPID --value myapp.service)/limits | grep "Max open files"

Для процессов, запускаемых не через systemd (например, в консоли или через старый init), лимиты задаются через /etc/security/limits.conf:

myappuser soft nofile 65536
myappuser hard nofile 65536

Это применится только к новым сессиям через PAM — уже запущенный процесс не подхватит изменение, нужен новый логин или перезапуск сервиса. Для Docker-контейнеров лимит задаётся на уровне docker run/compose:

services:
  myapp:
    ulimits:
      nofile:
        soft: 65536
        hard: 65536

Не забывайте про общесистемный потолок fs.file-max — он редко становится проблемой, но на серверах с десятками сервисов и большим числом соединений его стоит поднять на уровне ядра:

sudo sysctl -w fs.file-max=2097152
echo "fs.file-max=2097152" | sudo tee -a /etc/sysctl.conf

Значение 65536 для отдельного сервиса — разумный практический ориентир для среднего нагруженного веб-бэкенда, а не универсальная константа: конкретное число зависит от вашего профиля нагрузки (количества одновременных соединений, размера пула к БД), и его стоит подбирать по данным диагностики из предыдущего раздела, а не бездумно копировать.

Если лимит исчерпывается снова и снова: ищем утечку

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

Первый шаг — понять, какого типа дескрипторы растут. Смотрите полный список открытых файлов с деталями:

lsof -p PID

Ищите повторяющиеся записи одного и того же файла или сокета к одному и тому же адресу — если строк с одинаковым TCP host:port->host:port сотни, значит соединения к этому адресу не закрываются. Для сравнения "было/стало" удобно снять снимок дважды с интервалом и сравнить diff:

lsof -p PID > /tmp/fds_before.txt
sleep 600
lsof -p PID > /tmp/fds_after.txt
diff /tmp/fds_before.txt /tmp/fds_after.txt | grep '^>' | wc -l

Число новых строк за 10 минут, которые не исчезли — это и есть скорость утечки. Дальше нужно смотреть в код по тем же местам, где обычно теряют дескрипторы: HTTP-клиенты без reuse соединения, БД-коннекты не в finally/defer, файловые хендлы логов при ротации. В Node.js полезно проверить активные хендлы напрямую из процесса:

console.log(process._getActiveHandles().length);

В Go — включить pprof и посмотреть goroutine/fdgraph профиль, если подозревается утечка через горутины, которые держат соединение открытым. В Java — снять heap dump и поискать инстансы Socket/FileInputStream, которых больше, чем ожидается.

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

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

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

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

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

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

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

Как быстро понять, дело в дескрипторах или в чём-то другом?

Сравните lsof -p PID | wc -l с лимитом из /proc/PID/limits. Если число близко к лимиту (от 80% и выше) в момент, когда сервис глючит — это оно. Если далеко от лимита — ищите причину в другом месте.

Можно ли просто поставить лимит побольше и забыть?

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

Почему ошибка иногда про файлы, а иногда про сеть — раньше был один и тот же сервис?

Потому что сокеты, файлы, pipe и inotify-вотчеры делят один общий лимит на процесс. Какая именно операция упадёт первой — зависит от того, что в этот момент пытается получить новый дескриптор, отсюда и разнообразие симптомов.

Нужно ли перезапускать сервис после правки limits.conf?

Да, изменения в /etc/security/limits.conf применяются через PAM только к новым сессиям/процессам. Уже работающий процесс лимит не подхватит — его нужно перезапустить (или, для systemd-юнита, использовать LimitNOFILE= в юните и systemctl restart).

Влияет ли это на производительность, если лимит просто высокий?

Сам по себе высокий лимит nofile практически не расходует ресурсы — таблица дескрипторов растёт по мере использования, а не резервируется заранее целиком. Ставить с запасом (например, 65536 вместо впритык рассчитанного числа) — нормальная практика.

Что если проблема на арендованном VPS, а не на своём железе?

Ограничения nofile/fs.file-max задаются на уровне ОС гостевой машины и не зависят от провайдера — правите их так же, как на любом Linux-сервере, права root для этого достаточно.

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

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

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