Предел параллельных сборок: сколько job-ов выдержит раннер до срыва таймаутов
Рано или поздно в любом self-хостед CI приходит момент, когда кто-то поднимает concurrent в конфиге раннера с 4 до 10, потому что «сборок много, а очередь длинная» — и вместо ускорения получает волну таймаутов, случайные падения не связанных друг с другом job-ов и одну особенно неприятную сборку, у которой процесс компилятора убил OOM killer посреди линковки. Проблема в том, что параллелизм CI-раннера упирается не в одну цифру, а сразу в четыре независимых потолка — CPU, память, диск и сеть, — и превышение любого из них не даёт плавной деградации, а роняет разом все job-ы, которые в этот момент выполняются на машине. Разберём, как посчитать реальный предел параллелизма для конкретного железа.
Содержание
- Почему нельзя просто взять число ядер и делить
- Лимит по CPU и памяти: формула и на что реально смотреть
- Диск: I/O при checkout и build — недооценённое узкое место
- Сеть: скачивание зависимостей параллельными job-ами
- Что происходит при превышении лимита
- Настройка concurrent-лимитов в GitLab CI / GitHub Actions runner / Jenkins
- Практические рекомендации по масштабированию раннеров
Почему нельзя просто взять число ядер и делить
Первый инстинкт — поставить concurrent = nproc. Это работает только для job-ов, которые целиком CPU-bound, не создают дочерних процессов сверх одного потока и не трогают диск и сеть. Реальная сборка так себя не ведёт почти никогда:
- Компиляция (
make -j,cargo build,webpack) сама параллелится внутри job-а и может забрать больше ядер, чем вы ей номинально выделили —make -j$(nproc)внутри контейнера с CPU-лимитом в 2 ядра всё равно попытается запустить процессы по числу ядер хоста, если лимит не прокинут явно. - Тесты часто держат в памяти БД, тестовые контейнеры, headless-браузер — RAM-профиль job-а на фазе тестов может быть в разы выше, чем на фазе сборки.
- Checkout и docker build — это в первую очередь диск: распаковка слоёв образа и запись объектов git создают всплеск IOPS, который не виден в usage CPU вообще.
- Установка зависимостей (
npm install,pip install,docker pull) — это сеть, причём часто именно она, а не компилятор, оказывается самым долгим шагом job-а.
Поэтому лимит параллелизма — это минимум из четырёх независимо посчитанных чисел: concurrent = min(лимит по CPU, лимит по RAM, лимит по диску, лимит по сети). Считать нужно все четыре, а не только тот, который проще всего измерить.
Лимит по CPU и памяти: формула и на что реально смотреть
Для CPU считайте не общее число vCPU, а число vCPU, которое реально доступно job-ам — вычтите то, что нужно самой ОС, демону раннера (gitlab-runner, Runner.Worker GitHub Actions, JVM Jenkins-агента) и системным сервисам вроде мониторинга:
nproc --all # всего vCPU на хосте
ps -eo pid,pcpu,comm --sort=-pcpu | head -10 # чем занят хост в простое
Практическая формула:
CPU_available = total_vCPU - reserved_for_host # обычно 1-2 vCPU про запас
CPU_limit = CPU_available / vCPU_per_job
vCPU_per_job — не то, что вы прописали в лимите контейнера, а то, что job реально потребляет на пике. Узнать честно можно только замером: запустите одну сборку в изоляции и посмотрите docker stats или pidstat -u 1 во время самой тяжёлой фазы (обычно это компиляция или линковка), а не усреднённое значение за весь job.
С памятью логика та же, но ошибка в другую сторону обходится дороже — если CPU при перегрузке просто делит время (планировщик хоста режет каждому по кусочку), то память либо есть, либо OOM killer убивает процесс. Поэтому для RAM берите не средний расход, а пиковый:
free -h # сколько всего и сколько уже занято
cat /proc/meminfo | grep -i Commit # текущий commit-лимит и факт коммита памяти
RAM_available = total_RAM - reserved_for_host - swap_buffer
RAM_limit = RAM_available / RAM_peak_per_job
reserved_for_host для VPS с docker executor разумно закладывать 10-15% RAM под Docker daemon, containerd, файловый кэш и демон раннера — если срезать запас в ноль, любой всплеск на хосте (ротация логов, backup-джоба) начинает конкурировать с job-ами за память. Итоговый лимит по компьюту — min(CPU_limit, RAM_limit), и на практике почти всегда именно RAM_limit оказывается уже, потому что пиковое потребление памяти недооценивают чаще, чем пиковую загрузку CPU.
Пример на условных цифрах (не эталон — на вашем железе числа будут другие): 8 vCPU, 16 ГБ RAM, резерв хосту 1 vCPU и 3 ГБ RAM. Job в среднем ест 1.5 vCPU и 1.5 ГБ, но на пике компиляции — до 3 ГБ. Тогда CPU_limit = 7 / 1.5 ≈ 4, RAM_limit = 13 / 3 ≈ 4. Безопасный concurrent = 4, а не 8, которые формально влезают по ядрам, и не 10, которые кажутся достаточными при подсчёте по средней, а не пиковой памяти.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверДиск: I/O при checkout и build — недооценённое узкое место
Диск редко упоминают первым, но именно он чаще всего оказывается настоящим потолком параллелизма, потому что нагрузка на него нелинейна: один job делает последовательное чтение/запись и почти не создаёт очереди, а пять параллельных job-ов одновременно делают git checkout, распаковку слоёв docker-образа и запись временных файлов сборки — и диск начинает работать в режиме случайного доступа с конкурирующими потоками, для которого throughput в разы ниже паспортного.
Проверить, упирается ли текущая нагрузка в диск, можно во время реальной параллельной сборки:
iostat -x 1 # %util близко к 100 — диск уже узкое место
vmstat 1 # столбец wa (iowait) стабильно растёт
Если %util держится у 90-100% при нескольких одновременных job-ах, добавлять параллелизм бессмысленно — новые job-ы просто встанут в очередь на I/O, и время выполнения каждого вырастет пропорционально числу конкурентов, даже если CPU и память свободны. Диск стоит проверить синтетическим тестом заранее, а не гадать по метрикам под нагрузкой — методика разобрана в статье про честный замер диска через fio; для CI-раннера важен профиль со смешанным случайным чтением/записью и небольшим блоком — он ближе к паттерну checkout+build, чем последовательный тест на больших блоках.
Это одна из частых причин загадочных падений сборок без видимой причины в логах — диск забивается местом или упирается в IOPS, а ошибка всплывает где угодно, только не в явном виде «нет места». Разбор одного такого инцидента, где сборки падали с ошибкой линковки, а причина оказалась в исчерпании диска раннера, — в статье диск раннера кончился, а сборки падали на линковке. Практический вывод: для параллельных сборок NVMe оправдан не ради лишних гигабайт, а ради глубины очереди команд — она позволяет диску обслуживать несколько потоков I/O без деградации, чего от SATA SSD в такой нагрузке ждать не стоит.
Сеть: скачивание зависимостей параллельными job-ами
Четвёртый потолок — исходящий и входящий трафик раннера. Если пять job-ов одновременно делают npm install, pip install -r requirements.txt или docker pull базового образа, они делят один канал, и на слабом аплинке VPS это превращается в одинаковое линейное замедление всех пяти сразу — ровно тот же эффект «деградации разом», что при перегрузке CPU или диска.
Проверить загрузку канала во время параллельной сборки:
nload # текущая скорость по интерфейсам
ss -tunlp | grep ESTAB # сколько активных соединений держат job-ы
Единственное по-настоящему рабочее решение здесь — не «расширить канал», а убрать повторяющееся скачивание одного и того же из внешней сети:
- локальный кэш пакетного менеджера (
npmчерез Verdaccio/Nexus-прокси,pipчерез devpi, Maven/Gradle через локальный репозиторий-зеркало); - локальный registry mirror для Docker-образов, чтобы
docker pullбазовых образов шёл из локальной сети, а не каждый раз из Docker Hub; - кэш зависимостей самого CI (GitLab CI cache, actions/cache) с ключом по lock-файлу, чтобы job-ы вообще не трогали сеть при неизменных зависимостях.
После внедрения локального кэша сеть почти всегда перестаёт быть лимитирующим фактором раньше, чем CPU и память — но проверить стоит именно на своей нагрузке, а не считать по умолчанию, что раз кэш есть, сеть больше не в счёт.
Что происходит при превышении лимита
Превышение любого из четырёх потолков не даёт постепенной деградации — оно валит все job-ы, которые в этот момент выполняются на машине, потому что они делят один и тот же физический ресурс без изоляции по приоритету:
- Таймауты. Если CPU или диск перегружены, шаги job-а (компиляция, тесты, checkout) идут медленнее, чем закладывалось в
timeoutпайплайна. Job не падает с явной ошибкой — он висит до истечения таймаута и завершается сообщением видаERROR: Job failed: execution took longer than Xm, которое ничего не говорит о настоящей причине. - OOM kill сборок. Когда суммарное потребление памяти всеми job-ами превышает доступную RAM (плюс swap, если он есть), ядро вызывает OOM killer, а он выбирает жертву по эвристике
oom_score— не обязательно тот процесс, что вызвал перегрузку. Убитым может оказаться компилятор в чужом, вполне лёгком job-е, просто оказавшемся «удобной» жертвой по памяти. Механику выбора жертвы разбирает статья как ядро выбирает жертву OOM killer. - Деградация всех job-ов разом. В отличие от облачных CI, где каждый job часто получает изолированную виртуалку, self-хостед раннер с несколькими job-ами на одной машине — классический оверкоммит: CPU делится планировщиком линейно (все становятся медленнее разом), а память — либо есть у всех, либо кто-то из процессов гибнет. Механика отказа CPU и памяти при превышении лимита разобрана в статье оверкоммит CPU и памяти: где предел — она написана про гипервизор, но для docker executor раннера, где job-ы делят хост через cgroups, механика идентична.
Итог: превышение лимита параллелизма — это не «job-ы стали работать чуть медленнее», а состояние нестабильности: один и тот же пайплайн может пройти зелёным и упасть с таймаутом в зависимости от того, что параллельно крутилось на раннере в этот момент. Именно нестабильность, а не абсолютное падение производительности — главный сигнал, что параллелизм пора снижать.
Настройка concurrent-лимитов в GitLab CI / GitHub Actions runner / Jenkins
Посчитанный лимит нужно явно прописать в конфиге раннера — иначе система по умолчанию либо не ограничивает параллелизм вообще, либо ограничивает его не так, как вы рассчитывали.
GitLab Runner (/etc/gitlab-runner/config.toml) — два уровня лимита: глобальный concurrent и лимит на конкретный [[runners]]:
concurrent = 4 # общий потолок по всем runner-секциям сразу
[[runners]]
name = "docker-runner-1"
limit = 4 # потолок для этой конкретной секции
executor = "docker"
[runners.docker]
cpus = "1.5" # cgroups-лимит на каждый job
memory = "3g"
memory_swap = "3g" # запрещаем job-у уходить в своп хоста
Без явного cpus/memory в секции [runners.docker] job внутри контейнера видит все ресурсы хоста и может запустить make -j на все ядра сразу, игнорируя ваш расчёт лимита параллелизма.
GitHub Actions self-hosted runner встроенного лимита параллелизма на машине не имеет — каждый установленный раннер берёт ровно один job за раз. Параллелизм регулируется числом установленных экземпляров раннера (несколько сервисов actions.runner.* с разными именами) и лимитами runner group на уровне организации. Практически расчёт concurrent из предыдущего раздела применяется не к конфигу одного раннера, а к тому, сколько экземпляров вы поднимаете на одном хосте — плюс явно выставлять --cpus/--memory в job-е, если сборки идут в Docker.
Jenkins — параллелизм задаётся числом executors на агенте (Manage Jenkins → Nodes → конкретный агент → # of executors). Правило то же: число executors не должно быть больше, чем позволяет самый узкий из четырёх лимитов, а не число ядер агента. Для Docker-агентов Jenkins лимиты CPU/памяти на контейнер задаются в конфиге Docker Cloud или напрямую в docker run-аргументах шаблона агента.
Практические рекомендации по масштабированию раннеров
Когда посчитанный concurrent стал у́же, чем требуемая скорость прохождения очереди пайплайнов, есть два направления масштабирования, и они закрывают разные проблемы:
| Способ | Когда помогает | Когда не помогает |
|---|---|---|
| Вертикальное (больше vCPU/RAM/NVMe на той же машине) | Лимит упирался в CPU или RAM конкретно на этом раннере | Диск и сеть общие для всех job-ов на хосте — узкое место может остаться тем же |
| Горизонтальное (несколько раннеров-агентов) | Нужна изоляция между тяжёлыми и лёгкими job-ами, нужен запас на пиковые часы | Требует общего кэша зависимостей между машинами, иначе каждый раннер греет свой кэш заново |
| Выделенные раннеры по тегам (heavy/light) | Есть явно разные профили job-ов (сборка vs линт) | Усложняет конфиг пайплайна, требует дисциплины в расстановке тегов |
Практические правила, которые снижают число сюрпризов:
- Считайте лимит по пиковому, а не среднему потреблению. Средний расход RAM за job почти всегда ниже пикового в 1.5-2 раза — при подсчёте по среднему оверкоммит обнаружится ровно тогда, когда совпадут пики нескольких job-ов, то есть в самый неудобный момент.
- Мониторьте раннер отдельно от мониторинга самих job-ов. CPU, память,
iostat, сетевой трафик хоста раннера должны быть на дашборде так же, как метрики приложения — иначе первый сигнал о перегрузке вы увидите не в графике, а в тикете «сборки стали нестабильными». - Разделяйте тяжёлые и лёгкие job-ы тегами раннера, если профиль нагрузки заметно разный (полная пересборка с нуля vs линтер за 10 секунд) — иначе
concurrentпридётся считать по самому тяжёлому сценарию, а лёгкие job-ы будут простаивать в очереди без необходимости. - Держите резерв под кэш зависимостей и docker layer cache — они растут медленно, но занимают именно тот диск, от которого зависит скорость checkout и build; отдельная оценка ресурсов под VPS для разработки и CI/CD — в статье сколько ресурсов нужно VPS для разработчика и CI/CD.
- Пересчитывайте лимит при смене стека или росте монорепозитория. Число, посчитанное год назад под проект на 50 файлов, не годится для монорепозитория на 5000 файлов — растёт и пиковая память, и время checkout.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли просто поставить concurrent равным числу vCPU и не считать вручную?
Можно, но это работает только для лёгких, чисто CPU-bound job-ов без заметного расхода памяти, диска и сети. Для реальных сборок такой подход почти всегда даёт нестабильные таймауты уже на 3-4-й параллельной сборке.
Что произойдёт, если превысить лимит по диску, а не по CPU/RAM?
Диск не убивает процессы, как OOM killer, — он выстраивает очередь I/O, и все job-ы, ждущие чтения/записи, синхронно замедляются. Выглядит это как «сборки стали медленнее без причины», и обнаружить причину без iostat/vmstat под нагрузкой сложно.
Свап поможет пережить нехватку памяти при параллельных сборках?
Ненадолго и ценой скорости — активный своп превращает нехватку RAM в нехватку диска I/O, потому что страницы памяти вытесняются на диск, который и так занят checkout и build. Правильнее снизить concurrent или добавить RAM.
Как понять, что узкое место — именно сеть, а не диск или CPU?
Проверить нагрузку на все четыре ресурса одновременно: mpstat/vmstat для CPU, free/docker stats для памяти, iostat -x для диска, nload/ss для сети. Обычно узкое место одно и видно сразу.
Нужно ли считать лимит отдельно для каждого раннера, если их несколько?
Да — если раннеры на разных машинах с разным железом, единый concurrent для всех даёт либо недогруз мощных машин, либо перегруз слабых. Лимит считается per-host, а не для инфраструктуры CI в целом.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →