[Перевод] Слишком много открытых файлов: лимит Linux, который валит прод в 3 часа ночи

Страницы:  1

Ответить
 

Professor Seleznov


Ваш сервис спокойно живёт неделями: графики ровные, алерты молчат, релизы проходят без сюрпризов. А потом в три часа ночи под нагрузкой всё начинает разваливаться с ошибкой: too many open files
На Go это может выглядеть так:
http: Accept error: accept tcp 0.0.0.0:8080: accept4: too many open files; retrying in 5ms
На Java — так:
java.io.IOException: Too many open files
at sun.nio.ch.FileDispatcherImpl.init0(Native Method)
На Python — так:
OSError: [Errno 24] Too many open files
Названия разные, причина одна: EMFILE. Ядро отказалось выдать процессу новый файловый дескриптор, потому что тот упёрся в один из лимитов. Дальше всё обычно идёт по знакомому сценарию: сервис падает, оркестратор его перезапускает, под нагрузкой он снова быстро доходит до того же состояния — и цикл повторяется.
Самое неприятное начинается в момент диагностики. ulimit -n показывает 1,048,576. fs.file-max выглядит почти бездонным. lsof | wc -l даёт какие-нибудь 5000 открытых файлов. Казалось бы, до лимита ещё далеко. Тогда почему сервис всё равно падает?
Проблема в том, что лимит здесь не один. Есть как минимум три разных ограничения, и срабатывает не всегда то, на которое вы смотрите первым. Результат зависит от того, где живёт процесс: на обычном Linux-хосте, в контейнере, в Kubernetes или под непривилегированным пользователем.
В этой статье разберём, что в Linux вообще считается «открытым файлом», какие лимиты реально участвуют в ошибке too many open files, как они наследуются в Kubernetes и где смотреть, чтобы не чинить не тот слой инфраструктуры.
Что на самом деле считается «открытым файлом»
Файловый дескриптор — это небольшое целое число (0, 1, 2, 3, ...), которое ядро возвращает процессу, когда тот что-либо открывает. И под «что-либо» здесь понимается далеко не только файл:
  • Обычный файл на диске
  • TCP- или UDP-сокет
  • Unix-сокет
  • Канал (pipe)
  • eventfd, signalfd, timerfd
  • Экземпляр epoll
  • Каталог, открытый через opendir
  • Дескриптор устройства (/dev/null, /dev/random и т. д.)
Типичный HTTP-сервис на Go обычно держит:
  • 1 файловый дескриптор для сокета прослушивания
  • 1 файловый дескриптор на каждое принятое соединение
  • 1 файловый дескриптор на каждое исходящее HTTP-соединение клиента (часто через пул соединений)
  • Несколько файловых дескрипторов для логов, stdin/stdout/stderr, экземпляров epoll
Сервис, который обслуживает 10 000 одновременных соединений, имеет ещё 1000 исходящих подключений и систему телеметрии, легко выходит на 12 000+ файловых дескрипторов. А сервис с утечкой ресурсов (например, забывающий закрывать простаивающие соединения) будет бесконечно наращивать их количество, пока не упрётся в лимит и не упадёт.
Три типа ограничений
Существует три разных ограничения на количество файловых дескрипторов. Срабатывает самое маленькое из них.
Ограничение №1: мягкий лимит на процесс (ulimit -n внутри процесса)
Классический ulimit. У каждого процесса есть мягкий и жёсткий лимиты. Именно мягкий лимит проверяется при вызовах open(). Жёсткий лимит задаёт верхнюю границу, до которой процесс может повысить свой мягкий лимит.
# Изнутри процесса или shell
ulimit -n # текущий мягкий лимит
ulimit -Hn # жёсткий лимит
ulimit -n 65536 # увеличить мягкий лимит (не выше жёсткого
Для процесса внутри pod в Kubernetes именно этот лимит ядро проверяет при вызовах open().
Ограничение №2: общесистемный лимит (fs.file-max и fs.nr_open)
У ядра есть глобальное ограничение на общее количество открытых файлов во всей системе (fs.file-max) и ограничение на максимальное число файловых дескрипторов для одного процесса (fs.nr_open).
sysctl fs.file-max     # общесистемный максимум (обычно очень большой, например 9_223_372)
sysctl fs.nr_open # максимальный потолок для одного процесса (обычно 1_048_576)
# Текущее использование по всей системе
cat /proc/sys/fs/file-nr
# Формат вывода: <allocated> <free> <max>
# Например: 4032 0 9223372
Обычно с такими проблемами сталкиваются только на сильно загруженных общих хостах. На рабочем узле Kubernetes, работающем с 50 подами, fs.file-max значительно превышает допустимый уровень, если только не происходит массивная утечка памяти.
Более важным является параметр fs.nr_open: он ограничивает максимальный уровень, до которого отдельный процесс может поднять свой собственный мягкий лимит. Вы не можете использовать ulimit -n 2_000_000, если fs.nr_open равен 1_048_576.
Ограничение №3: cgroup pids.max (связанный, но другой ресурс)
Формально это не лимит файловых дескрипторов, но его часто с ними путают: pids.max ограничивает количество процессов и потоков в cgroup.
Нагрузка с утечкой потоков — например, сервис на Go, который создаёт горутину на каждый запрос и никогда их не завершает — может упереться именно в этот лимит. В таком случае проблема будет выглядеть скорее как ошибка создания процесса (fork failure), а не как EMFILE.
cat /sys/fs/cgroup/pids.max     # максимум для этой cgroup
cat /sys/fs/cgroup/pids.current # текущее количество
Это не основная тема статьи, но о таком лимите полезно помнить при смежной отладке.
Как ограничения взаимодействуют в Kubernetes
Именно здесь всё становится запутанным. Когда запускается pod:
  • kubelet просит контейнерный рантайм — например, containerd или CRI-O — запустить контейнер.
  • По умолчанию контейнерный рантайм не задаёт процессу ulimit -n. Процесс наследует те значения, которые настроены у самого рантайма.
  • Значения ulimit по умолчанию зависят от рантайма: containerd, например, не задаёт отдельного переопределения и использует настройки хоста; некоторые дистрибутивы Kubernetes меняют это через конфигурацию kubelet.
  • Значения по умолчанию на хосте приходят из лимитов unit-файлов systemd (LimitNOFILE). В большинстве современных дистрибутивов Linux они установлены на уровне 1_048_576 или выше.
В итоге pod обычно стартует с ulimit -n где-то между 1024 (редкие старые конфигурации) и 1,048,576 (современные системы). И, как правило, вы не знаете точное значение, пока не проверите его вручную.
# Проверка изнутри pod
kubectl exec -it $POD -- sh -c 'ulimit -n; ulimit -Hn'
# Проверка фактического лимита у работающего процесса
PID=$(kubectl exec -it $POD -- pgrep -f myapp | head -1 | tr -d '\r')
kubectl exec -it $POD -- cat /proc/$PID/limits | grep "Max open files"
Третье значение берётся из /proc/PID/limits, и именно оно является единственным достоверным источником: это тот лимит, который ядро реально применяет к конкретному процессу.
Как правильно задать лимит в Kubernetes
Нельзя декларативно задать ulimit в спецификации pod так же, как CPU или память. Параметра resources.limits.openFiles не существует. На практике используют три подхода.
Подход №1: значения по умолчанию в container runtime (правильное решение)
Настройте контейнерный рантайм kubelet, чтобы он задавал адекватные лимиты файловых дескрипторов для всех контейнеров.
Для containerd:
# /etc/containerd/config.toml
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
# ... другие параметры ...
Rlimits = [
{ type = "RLIMIT_NOFILE", hard = 1048576, soft = 1048576 }
]
Для CRI-O:
# /etc/crio/crio.conf
[crio.runtime]
default_ulimits = [
"nofile=1048576:1048576",
]
После перезапуска все новые контейнеры будут наследовать эти лимиты. Это самый чистый и правильный вариант, потому что каждое приложение получает адекватные настройки по умолчанию без отдельных конфигураций для каждого pod.
Подход №2: securityContext.sysctls (ограниченно применим)
Kubernetes позволяет задавать некоторые параметры sysctl для отдельных pod через securityContext.sysctls. Параметр fs.file-max изолирован по пространствам имён (namespaced) и может быть настроен, но это общесистемный лимит, а не ограничение на отдельный процесс. Он не задаёт ulimit для процесса.
spec:
securityContext:
sysctls:
- name: fs.file-max
value: "1048576"
В ряде случаев это полезно, но типичную проблему EMFILE не решает, потому что она обычно связана с мягким лимитом конкретного процесса.
Подход №3: явная установка ulimit в entrypoint
Можно заставить контейнер поднимать собственный ulimit при запуске — до выполнения основного бинарника:
ENTRYPOINT ["/bin/sh", "-c", "ulimit -n 1048576 && exec /usr/local/bin/myapp"]
Но здесь есть важный нюанс: процесс может поднять свой мягкий лимит только до значения жёсткого лимита. Если жёсткий лимит равен 1024, команда ulimit -n 1048576 завершится ошибкой.
Чтобы увеличить жёсткий лимит, контейнеру нужна capability CAP_SYS_RESOURCE, которой обычно нет у защищённых контейнеров без дополнительных привилегий.
Такой подход подходит как временный костыль, когда вы не можете изменить конфигурацию рантайма. Но как долгосрочное решение он считается неправильным.
Диагностика EMFILE в продакшене
Когда процесс начинает падать с ошибкой «too many open files»:
Шаг 1: проверьте лимит и текущее количество файловых дескрипторов
PID=$(pgrep -f myapp | head -1)
# Какой установлен лимит?
cat /proc/$PID/limits | grep "Max open files"
# Сколько файловых дескрипторов реально используется?
ls /proc/$PID/fd | wc -l
Если текущее количество приближается к лимиту или уже достигло его, значит у вас либо утечка ресурсов, либо приложению действительно не хватает ёмкости.
Шаг 2: определите, что именно открыто
# Группировка файловых дескрипторов по типу
ls -la /proc/$PID/fd | awk '{print $NF}' | grep -oE 'socket|^/.*' | sort | uniq -c | sort -rn | head
Это покажет, что именно утекает: файловые дескрипторы файлов, сокеты или и то и другое одновременно.
Шаг 3: если проблема в сокетах — копайте глубже
# Показать детали сокетов (состояние, удалённые адреса)
ss -tnp | grep "pid=$PID"
# Сокеты в состоянии CLOSE_WAIT часто указывают на утечку
# (удалённая сторона закрыла соединение, а вы — нет)
ss -tnp state close-wait | wc -l
Состояние CLOSE_WAIT — один из самых известных признаков утечки HTTP-клиента. Удалённый сервер уже закрыл соединение, но ваш клиент так и не дочитал EOF, поэтому сокет остаётся в CLOSE_WAIT до тех пор, пока процесс не будет завершён.
Шаг 4: если проблема в файловых дескрипторах файлов — найдите, что именно открыто
ls -la /proc/$PID/fd | awk '{print $NF}' | grep '^/' | sort | uniq -c | sort -rn | head
Повторяющиеся записи с одним и тем же путём означают, что в коде есть участок, который открывает файл, но не закрывает его.
Типичные причины EMFILE на уровне кода
Большинство ошибок EMFILE связаны не с инфраструктурой, а с самим приложением.
  • HTTP-клиент без пула соединений
Каждый запрос создаёт новое соединение; под нагрузкой соединения накапливаются быстрее, чем закрываются.
// ПЛОХО: новый клиент на каждый запрос
http.Get(url) // использует клиент по умолчанию, но создаёт новые соединения
// ЛУЧШЕ: общий клиент с нормально настроенным Transport
var client = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
2. Подключения к базе данных без пула соединений
Та же проблема встречается и с SQL-драйверами. Большинство современных драйверов используют пул соединений по умолчанию, но только если вы переиспользуете экземпляр *sql.DB (потому что именно он и является пулом).
3. Файлы открываются в горячем цикле (hot loop) без defer close() (Go), with (Python) или try-with-resources (Java)#
Если функция открывает файл и при ошибке выходит раньше времени, не закрывая его, вы получаете утечку одного файлового дескриптора на каждую такую ошибку.
4. Горутиины или потоки удерживают сокеты бесконечно долго
Пул воркеров, который бесконтрольно растёт под нагрузкой, создаёт отдельный воркер на каждое соединение; каждый воркер удерживает своё соединение открытым; количество файловых дескрипторов постепенно растёт.
5. Долгоживущие процессы с внутренними кэшами
«Кэш открытых файлов» без механизма вытеснения продолжает накапливать файловые дескрипторы бесконечно.
Во всех этих случаях исправление должно происходить на уровне приложения. Повышение ulimit только маскирует утечку, но не устраняет её.
Когда действительно нужно повышать лимит
Иногда лимит и правда оказывается слишком низким для нормальной рабочей нагрузки:
  • Реверс прокси вроде NGINX, Envoy или HAProxy, обслуживающий десятки тысяч одновременных соединений
  • WebSocket-сервер с постоянными соединениями
  • Сервер базы данных с большим количеством одновременных клиентских подключений
  • Пограничный CDN-узел
