MAATRIX / Блог / Предел числа процессов: форк-бомба без злого умысла и как найти свой pids_max

Предел числа процессов: форк-бомба без злого умысла и как найти свой pids_max

MAATRIX

Форк-бомбу :(){ :|:& };: знают все, кто хоть раз читал про безопасность Linux — это учебный пример злонамеренной атаки. Но на практике сервер куда чаще упирается в тот же самый предел числа процессов без всякого злого умысла: неограниченный пул воркеров, который плодит новые процессы быстрее, чем успевает их убирать, или рекурсивный вызов subprocess, который забыл про базовый случай. Результат один и тот же — система отказывается запускать что-либо новое, включая bash для диагностики. Разберём, где именно проходит этот предел, как его увидеть заранее и как настроить защиту так, чтобы один сбойный скрипт не укладывал весь сервер.

Что на самом деле ограничивает число процессов

В Linux нет одного числа «максимум процессов» — их несколько, и они действуют на разных уровнях одновременно.

kernel.pid_max — глобальный предел ядра на максимальный числовой идентификатор процесса (PID), общий для всей системы. Это не совсем «лимит на количество процессов» в чистом виде: если PID-ы переиспользуются циклически (а именно так и происходит), система может держать одновременно живыми до pid_max процессов и потоков суммарно, потому что каждый поток тоже получает собственный идентификатор в пространстве PID. Значение зависит от дистрибутива, ядра и иногда от объёма памяти на 64-битных системах — не полагайтесь на память, проверяйте на конкретном сервере:

cat /proc/sys/kernel/pid_max

kernel.threads-max — отдельный, обычно менее заметный предел на общее число потоков в системе, рассчитываемый ядром исходя из объёма RAM при загрузке. На практике вы упрётесь либо в него, либо в pid_max — какой из двух окажется меньше на конкретной машине, тот и сработает первым.

ulimit -u (RLIMIT_NPROC) — лимит на число процессов/потоков для конкретного пользователя, если посчитать их по всем сессиям сразу. Это тот предел, в который чаще всего упираются на практике: он куда меньше pid_max и настраивается индивидуально через PAM (/etc/security/limits.conf) или systemd.

pids.max cgroup-контроллера — начиная с cgroup v2 (в современных дистрибутивах включён по умолчанию) это предел на число процессов и потоков внутри конкретной cgroup — то есть внутри контейнера, systemd-юнита или произвольно нарезанной группы задач. Именно этот механизм стоит за --pids-limit в Docker и за TasksMax= в systemd-юнитах — подробнее про эту механику разобрано в статье как cgroups ограничивают контейнер.

Разница между этими уровнями принципиальна: pid_max — это глобальный потолок для всей машины, ulimit -u — персональный потолок для пользователя, а pids.max — потолок для изолированной группы задач. Форк-бомба одного контейнера, ограниченного через pids.max, упадёт сама, не тронув соседние контейнеры и хост. Форк-бомба процесса без cgroup-изоляции, запущенного напрямую под системным пользователем без ulimit -u, способна дотянуться до pid_max и положить всю машину целиком, включая процессы, не имеющие никакого отношения к виновнику.

Как посмотреть свои текущие лимиты и нагрузку

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

# Глобальный предел ядра
cat /proc/sys/kernel/pid_max
cat /proc/sys/kernel/threads-max

# Сколько процессов и потоков сейчас живо в системе (счёт по task_struct)
ps -eLf | wc -l

# Лимит и текущее число процессов конкретного пользователя
ulimit -Su          # мягкий лимит текущей сессии
ulimit -Hu          # жёсткий лимит текущей сессии
ps -u имя_пользователя h | wc -l

# Лимиты и текущая занятость конкретной cgroup (cgroup v2)
cat /sys/fs/cgroup/имя_группы/pids.max
cat /sys/fs/cgroup/имя_группы/pids.current

# Для systemd-юнита — то же самое одной командой
systemctl show имя_сервиса.service -p TasksMax -p TasksCurrent

Полезная деталь: ulimit -u считает не только процессы, а сумму процессов и потоков, принадлежащих пользователю, причём независимо от того, в скольких сессиях они запущены — если у вас параллельно открыто пять SSH-сессий одного и того же системного пользователя, все процессы во всех пяти считаются в один общий лимит. Это часто ловит на CI-раннерах и деплой-машинах, где параллельные job'ы работают от одного технического пользователя: разбор похожего случая, где лимит на пользователя внезапно оборвал раскатку на середине флота, — в статье лимит процессов на пользователя обрубил деплой.

