MAATRIX / Блог / Сколько дескрипторов тратит один запрос: считаем расход на своём сервере

Сколько дескрипторов тратит один запрос: считаем расход на своём сервере

MAATRIX

Лимит ulimit -n кажется абстрактным числом, пока сервер не начинает отдавать Too many open files под нагрузкой, которая ещё вчера казалась нормальной. Проблема почти всегда не в самом лимите, а в том, что один запрос к приложению тратит не один дескриптор, а несколько — и никто заранее не посчитал, сколько именно. Разберём, откуда берётся этот расход, и дадим методику, чтобы измерить его на своём процессе, а не гадать по чужим бенчмаркам.

Что такое дескриптор и почему это конечный ресурс

Файловый дескриптор (file descriptor, fd) — это число, которым ядро Linux помечает открытый в процессе канал ввода-вывода: обычный файл, сетевой сокет, именованный или анонимный pipe, устройство, epoll-инстанс, даже eventfd для внутренней синхронизации. С точки зрения ядра всё это — один и тот же вид объекта в таблице открытых файлов процесса, и на каждый такой объект уходит одна запись.

Лимитов у этого ресурса на самом деле два, и они независимы:

  • На процессulimit -n (soft/hard limit), в systemd-юнитах — LimitNOFILE. Посмотреть фактический лимит уже запущенного процесса можно так:
cat /proc/<PID>/limits | grep "Max open files"
  • На всю систему/proc/sys/fs/file-max, суммарный потолок на все процессы разом. Текущий расход и запас видно через:
cat /proc/sys/fs/file-nr
# три числа: выделено дескрипторов / свободно в кэше / максимум

Важный нюанс: дефолтный soft limit в 1024 дескриптора на процесс в большинстве дистрибутивов — наследие эпохи, когда сервер редко держал больше сотни одновременных соединений. Современный веб-бэкенд под нагрузкой упирается в него за секунды, если открывает больше одного дескриптора на запрос и не переиспользует соединения.

Один HTTP-запрос — это не один дескриптор

Интуитивно кажется, что раз клиент открыл одно TCP-соединение, то и расход — один дескриптор на этот сокет. На практике для типичного веб-приложения (не статики, а бэкенда с базой данных, кэшем и, возможно, внешними API) расход на обработку одного запроса может состоять из нескольких дескрипторов одновременно:

  • сокет входящего клиентского соединения (сам HTTP-запрос);
  • сокет соединения с базой данных — если приложение открывает новое соединение на каждый запрос вместо переиспользования из пула;
  • сокет соединения с кэшем (Redis/Memcached) — та же история, если клиент кэша не держит постоянное соединение;
  • сокеты к другим бэкенд-сервисам или внешним API, если запрос дёргает микросервисы синхронно;
  • иногда — файловый дескриптор на чтение шаблона, статического файла или временного файла загрузки, если приложение не кэширует открытые хендлы.

Ключевая развилка — переиспользуется соединение или открывается заново. Если у вас есть пул соединений к базе (о том, что такое connection pool и зачем он нужен, стоит понимать до того, как разбираться в расходе fd), то дескриптор на БД не создаётся на каждый запрос — он берётся из уже открытых соединений пула и возвращается обратно после использования. А вот если приложение написано в духе «на каждый запрос — новое подключение к Postgres», то расход на этот один HTTP-запрос удваивается или утраивается, причём каждое такое соединение живёт какое-то время даже после ответа клиенту — из-за TCP TIME_WAIT на закрывающей стороне, буферизации на уровне драйвера СУБД или просто задержки закрытия в самом приложении.

Отдельно стоит держать в уме соединения по keep-alive — если они настроены неправильно, сервер держит их дольше, чем нужно, и дескрипторы копятся быстрее, чем освобождаются, даже без роста реального трафика.

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

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

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

Как посчитать расход дескрипторов конкретного приложения: базовая методика

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

Шаг 1. Найти PID процесса и снять базовую линию

pgrep -fl your-app
ls -la /proc/<PID>/fd | wc -l

Это число — «холостой» расход: сколько дескрипторов приложение держит просто потому, что запущено (слушающие сокеты, файлы логов, соединения пула в режиме простоя, systemd-сокеты, инстансы epoll у event loop).

Шаг 2. Посмотреть, что именно открыто, а не только сколько

