Лимит процессов на пользователя обрубил деплой на середине
Раскатка новой версии останавливалась примерно на одном и том же месте — не на первом сервере и не на последнем, а где-то посередине списка, каждый раз с новой, на первый взгляд не связанной ошибкой. Через два дня разбора выяснилось, что дело не в сети, не в Docker и не в самом приложении, а в том, что операционная система буквально отказывалась плодить пользователю новые процессы. Ниже — как мы это нашли и почему такие вещи стоит проверять раньше, чем гоняться за призраками в логах приложения.
Содержание
Что сломалось
Раскатка шла через Ansible-плейбук, который параллельно (forks: 50) заходит по SSH на серверы флота, останавливает старый контейнер, подтягивает новый образ и поднимает его через docker compose up -d. Флот на момент инцидента — около 40 хостов, задеплой запускался job'ом в самостоятельно поднятом GitLab CI runner на отдельной управляющей машине, от системного пользователя deployer.
Симптом был стабильно воспроизводимым: раскатка успешно проходила первые два десятка серверов, а дальше начинала сыпать разномастными ошибками — то обрыв SSH-соединения, то таймаут подключения, то Ansible ругался на невозможность запустить локальный процесс для обработки инвентаря. При этом на самих целевых серверах ничего подозрительного не было: контейнеры, которые успели обновиться, работали нормально, а те, до которых очередь не дошла, продолжали крутить старую версию. Получился неприятный промежуточный статус: часть флота на новом релизе, часть — на старом, и без чёткого списка, где что.
Первая реакция дежурного была предсказуемой — перезапустить job. Со второй попытки раскатка проходила чуть дальше, с третьей — снова падала в похожем месте. Стало ясно, что это не разовая сетевая помеха, а что-то системное на управляющей машине, откуда идёт раскатка.
Что видели в логах и метриках
В логе CI job'а среди ошибок Ansible нашлась одна показательная строка, которую поначалу приняли за шум:
fatal: [app-27.internal]: UNREACHABLE! => {"changed": false, "msg": "Failed to connect to the host via ssh:
ssh_askpass: exec(/usr/bin/ssh-askpass): Resource temporarily unavailable
fork: retry: Resource temporarily unavailable", "unreachable": true}
Resource temporarily unavailable при попытке форкнуть новый процесс — классический признак упора в лимит, а не сетевой проблемы. Дальше уже целенаправленно смотрели на управляющую машину:
# сколько процессов сейчас у пользователя deployer
ps -u deployer h | wc -l
# текущий мягкий и жёсткий лимит для его сессий
sudo -u deployer bash -c 'ulimit -Su; ulimit -Hu'
Первая команда во время падающего job'а показывала число, вплотную подходящее ко второй. Совпадение по времени было один в один: график количества процессов пользователя deployer (собирали через текстовый коллектор node_exporter, отдающий ps -u deployer h | wc -l раз в 15 секунд) плавно рос с начала job'а и упирался в полку ровно в момент первых UNREACHABLE.
Второе наблюдение — полка была на одном и том же значении при каждом запуске, независимо от того, на каком именно сервере раскатка спотыкалась. Это отличало историю от сетевой флуктуации: если бы дело было в конкретном хосте или коммутаторе, точка падения гуляла бы, а не упиралась в одну и ту же цифру.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотезы, которые отбросили
Прежде чем добрались до лимита процессов, проверили несколько версий, каждая из которых на старте выглядела не менее правдоподобной.
- Проблема на стороне целевых серверов. Проверили
df -h,free -m,dmesg | tailна нескольких хостах, до которых раскатка не доходила — место, память, ядерные ошибки в норме. Отдельно смотрели, не срабатывает лиfail2banили иной механизм блокировки по IP управляющей машины: в логахsshdиfail2ban-client status sshd— ничего. - Баг конкретной версии Ansible. На управляющей машине к моменту инцидента обновили Ansible за неделю до этого, и совпадение по времени сразу вызвало подозрение. Откатили на предыдущую версию в тестовом окружении и воспроизвели ту же картину — версия Ansible была ни при чём.
- Лимит cgroup у systemd-юнита раннера. GitLab runner на управляющей машине работает как systemd-сервис, и первая мысль была — упёрлись в
TasksMaxэтого юнита. Проверили:
systemctl show gitlab-runner.service -p TasksMax -p TasksCurrent
TasksCurrent был заметно ниже TasksMax — юнит был ни при чём, лимит явно приходил откуда-то ещё.
- DNS и сетевые таймауты. Проверили резолвинг имён хостов флота (
resolvectl query) и время установления TCP-соединения до нескольких серверов из точки падения — задержки были в пределах обычных, ничего похожего на деградацию сети.
Отбросив по очереди сетевые и программные версии, вернулись к самому симптому — fork: retry: Resource temporarily unavailable — и стали разбираться, какой именно лимит стоит за системным пользователем deployer, а не за systemd-юнитом.
Как докопались до настоящей причины
Ключевая деталь — job на управляющей машине запускался shell-executor'ом GitLab runner, который в свою очередь логинится под пользователя deployer через полноценную login-сессию (не как systemd-сервис, а как процесс, порождённый через su/PAM-сессию). А для login-сессий на этой машине действовали лимиты из /etc/security/limits.d/, а не TasksMax какого-то юнита — это два разных механизма, которые ограничивают одно и то же количество процессов, но настраиваются и проверяются по-разному:
# /etc/security/limits.d/90-nproc.conf
deployer soft nproc 4096
deployer hard nproc 4096
Этот файл поставили полгода назад в рамках общей инвентаризации лимитов на серверах — тогда 4096 процессов на пользователя выглядели избыточным запасом. С тех пор автоматизации на этой машине стало больше, но никто не пересматривал лимит под новую нагрузку.
Дальше стало интересно: 40 серверов с forks: 50 даже с учётом того, что Ansible на каждое SSH-подключение поднимает не один, а несколько процессов (сам ssh, при отключённом pipelining — ещё и sftp/scp для передачи модуля, плюс локальный python-воркер Ansible на управляющей машине для обработки результата) — не должны были упереться в 4096 при 40 хостах. Посчитали по максимуму — получалось в разы меньше лимита. Значит, часть процессов накапливалась ещё до старта деплоя.
Проверили, что творится с процессами deployer в спокойное время, вне деплоев:
ps -u deployer -o pid,ppid,etime,cmd --sort=-etime | head -40
И нашли объяснение: параллельно с раскатками на той же машине по ночам крутился отдельный smoke-test job, который тоже логинится по SSH на серверы флота, но с ControlMaster=auto и ControlPersist=10m. Из-за особенности его обёртки master-соединения не закрывались явно (ssh -O exit не вызывался), и ControlPersist держал их дольше ожидаемого — часть master-процессов зависала на многие часы, а не завершалась через 10 минут, как задумывалось. За несколько недель накопился фоновый пул из полутора-двух тысяч висящих ssh-процессов, которые никто не замечал, потому что сами по себе они не потребляли заметно CPU или память — только слот в лимите nproc.
Получалась картина: к началу дневного деплоя у пользователя deployer уже было занято существенно больше половины лимита фоновыми master-соединениями от ночных smoke-тестов. Оставшегося запаса хватало примерно на два десятка серверов раскатки, после чего новые fork() начинали получать EAGAIN — то есть ровно Resource temporarily unavailable, которую видели в логе Ansible. Момент падения гулял на пару серверов от запуска к запуску именно потому, что зависших фоновых соединений на момент старта каждого конкретного job'а было чуть по-разному.
Корень проблемы без спецэффектов
Если убрать детали конкретной обёртки, суть простая: два независимых процесса (ночные smoke-тесты и дневной деплой) делили один и тот же бюджет nproc одного системного пользователя, и ни один из них не был спроектирован с оглядкой на то, что бюджет общий. Smoke-тесты незаметно съедали его фоном, деплой добивал остаток и первым натыкался на стену — хотя причина была не в нём.
Отдельно стоит подчеркнуть путаницу, из-за которой поиск причины занял больше времени, чем мог бы: nproc в /etc/security/limits.d/ действует только на процессы, порождённые через PAM-сессию (интерактивный логин, su, sudo -i, а в нашем случае — shell-executor раннера, который логинится именно так). Процессы, запущенные напрямую как systemd-сервис от того же пользователя, этот файл не видит вообще — для них лимит числа задач регулируется TasksMax юнита или DefaultTasksMax в systemd-system.conf / logind.conf. Мы почти час потратили на проверку TasksMax раннера именно потому, что заранее не разделили эти два механизма — искали лимит не в том месте.
Что изменили после инцидента
Правки разделили на две группы — то, что убирает конкретную причину, и то, что не даёт похожей ситуации повториться в другом виде.
Точечно по инциденту:
- В обёртке ночных smoke-тестов добавили явное закрытие master-соединения после каждого прогона:
ssh -O exit -o ControlPath=... hostв блоке очистки, независимо от того, успешно завершился тест или упал с ошибкой. - Сократили
ControlPersistдля этой обёртки с 10 минут до 30 секунд — запас на повторные обращения внутри одного прогона всё равно избыточен для реального сценария использования. - Добавили в начало smoke-test job'а и деплой-job'а шаг очистки: поиск и закрытие master-сокетов старше часа перед стартом, как страховку на случай, если очистка по какой-то причине снова не сработает.
Системно:
- Подняли
nprocдля пользователяdeployerдо значения с ощутимым запасом сверх текущего пикового потребления (посчитали пиковое потребление по метрике за две недели и заложили кратный запас, а не round-число наугад), с комментарием в файле лимитов, откуда взялась цифра и когда её в следующий раз стоит пересмотреть. - Завели в мониторинг отдельную метрику по количеству процессов ключевых сервисных пользователей (
deployerи ещё пары технических аккаунтов) с алертом на приближение к 70% от лимита — раньше эта метрика собиралась, но алерт на неё не был настроен, поэтому рост никто не заметил вовремя. - В плейбук деплоя добавили
any_errors_fatal: trueи разбили раскатку на последовательные батчи (serial: 10) вместо одного захода на все 40 серверов сразу — теперь при сбое раскатка останавливается на границе батча с понятным списком «что уже обновлено», а не расползается по флоту частично и бесконтрольно. - В сам job деплоя добавили преflight-шаг, который до начала раскатки проверяет свободный запас по
nprocдляdeployerи явно останавливает job с понятным сообщением, если запас меньше расчётного минимума на весь прогон — лучше явный отказ на нулевой минуте, чем непонятная ошибка на середине.
Что стоит проверить у себя, если деплой падает не на всех серверах одинаково
Если раскатка стабильно спотыкается примерно в одном месте списка, а точка падения слегка гуляет от запуска к запуску, это довольно характерный отпечаток именно упора в лимит процессов, а не сетевой или прикладной проблемы. Стоит по порядку проверить следующее.
| Проверка | Команда | Что означает результат | |
|---|---|---|---|
Мягкий и жёсткий nproc пользователя, от которого идёт деплой | sudo -u <user> bash -c 'ulimit -Su; ulimit -Hu' | Если цифра заметно ниже ожидаемого числа параллельных подключений — вот и кандидат | |
| Текущее число процессов этого пользователя | `ps -u <user> h \ | wc -l` | Сравнить с лимитом выше; если близко даже вне деплоя — где-то фоновая утечка |
| Откуда запущен процесс — как login-сессия или как systemd-юнит | loginctl user-status <user> и systemctl status <unit> | От этого зависит, limits.conf или TasksMax регулирует лимит | |
TasksMax соответствующего systemd-юнита, если процесс запускается через него | systemctl show <unit> -p TasksMax -p TasksCurrent | Актуально, только если процесс — systemd-сервис, а не login-сессия | |
| Долгоживущие фоновые процессы того же пользователя | `ps -u <user> -o pid,etime,cmd --sort=-etime \ | head` | Ищем то, что должно было завершиться, но зависло |
Для быстрой диагностики полезно держать под рукой и обратный список — где число процессов пользователя вообще не является узким местом, а лимит открытых файлов у того же пользователя может оказаться следующей стеной: механика похожая, но за неё отвечает уже отдельный nofile, а не nproc, и мы отдельно разбирали, как его настраивать через ulimit. Если раскатка идёт через контейнеры и упирается не в процессы управляющей машины, а в лимиты внутри самих контейнеров, механика уже другая — там работают cgroup, и стоит посмотреть, как именно cgroups ограничивают контейнер. А если проблема не в лимите процессов, а в самом сценарии автодеплоя из git — стоит свериться с частыми причинами сбоев, которые мы собирали отдельно для автодеплоя из git на сервере.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему ошибка fork: retry: Resource temporarily unavailable не выглядит очевидно как проблема с лимитами?
Потому что она приходит от конкретной команды (в нашем случае — от ssh/ssh-askpass, вызванного Ansible) и легко читается как её локальный сбой, а не как системное ограничение. Resource temporarily unavailable — это код EAGAIN, и при вызове fork()/clone() он почти всегда означает именно упор в nproc, pid_max или TasksMax, а не проблему конкретной программы.
Чем nproc из /etc/security/limits.d/ отличается от TasksMax systemd-юнита?
Оба ограничивают число процессов/задач, но применяются к разным механизмам порождения процессов. limits.conf работает через PAM и действует на login-сессии — интерактивный вход, su, sudo -i, сессии, которые открывает shell-executor некоторых CI-раннеров. TasksMax — это ограничение cgroup конкретного systemd-юнита и действует на всё, что порождено внутри этого юнита, независимо от PAM. Процесс, запущенный одним механизмом, лимитом другого не ограничивается.
Как узнать, какой именно лимит сработал, если под рукой нет доступа посмотреть логи в реальном времени?
Проверить /proc/<pid>/limits для живого процесса того же пользователя — там видно и мягкий, и жёсткий Max processes. Если процесс уже завершился, полезно смотреть journalctl -k | grep -i "cgroup\|fork\|EAGAIN" — ядро логирует часть отказов при упоре в cgroup-лимиты, хотя отказы nproc через PAM в журнал ядра не попадают и видны только по симптому в самом приложении.
Стоит ли просто поднять nproc с запасом на будущее и забыть?
Частично да — разумный запас нужен, но сам по себе он не решает проблему, если есть утечка процессов (как в нашем случае с зависшими ssh-мастерами). Больший лимит в такой ситуации просто отодвигает момент отказа дальше по времени, а утечка продолжает расти и рано или поздно упрётся в новый, уже больший потолок. Лимит с запасом и мониторинг фактического потребления имеет смысл настраивать вместе, а не по отдельности.
Можно ли было поймать это раньше по метрикам, не дожидаясь падения деплоя?
Да, и это как раз то, чего не хватало до инцидента: метрика по числу процессов ключевых сервисных пользователей уже собиралась текстовым коллектором, но алерт на неё не был настроен. Плавный рост за несколько недель на графике был бы заметен на глаз задолго до того, как запас исчерпался бы во время реального деплоя.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →