Фронтенд-команда смотрит на зелёный индикатор и ждёт, когда соберётся бандл. Бэкенд-команда смотрит на тот же индикатор и ждёт API-тесты. В CI у пайплайна одна строка: «Duration: 23 минуты». Из этой цифры нельзя вывести ни то, кто кого задерживает, ни то, какой сервер нужен. Двадцать три минуты — это результат наложения нескольких разных нагрузок, а сервер выбирают под нагрузки, а не под итог.
Вопрос «сколько ядер нужно монорепозиторию» поэтому поставлен неудачно. Правильнее спросить: какой этап задерживает людей, чем он ограничен — процессором, памятью, диском или сетью — и что случится, когда таких пайплайнов станет три одновременно. Дальше разберём это по шагам, на условном примере. Все цифры в нём иллюстративные, подставьте свои.
Сначала разложить сборку на отрезки
Возьмём типичный монорепозиторий: веб-клиент, API-сервис и общий пакет типов, который нужен им обоим. Пайплайн в нём выглядит примерно так: установка зависимостей, сборка общего пакета, сборка frontend, сборка API, юнит-тесты API с базой данных, браузерные тесты, сборка образов.
Чтобы увидеть картину, нужны три измерения для каждого этапа: время начала и конца, пик памяти и то, чего этап ждёт. Время почти всегда видно в самом CI. Пик памяти придётся снять самостоятельно, например запустив этап на тестовой машине под наблюдением /usr/bin/time -v (поле Maximum resident set size) либо просто следя за free -m и top в соседнем окне. Для контейнерных этапов подойдёт docker stats. Эти инструменты есть в стандартном Linux и Docker, ничего экзотического.
Выпишите результат в таблицу. Вот условный пример для одного прогона:
| Этап | Длительность | Пик памяти | Чем ограничен |
|---|---|---|---|
| Установка зависимостей | 3 мин | 1 ГБ | сеть и диск |
| Общий пакет типов | 1 мин | 1 ГБ | один поток |
| Сборка frontend | 6 мин | 5 ГБ | процессор и память |
| Сборка и тесты API | 5 мин | 2 ГБ | процессор, база данных |
| Браузерные тесты | 7 мин | 4 ГБ | процессор, память, таймауты |
| Сборка образов | 2 мин | 1 ГБ | диск, сеть |
Сумма длительностей здесь 24 минуты, а пайплайн занимает 23: что-то уже перекрывается. Эта разница и есть начало разговора. Если бы всё шло строго друг за другом, итог совпал бы с суммой. Если бы всё выполнялось параллельно, итог был бы равен самому длинному этапу. Реальность лежит между ними, и положение зависит от графа зависимостей, а не от количества ядер.
Что действительно независимо, а что только выглядит независимым
Теперь нарисуйте стрелки. Общий пакет типов нужен и frontend, и API, значит, до его готовности стартовать нельзя. Установка зависимостей нужна всем. После этих двух шагов открывается развилка: сборка frontend и сборка API не зависят друг от друга. Юнит-тесты API зависят от сборки API и базы. Браузерным тестам нужны собранный frontend и запущенный API с базой: это точка схождения двух веток.
На бумаге критический путь такой: установка (3) → типы (1) → frontend (6) → браузерные тесты (7) → образы (2). Итого 19 минут, если у всех достаточно ресурсов. API-ветка (5 минут сборки и тестов) идёт рядом и в критический путь не попадает, но с ним связана: браузерные тесты ждут не только frontend, но и API. Пока frontend собирается шесть минут, API успевает собраться, и его ожидание «бесплатное». Если же frontend ускорить до трёх минут, критическим станет API, и ускорение окажется меньше ожидаемого. Это важный контраст: оптимизировать одну ветку имеет смысл только до тех пор, пока она остаётся самой длинной.
Теперь про «выглядит независимым». Две задачи, которые по графу можно запускать вместе, на одном сервере конкурируют за одни и те же ядра и память. Сборка frontend и сборка API в нашем примере одновременно занимают 5 + 2 = 7 ГБ памяти. Если на машине 8 ГБ вместе с системой, базой данных для тестов и файловым кэшем, начнётся своппинг или OOM-killer завершит самый прожорливый процесс. Тогда параллельный запуск окажется не быстрее последовательного, а то и вовсе приведёт к падению сборки без внятной причины в логе. Поэтому граф зависимостей отвечает на вопрос «можно ли параллелить», а профиль ресурсов — на вопрос «хватит ли железа».
Есть и третья группа ограничений, которые графом не видны: общая база данных для тестов (две задачи, пишущие в одну схему, портят друг другу результаты), занятые порты, общий каталог артефактов, блокировка менеджера пакетов. Такие места лечатся изоляцией: отдельная база или схема на задачу, динамические порты, раздельные рабочие каталоги. Это работа с конфигурацией, не с размером сервера, но без неё лишние ядра не помогут.
Три потребителя, три разных характера
Frontend-сборка обычно живёт на процессоре и памяти. Бандлеры и транспиляторы раскладывают работу по потокам, но пик памяти часто определяется размером графа модулей: чем крупнее приложение, тем выше пик. Типичная проблема: сборка проходит на ноутбуке с 16 ГБ и падает на маленьком runner. Разумно не гадать, а смотреть в профиле, где именно пик, и при необходимости ограничить параллелизм самого сборщика, прежде чем покупать память.
API-сборка и тесты ведут себя иначе. Компиляция или установка зависимостей грузит процессор, а тесты с базой данных упираются ещё и в диск и задержку при обращении к БД. Здесь выигрыш приносит быстрый диск и корректно поднятая тестовая база, а не только количество потоков. Если база для тестов лежит на обычном диске с высокой задержкой, тест на сотни запросов растягивается, хотя процессор простаивает. Это легко заметить: загрузка ядер низкая, а время большое.
Браузерные тесты — третий характер: много лёгких проверок, каждая из которых запускает окно браузера и ждёт ответа страницы. Они потребляют и процессор, и память на каждый worker, а ещё чувствительны к таймингам: при перегрузке сервера тест начинает падать не из-за бага, а из-за таймаута. Документация Playwright о CI советует начинать с одного worker ради воспроизводимости, а на мощном собственном CI допускает параллельный запуск; для распределения набора по нескольким задачам есть sharding, а общий таймаут позволяет самому инструменту завершить зависший прогон и подготовить отчёт (Playwright: Continuous Integration). Для нашей темы важен вывод: число workers — такая же ручка нагрузки, как и размер сервера, и её нужно подбирать вместе с остальными этапами, а не отдельно. Подробности работы с браузерным набором — отдельная тема, здесь это один потребитель из нескольких.
Теперь можно честно сравнить, как на разных этапах ведёт себя дополнительное ядро.
- Frontend: помогает до определённого предела, затем упирается в память или в последовательные участки сборки.
- API-тесты: помогает, если тесты независимы между собой; не помогает, если все они делят одну базу.
- Браузерные тесты: помогает вместе с памятью; без памяти добавленные workers дают нестабильность.
- Установка зависимостей и сборка образов: почти не зависят от числа ядер, зато зависят от диска и сети.
Кэш: когда он ускоряет, а когда только занимает место
Установка зависимостей — самый очевидный кандидат на кэш. Если lock-файл не менялся, зачем снова скачивать те же пакеты? Но здесь легко принять желаемое за действительное. Кэш помогает, пока ключ кэша совпадает; стоит каждому изменению lock-файла сбрасывать его целиком, и вы будете платить за хранение и восстановление, получая холодную установку. В монорепозитории, где десятки пакетов и lock-файл правят каждый день, такое случается часто.
Есть и обратный эффект: восстановление большого кэша само по себе занимает время. Если архив на несколько гигабайт распаковывается дольше, чем скачиваются нужные пакеты, кэш замедляет пайплайн. Это проверяется простым сравнением: два прогона с кэшем и без, на одном и том же коммите. Не верьте ощущениям, запишите время обоих.
Для сборки образов кэш устроен по-другому. Buildx поддерживает inline, registry и local cache, причём доступность конкретного backend зависит от драйвера и хранилища образов; registry cache хранится отдельно от итогового образа, а внешний кэш нужно явно импортировать и экспортировать (Docker: Cache storage backends). Практический смысл: на одном постоянном сборочном сервере локальный кэш слоёв часто самый простой вариант, а registry-кэш пригодится, когда сборки идут на разных машинах и им нужно делиться результатом. Выбор определяется топологией ваших runner, а не модой.
Отдельное предупреждение из той же документации: передача секретов через COPY или ARG может их раскрыть, для секретов при сборке предусмотрен отдельный механизм. В монорепозитории с приватными реестрами пакетов это касается напрямую: токен доступа к реестру не должен оказываться в слоях образа или в кэше, который вы потом выгрузите во внешнее хранилище.
Теперь о кэше результатов сборки, когда пропускаются неизменившиеся пакеты. В монорепозитории это самое ценное: если правка касается только API, зачем пересобирать frontend? Но выгода зависит от того, насколько честно описаны входные данные каждой задачи. Если сборка читает переменные окружения или файлы, не указанные среди входов, кэш будет отдавать устаревший результат; если, наоборот, входов указано слишком много, кэш будет промахиваться. Поэтому на первом этапе внедрения полезно включить кэш и сравнить артефакты «с кэшем» и «без кэша» на нескольких коммитах. Расхождение — признак неполного описания входов.
И последнее про кэш: он съедает диск. Кэш зависимостей, слои образов, артефакты старых прогонов — всё это копится. На маленьком NVMe-диске сборочного VPS рост кэша незаметно превращается в падения «no space left on device». Нужна политика очистки, а при выборе размера диска стоит заложить место не только под репозиторий, но и под кэш нескольких веток.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Один прогон — не то же самое, что десять pull request
До сих пор мы мерили одиночный пайплайн. Но команда работает иначе: утром в репозиторий приходят три pull request, ещё два обновляются после ревью, а кто-то пушит исправление за минуту до обеда. Нагрузка становится очередью, а не одиночным событием.
Одиночный пайплайн в нашем примере занимает 23 минуты и пиково требует около 9 ГБ, когда frontend, API и браузерные тесты перекрываются. Три таких пайплайна одновременно — это уже под 27 ГБ пиковой памяти, если пики совпали, а они часто совпадают, потому что у всех стартовый этап одинаковый и стадии идут в ногу. Это и есть главное отличие: параллельные pull request синхронизируются по этапам и бьют в сервер одной и той же фазой.
Тут нужно понять, чего вы хотите. Есть две разные цели: минимальное время одного прогона и минимальное время ожидания очереди. Они конфликтуют. Если дать каждому прогону все ядра, он выполнится быстро, но остальные будут стоять. Если разрешить много параллельных прогонов, каждый пойдёт медленнее, зато никто не ждёт старта. Команде обычно важнее второе: человеку неприятно не само долгое выполнение, а неопределённость, когда его задача даже не начата.
Есть и тонкость с перекрытием. Итоговое время прогона не равно сумме этапов именно потому, что они перекрываются, и под нагрузкой это перекрытие ломается. Две ветки, которые на свободном сервере шли параллельно за шесть минут, на занятом сервере растянутся и частично превратятся в последовательные. Прогон из 19 минут критического пути легко становится 30, и никто не менял код. Поэтому замерять нужно не только пустой сервер, но и сервер под реалистичной очередью.
Помогает и простая гигиена: отменять устаревшие прогоны, когда в ту же ветку пришёл новый коммит, запускать тяжёлые браузерные тесты только там, где это нужно (например, перед слиянием), а быстрые проверки — на каждый пуш. Это не оптимизация железа, но она снижает пиковую нагрузку заметнее, чем лишние ядра.
Число одновременных jobs и размер runner — одно решение
Теперь можно соединить всё вместе. Размер runner и число одновременных задач — две стороны одного уравнения: общая память делится на пик одной задачи, и получается верхняя граница параллелизма, причём к памяти нужно прибавить запас под систему, базу данных и кэш файловой системы.
На нашем условном примере посчитаем. Самая тяжёлая задача — сборка frontend с пиком 5 ГБ. Если на сервере 16 ГБ, из них около 3 ГБ нужно оставить под систему, базу и файловый кэш, остаётся 13 ГБ. Это две тяжёлые задачи одновременно с небольшим запасом, но не три. Значит, лимит concurrency для такого runner — два, и третью задачу стоит поставить в очередь, а не запускать. По процессору проверка аналогична: если frontend и браузерные тесты при полной загрузке используют по четыре ядра, на восьми ядрах вы получите две такие задачи без борьбы за процессор.
Если же на одном runner живут и тяжёлые, и лёгкие задачи (линтеры, юнит-тесты, сборка образов), их полезно разнести: два разных runner или два пула с разными лимитами. Тогда быстрые проверки не стоят в очереди за браузерным набором. Для настройки собственного агента пригодится материал GitLab Runner на VPS: свой CI/CD-агент, где разобрана установка и регистрация runner на сервере.
Когда имеет смысл один крупный сервер, а когда несколько мелких? Один крупный проще в обслуживании, у него общий локальный кэш и меньше простоя. Несколько мелких дают изоляцию задач друг от друга и удобнее, если нагрузка сильно колеблется, но кэш придётся делить через registry или внешнее хранилище. Заранее универсального ответа нет, решают ваши замеры. При этом не стоит считать, что два VPS автоматически дают отказоустойчивость: это просто два сервера, и отказоустойчивость нужно строить отдельно.
Про ресурсы VPS. В линейке MAATRIX есть конфигурации от 1 vCPU и 2 ГБ RAM (Ba) до 16 vCPU и 32 ГБ (Djed); промежуточные — Uab с 2 vCPU и 4 ГБ, Heka с 4 vCPU и 8 ГБ, Maat с 8 vCPU и 16 ГБ. На всех указан NVMe-диск, что важно для кэша и тестов с базой данных, а root-доступ позволяет поставить нужные пакеты, настроить Docker и собственный runner. Эти параметры — условия, а не обещание ёмкости: сколько задач потянет конкретная конфигурация, показывает только ваш профиль. В нашем примере под две тяжёлые задачи подошёл бы вариант вроде Maat, но на ваших цифрах расчёт может дать другой ответ.
Как проверить выбор, не ломая рабочий CI
Разумный путь — репетиция. Поднимите отдельный сервер, зарегистрируйте на нём runner с особым тегом и направьте на него один необязательный job или одну неосновную ветку. Основной пайплайн продолжает работать на старой инфраструктуре, поэтому ошибка в настройке не остановит команду.
Дальше соберите данные на нескольких днях реальной работы, а не на одном удачном прогоне. Следите за четырьмя величинами: время ожидания в очереди, время выполнения по этапам, пик памяти и доля падений, не связанных с кодом (OOM, таймауты, нехватка диска). Если очередь короткая, пики памяти ниже границы, а ложных падений нет, можно переключать на новый runner основную нагрузку.
Критерии переключения и возврата лучше записать заранее, пока нет эмоций. Например: переключаем, если медианное ожидание в очереди упало и доля нестабильных падений не выросла. Возвращаемся, если появились OOM или таймауты, которых не было раньше, либо ожидание выросло. Конкретные пороги вы выберете сами по своей статистике. Важно, чтобы возврат был простым: старый runner остаётся зарегистрированным и доступным, пока новый не отработал нужный срок без сюрпризов.
Отдельно проверьте очистку. Запустите серию прогонов подряд и посмотрите, как растёт занятое место на диске. Если кэш и образы копятся без границы, настройте периодическую очистку до переключения, а не после первой аварии.
Кто на самом деле ждёт дольше всех
Вернёмся к двум командам из начала. Frontend-команда видит сборку и браузерные тесты, API-команда — свои тесты. После профилирования выяснится, что их ожидание устроено по-разному. Frontend-команда ждёт критический путь: установку, типы, сборку и браузерный набор. API-команда может ждать вовсе не собственные тесты, а очередь, потому что тяжёлые прогоны соседей заняли сервер. Общее «23 минуты» скрывало эту разницу.
Поэтому итоговое решение стоит принимать не по числу ядер, а по самому длинному ожиданию, которое реально мешает людям. Если это очередь, лечится лимитом одновременных задач, разделением пулов и отменой устаревших прогонов, иногда вторым runner. Если это критический путь одного прогона, лечится кэшем, разбиением тестов на шарды и ускорением именно той ветки, которая сейчас самая длинная. Если это ложные падения от нехватки памяти, лечится объёмом RAM, а не процессором. Каждое из трёх решений стоит по-разному, и только профиль подскажет, какое окупится.
Практичный порядок действий такой: снять профиль одного прогона, нарисовать граф зависимостей, проверить кэш честными сравнениями, посмотреть поведение под очередью, посчитать допустимое число одновременных задач по памяти и только потом выбирать размер сервера. Если после этого окажется, что маленькая конфигурация справляется, а команда ждёт из-за очереди или неудачного кэша, лишние ядра были бы деньгами, потраченными мимо. А если пик памяти упирается в потолок, добавить RAM будет правильнее, чем увеличивать число workers. Лучший сервер для монорепозитория тот, после которого самое длинное ожидание команды становится короче, а не тот, у которого больше ядер в описании.