Для таких сценариев имеет смысл выставить значение по умолчанию в рантайме на уровне 1,048,576 или выше. При этом важно убедиться, что внутренние ограничения самого приложения (NGINX worker_connections, уровень параллелизма в Envoy и т. д.) настроены соответствующим образом.
Краткая памятка: чек-лист по EMFILE
1. Найдите процесс:
PID=$(pgrep -f myapp | head -1)
2. Проверьте лимит процесса:
cat /proc/$PID/limits | grep "Max open files"
3. Проверьте текущее использование:
ls /proc/$PID/fd | wc -l
4. Если количество файловых дескрипторов растёт и приближается к лимиту:
— Определите типы:
ls -la /proc/$PID/fd | awk '{print $NF}' | sort | uniq -c
 — Для сокетов:
ss -tnp | grep "pid=$PID"
— Ищите CLOSE_WAIT (удалённая сторона закрыла соединение, а вы — нет)
5. Сначала исправляйте приложение, потом инфраструктуру:
— Добавьте пул соединений
— Исправьте шаблоны close()/defer/with
— Ограничьте размер пулов воркеров
6. Если лимит действительно нужно повысить:
— Настройте default ulimits в containerd/CRI-O
— Перезапустите kubelet (существующие pod нужно перезапустить, чтобы они получили новые лимиты)
— Проверьте значения через /proc/PID/limits
7. Настройте мониторинг:
— алерт при (open_fds / max_fds) > 0.8
— алерт при бесконтрольном росте количества CLOSE_WAIT

Что мониторить
Метрики Prometheus из process_exporter или встроенные метрики языка дают всё необходимое:
# Соотношение использования файловых дескрипторов для процесса
process_open_fds / process_max_fds > 0.8
# Для pod через cAdvisor / kubelet
container_file_descriptors / container_ulimits_soft{ulimit="open_files"} > 0.8
# Детектор CLOSE_WAIT
# (требуется exporter состояний TCP)
node_netstat_Tcp_CloseWait > 1000
Панель мониторинга с количеством используемых файловых дескрипторов по каждому сервису и алертами на уровне 80% позволяет обнаружить утечки за несколько дней до того, как они уронят продакшен.
Правильная ментальная модель
ulimit -n — это мягкое ограничение ресурса на уровне процесса. Его задаёт тот компонент, который запускает процесс: unit-файл systemd, конфигурация containerd, сессия shell и так далее. В Kubernetes цепочка выглядит так: kubelet -> runtime -> container.
EMFILE в продакшене почти всегда сводится к одной из двух причин:
  • утечка на уровне приложения (лимит нормальный, но приложение держит файловые дескрипторы, которые должно было закрыть);
  • или нагрузка, которой действительно не хватает стандартного лимита рантайма (в этом случае нужно исправлять конфигурацию рантайма, а не городить костыли на уровне отдельных pod).
Когда проблема возникает, последовательность диагностики всегда одна и та же: проверить лимит, посчитать количество открытых файловых дескрипторов, определить их типы, найти источник утечки.
Самое сложное — понимать, куда смотреть. Но вы теперь понимаете.
pic
В ошибках вроде too many open files быстро выясняется, что одного ulimit -n мало. Разобраться в смежной практике по Linux можно на бесплатных уроках с возможностью задать вопросы экспертам:
  • 2 июня в 20:00. «Введение в Docker: контейнеризация приложений в Linux». Записаться
  • 18 июня в 20:00. «Основы Bash: пишем простые скрипты для автоматизации в Linux». Записаться
Тем, кто хотел бы подтянуть базу, рекомендую обратить внимание на мини-курс по основам Linux (сейчас всего за 10 рублей)
-Источник
 
Loading...
Error