Для регулярного контроля стоит не полагаться на разовую проверку, а вынести число процессов ключевых пользователей и занятость pids.current в мониторинг — рост этого числа к потолку обычно виден заранее, за минуты или часы до реального отказа, если снимать метрику достаточно часто.

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

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

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

Как это проявляется на практике

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

В shell и приложениях, которые вызывают fork()/clone() напрямую или через обёртки вроде subprocess, os.fork, Runtime.exec:

bash: fork: retry: Resource temporarily unavailable
bash: fork: Cannot allocate memory
-bash: /usr/bin/find: Resource temporarily unavailable

Формулировка «Resource temporarily unavailable» (errno EAGAIN) вводит в заблуждение — она наводит на мысль о нехватке памяти или диска, хотя причина в другом: ядро отказало в создании нового процесса из-за упора в один из лимитов, описанных выше. Проверять free -m и df -h в этот момент бессмысленно, если причина — именно лимит числа процессов.

Второй характерный симптом — система, в которую вы уже не можете зайти по SSH, потому что sshd не может форкнуть обработчик новой сессии, а зайдя (если успели раньше) — не можете запустить даже ps или top для диагностики, потому что и для них не хватает свободного слота. В этой ситуации помогает вход через уже открытую сессию, если она была, либо через systemd-run с приоритетом, либо — на крайний случай — через консоль хостинга, минуя сеть и sshd совсем.

Третий вариант — приложение не падает с явной ошибкой, а просто перестаёт отвечать: пул воркеров, который должен был создать очередной процесс для обработки задачи, зависает в ожидании ресурса, который ядро не выдаёт, и вся очередь копится за ним, не продвигаясь. Это медленнее по проявлению, чем чистый Cannot fork, и потому его сложнее сразу связать с лимитом процессов — на первый взгляд выглядит как обычное зависание из-за медленной базы или внешнего API.

Форк-бомба без злого умысла: реальные сценарии

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

  • Неограниченный пул воркеров под нагрузкой. Веб-сервер или очередь задач, который создаёт новый процесс-обработчик на каждый входящий запрос без предела параллелизма (max_workers не задан или задан слишком большим), при резком всплеске трафика начинает плодить процессы быстрее, чем они успевают завершиться. Каждый воркер сам по себе лёгкий, но их суммарное число растёт неограниченно, пока не упрётся в ulimit -u или pids.max.
  • Рекурсивный вызов subprocess без базового случая. Скрипт, который при ошибке перезапускает сам себя новым процессом (например, обёртка вокруг воркера, перезапускающая его же при падении через subprocess.Popen([sys.executable] + sys.argv)), а условие остановки рекурсии написано с ошибкой — получает точную копию форк-бомбы, только написанную из благих побуждений «на случай сбоя перезапустить автоматически».
  • Retry-логика без ограничения одновременных попыток. Задача падает, обработчик ошибок запускает повторную попытку новым процессом, не дожидаясь завершения предыдущей и не ограничивая число параллельных ретраев — если причина падения системная (например, недоступна база), все ретраи падают тоже, порождая ещё больше ретраев.
  • Демон, не убирающий зомби-процессы. Родительский процесс, который форкает дочерние задачи, но не вызывает wait()/waitpid() для завершившихся, накапливает зомби — они не занимают память и CPU, но каждый всё ещё держит слот в таблице процессов и учитывается в pid_max. При достаточно долгой работе и достаточно частом форке это тоже способно упереться в предел, хоть и медленнее, чем активная форк-бомба. Механика подробно разобрана в статье почему процесс-зомби не занимает память, но может положить сервер.
  • Контейнеризованное приложение с багом в библиотеке потоков. Утечка потоков (thread leak) в долгоживущем процессе — потоки создаются на каждую операцию и не завершаются штатно — тоже расходует пространство PID, потому что каждый поток ядро считает отдельной задачей с собственным идентификатором.

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

Защита на уровне пользователя: ulimit и системные лимиты

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

Постоянная настройка через PAM-лимиты — переживает перезапуск и применяется ко всем новым сессиям пользователя:

# /etc/security/limits.d/app-worker.conf
app-worker    soft    nproc    2048
app-worker    hard    nproc    4096

Важный нюанс: limits.conf применяется PAM-модулем pam_limits.so при входе в сессию — он не подействует на процессы, запущенные напрямую systemd-юнитом в обход login-сессии (типичный случай для сервисов на проде). Для systemd-юнитов лимит задаётся отдельно, в самом юните:

# /etc/systemd/system/app-worker.service
[Service]
User=app-worker
LimitNPROC=4096
TasksMax=4096