lsof -p <PID>
# только сетевые соединения:
lsof -p <PID> -a -i
# сгруппировать по типу
lsof -p <PID> | awk '{print $5}' | sort | uniq -c | sort -rn

lsof даёt читаемую расшифровку: TCP-сокеты с адресами, обычные файлы с путями, unix-сокеты. Это полезно ещё до нагрузочного теста — часто уже на этом шаге видно, что в состоянии покоя открыто подозрительно много соединений к БД или кэшу, которые никто не закрывает.

Шаг 3. Снять число под известной нагрузкой

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

# wrk: 4 потока, 100 одновременных соединений, 30 секунд
wrk -t4 -c100 -d30s http://127.0.0.1:8080/your-endpoint

# или ab: 1000 запросов, конкурентность 50
ab -n 1000 -c 50 http://127.0.0.1:8080/your-endpoint

Параллельно, в отдельном терминале, снимайте число дескрипторов каждую секунду:

watch -n1 'ls /proc/<PID>/fd | wc -l'

Или, если нужен лог для последующего анализа, а не только визуальное наблюдение:

while true; do
  echo "$(date +%s) $(ls /proc/<PID>/fd | wc -l)"
  sleep 1
done | tee fd-usage.log

Шаг 4. Посчитать расход на запрос

расход_на_запрос ≈ (пик_под_нагрузкой − базовая_линия) / конкурентность

Если при конкурентности 50 пик дескрипторов вырос со 120 (простой) до 420 под нагрузкой — то на каждый одновременный запрос в среднем приходится (420 − 120) / 50 = 6 дескрипторов. Это не значит, что каждый конкретный запрос стабильно тратит ровно 6 — величина усреднённая и зависит от типа запроса, но как ориентир для планирования лимитов вполне рабочая.

Точность замера: на что обратить внимание

Метод выше даёт хорошее приближение, но есть несколько мест, где он может обмануть:

  • Пиковое значение маскируется усреднением сэмплов раз в секунду. Если дескрипторы открываются и закрываются быстрее интервала опроса, вы поймаете не максимум, а случайную проекцию. Для коротких запросов уменьшайте интервал (watch -n0.2 или скрипт с sleep 0.1).
  • Прогрейте пул перед замером. Первые секунды теста покажут рост дескрипторов не от расхода на запрос, а от первичного наполнения пула до pool_size. Дайте тесту 10–15 секунд до старта замеров.
  • Разделяйте расход на конкурентность и утечку. Прогоните тест несколько раз подряд и сравните базовую линию до первого прогона и после последнего. Если она выросла и не падает — это утечка, а не расход на запрос.
  • Меняйте конкурентность и стройте зависимость. Один замер при одной конкурентности — точка, а не методика. Прогоните тест на 10, 50, 100, 200 запросов. Если расход на запрос растёт вместе с конкурентностью — не все дескрипторы освобождаются вовремя.
КонкурентностьБазовая линия fdПик fdРасход на запрос
101201705,0
501204206,0
1001207406,2
20011814806,8

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

Где чаще всего утекают дескрипторы: типичные причины

Когда замер показывает расход выше ожидаемого или его рост со временем, разбор обычно упирается в один из паттернов:

  • Новое соединение с БД на каждый запрос. ORM или обвязка создаёт connection = db.connect() внутри обработчика запроса вместо получения соединения из пула. Каждое соединение — минимум один сокет, а его закрытие не освобождает дескриптор мгновенно из-за TIME_WAIT на инициирующей стороне.
  • Клиент Redis/Memcached без переиспользования соединения. Та же история — клиентская библиотека создаётся заново на каждый вызов вместо постоянного соединения или пула, и расход множится от числа обращений к кэшу внутри одного запроса.
  • Синхронные вызовы к внешним HTTP API без keep-alive. Новый requests.Session() (Python) или http.Client (Go) на каждый исходящий вызов вместо переиспользуемого клиента — это TCP-хендшейк и отдельный сокет на каждое обращение.
  • Незакрытые файловые хендлы. Приложение читает файл конфигурации или временный файл загрузки и не закрывает хендл явно, полагаясь на сборщик мусора языка. В Python это особенно заметно при открытии файлов без with.
  • Логирование без ротации хендла. Внешний ротатор переименовывает файл лога, а процесс продолжает писать в отвязанный от каталога inode; при попытке переоткрыть — плодит дескрипторы. Причина роста fd, не связанная напрямую с трафиком запросов.

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

lsof -p <PID> > before.txt
# дать нагрузку, подождать завершения
lsof -p <PID> > after.txt
diff before.txt after.txt

Если diff показывает растущий список TCP-соединений в состоянии ESTABLISHED или CLOSE_WAIT к одному и тому же адресу (например, к базе данных) — это почти наверняка отсутствие пула соединений или неправильное его использование.

Пулы соединений: как они меняют картину расхода

Смысл пула соединений (connection pooling) — открыть ограниченное число соединений один раз при старте приложения (или лениво, по мере надобности, но не выше заданного максимума) и переиспользовать их между запросами вместо открытия нового сокета на каждое обращение. Подробно про механику и настройку — в статье про connection pooling и его настройку, здесь — именно в разрезе расхода дескрипторов.

Без пула расход дескрипторов на соединения с бэкенд-сервисами растёт вместе с конкурентностью запросов практически линейно — каждый одновременный запрос держит свой отдельный сокет к БД, к кэшу, к внешнему API. С пулом фиксированного размера (например, pool_size=20 для соединений к Postgres) это число соединений к базе — константа, не зависящая от того, сколько одновременных HTTP-запросов сейчас обрабатывается: 50 или 500 запросов будут делить между собой одни и те же 20 соединений через очередь на получение свободного соединения из пула.

Примеры настройки пула в популярных стеках:

# Python, SQLAlchemy
engine = create_engine(
    "postgresql://user:pass@localhost/db",
    pool_size=20,
    max_overflow=10,      # временный запас сверх pool_size под пиковую нагрузку
    pool_timeout=30,
    pool_recycle=1800,    # переоткрывать соединения раз в 30 минут
)
// Node.js, pg
const { Pool } = require('pg');
const pool = new Pool({
  max: 20,               // максимум соединений в пуле
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
});

Для PostgreSQL отдельно стоит рассмотреть внешний пулер — PgBouncer — который держит фиксированный набор реальных соединений с СУБД и мультиплексирует на них гораздо большее число клиентских подключений от бэкенда, что особенно ценно при нескольких инстансах приложения (каждый со своим внутренним пулом), суммарно упирающихся в лимит соединений самой базы:

# pgbouncer.ini, режим transaction pooling
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20

Для исходящих HTTP-запросов к внешним API аналог — переиспользуемый клиент с keep-alive вместо новой сессии на каждый вызов:

# Python: один Session на весь процесс, а не на запрос
session = requests.Session()  # создать один раз при старте
# использовать session.get(...) во всех обработчиках

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

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

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

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

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

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

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

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

Временная мера — поднять ulimit -n для пользователя или LimitNOFILE в systemd-юните и перезапустить процесс. Но это лечит симптом: если у приложения нет пула соединений, расход будет расти вместе с трафиком и упрётся в новый, более высокий лимит. Сначала замер и поиск утечки или отсутствующего пулинга, потом — увеличение лимита как страховка.

Почему ulimit -n в шелле показывает одно значение, а в работающем процессе — другое?

Лимит применяется в момент запуска процесса и наследуется от родителя (шелла, systemd, docker-демона). Правка в текущем терминале не повлияет на уже запущенный сервис — лимит нужно менять там, откуда сервис стартует, и перезапускать процесс.

Считаются ли дескрипторы дочерних процессов в лимит родителя?

Нет, лимит открытых файлов — атрибут конкретного процесса, не общий для дерева. Каждый форк получает собственный лимит (обычно унаследованный от родителя на момент fork), а дескрипторы каждого процесса считаются отдельно в его /proc/<PID>/fd.

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

Да, это разумная практика: метрику числа открытых fd (через /proc/<PID>/fd или экспортер вроде process_open_fds в Prometheus) стоит выводить на дашборд рядом с CPU и памятью и ставить алерт на приближение к лимиту.

Когда важна разница между лимитом на процесс и системным file-max?

На одиночном VPS с одним приложением системный лимит почти никогда не становится узким местом раньше лимита процесса — file-max по умолчанию исчисляется сотнями тысяч. На сервере с десятками процессов суммарный расход стоит сверять с /proc/sys/fs/file-nr отдельно от лимитов каждого процесса.

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

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

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