LimitNPROC здесь — это классический RLIMIT_NPROC, унаследованный из мира ulimit, а TasksMax — уже cgroup-предел, о котором подробнее в следующем разделе. Разница между ними и типичные ошибки при их совместной настройке разобраны в статье лимиты systemd: TasksMax, LimitNOFILE и мёртвый сервис — в частности там, что дефолтный TasksMax в systemd по умолчанию задан не бесконечным, а рассчитывается как доля от kernel.pid_max, и это иногда обрубает легитимно нагруженный сервис, даже когда явного бага в коде нет.

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

Защита на уровне cgroups: pids controller и изоляция контейнеров

Лимит на пользователя работает, только если сервис действительно запускается от отдельного пользователя, а не от root или общего технического аккаунта вместе с другими сервисами. В контейнерных окружениях и там, где нужна гарантированная изоляция, правильнее ограничивать не пользователя, а cgroup — тогда форк-бомба внутри одного контейнера физически не может дотянуться до процессов соседей и хоста.

Для Docker лимит задаётся флагом при запуске контейнера:

docker run --pids-limit 512 my-app:latest

В docker-compose.yml:

services:
  worker:
    image: my-app:latest
    pids_limit: 512

Для произвольной cgroup v2 напрямую, без Docker и без systemd-юнита — например, при ручной изоляции группы процессов:

mkdir /sys/fs/cgroup/isolated-group
echo 512 > /sys/fs/cgroup/isolated-group/pids.max
echo $$ > /sys/fs/cgroup/isolated-group/cgroup.procs

После этого любой процесс, порождённый из данной shell-сессии, наследует cgroup и её ограничение — новый fork() сверх pids.max завершится тем же EAGAIN, что и при упоре в ulimit -u, но только для процессов внутри этой группы, не затрагивая остальную систему.

Ключевое отличие pids.max от ulimit -u — область действия: ulimit -u считается по пользователю глобально по всей системе, а pids.max — по конкретной cgroup, в которую можно посадить произвольный набор процессов независимо от того, под каким пользователем они работают. Это делает cgroup-подход надёжнее для многопользовательских и мультиконтейнерных серверов: даже если внутри контейнера всё запущено от root, форк-бомба там ограничена собственным pids.max и не выйдет за пределы контейнера. При этом стоит учитывать, что слишком маленький pids.max для приложений с честно большим числом легитимных потоков (например, JVM-сервисов) сам по себе становится источником отказов — подбирать значение стоит по факту замера реальной нагрузки, а не по интуиции.

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

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

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

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

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

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

Как быстро понять, что причина отказа — именно лимит процессов, а не память или диск?

Смотрите текст ошибки: Resource temporarily unavailable при попытке запустить что угодно (даже ls или bash) в сочетании с нормальными показателями free -m и df -h — почти всегда указывает на упор в pid_max, ulimit -u или pids.max, а не на нехватку памяти или места.

Можно ли увеличить kernel.pid_max «про запас», чтобы проблема не повторилась?

Можно через sysctl -w kernel.pid_max=значение (и закрепить в /etc/sysctl.d/), но это лечит только глобальный симптом, а не причину — если конкретный сервис плодит процессы без ограничения, увеличенный pid_max просто отодвигает момент отказа и позволяет форк-бомбе без злого умысла нанести больше вреда соседним сервисам, прежде чем упасть.

Что произойдёт с сервисом, если он упрётся в TasksMax систематического юнита?

Новые процессы/потоки перестанут создаваться и упадут с той же ошибкой EAGAIN внутри процесса, но сам юнит не будет автоматически остановлен systemd только из-за этого — сервис останется в состоянии active, просто частично неработоспособным, что усложняет автоматическое обнаружение проблемы без отдельного мониторинга TasksCurrent.

Стоит ли ограничивать pids.max/ulimit -u для всех сервисов сразу, даже если проблем никогда не было?

Да, для любого сервиса, который создаёт процессы или потоки динамически в ответ на внешнюю нагрузку (веб-обработчики, очереди задач, деплой-агенты) — цена настройки лимита несравнимо ниже цены полного отказа сервера из-за бага, который проявится на проде при реальном всплеске трафика, а не в тестовом окружении с низкой нагрузкой.

Отличается ли поведение при упоре в лимит для потоков (threads) и для процессов?

Нет, ядро Linux не делает принципиальной разницы между процессом и потоком на уровне task_struct — оба варианта создаются через clone() с разным набором флагов и одинаково считаются в pid_max, threads-max и pids.max. Поэтому многопоточное приложение с утечкой потоков способно исчерпать те же лимиты, что и процесс, плодящий дочерние процессы.

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

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

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