Оптимизация Build Times
Remote caching, параллелизация и другие способы ускорить сборку
Сколько стоит получасовая сборка
В главе про feedback loops уже говорилось: после 10–15 минут ожидания разработчик переключается, и потом теряет десятки минут на возвращение в контекст. Арифметика выходит занятная. Команда из 15 инженеров, по три пуша в день на человека, сборка по 30 минут — суммарно 22,5 часа ожидания в сутки. По ставке 3000–4000 рублей в час получается 70–90 тысяч в день, 1,5–2 миллиона в месяц, около 20 миллионов в год. Потери от потерянного контекста сюда не входят, их просто никто не считает.
Зрелые инженерные команды эту арифметику видят и вкладываются в build times целенаправленно. Stripe после миграции трёх сотен сервисов в монорепозиторий на Bazel с remote caching ужал среднее время сборки с 30 минут до 5. Shopify публично рассказывал, как срезал CI с 15 минут до 5 за счёт параллелизации и аккуратного кэширования.
Remote caching: не собирать дважды
Идея за remote caching несложная. Если кто-то уже собрал артефакт с такими же входами — зачем повторять работу? Билд-система считает хэш от исходников, зависимостей и конфигурации, ходит в удалённый кэш, и при совпадении скачивает готовое вместо очередной сборки.
Bazel реализует эту идею глубже всех. Сборка моделируется как граф задач, каждая задача — чистая функция от входов. Свойство герметичности (hermeticity) гарантирует, что закэшированный артефакт совпадает с тем, что получился бы при пересборке. Remote cache — это HTTP-сервер, либо Google Cloud Storage, либо S3, куда Bazel пишет и откуда читает. Попадание в кэш — миллисекунды на скачивание вместо секунд или минут компиляции.
Turborepo делает то же самое для JavaScript/TypeScript-монорепозиториев. Сам инструмент open-source, ограничений на использование нет. Managed remote cache через Vercel из России недоступен, но это решаемо: поднимается свой cache-сервер. Пакет turborepo-remote-cache на npm запускает совместимый HTTP-сервер за минуты, а хранилищем берёт любой S3-совместимый бэкенд (MinIO, Yandex Object Storage, Selectel). Альтернатива — ducktors/turborepo-remote-cache с поддержкой Local, S3 и Google Cloud Storage. После настройки каждый turbo run build сначала смотрит в remote cache, и если пакет уже собран кем-то с теми же исходниками — результат скачивается.
Nx от Nrwl тоже умеет в remote caching и в distributed task execution — распределение задач между CI-агентами. Managed-вариант Nx Cloud из России может быть недоступен, но есть self-hosted remote cache для on-premise и air-gapped окружений. Nx Cloud разворачивается у себя (Kubernetes или Docker), функционал тот же: remote caching, распределённые задачи, визуализация графа. Связка кэширования с параллелизацией особенно хорошо ложится на крупные монорепы.
Gradle Build Cache — для JVM-проектов. Gradle хэширует входы каждой задачи (исходники, classpath, версию JDK, параметры компиляции) и проверяет remote cache до выполнения. По собственным данным Gradle, включение build cache режет время повторных сборок на 40–90% в зависимости от структуры проекта.
Параллелизация
Сборка как последовательная цепочка (скомпилируй, потом линтинг, потом юнит-тесты, потом интеграционные, потом Docker-образ) ускоряется тривиально: независимые шаги запускаются одновременно. Линтинг не зависит от компиляции — гоните параллельно. Юнит-тесты разных модулей друг с другом не связаны — бейте на группы и пускайте разом.
GitHub Actions поддерживает параллельные jobs и matrix builds: задаёте матрицу (три версии Node.js × два модуля) и получаете шесть параллельных jobs. У GitLab CI похожий механизм через parallel: и DAG-пайплайны (needs: вместо stages:), задачи стартуют по готовности зависимостей, не дожидаясь завершения целого этапа.
Для тестов эффект максимальный: 2000 тестов, идущие 20 минут последовательно, на четырёх runner-ах укладываются в 5. Knapsack Pro, Launchable и подобные сервисы распределяют тесты по runner-ам с учётом длительности, а не наивным делением по файлам — чтобы все runner-ы заканчивали в одно время.
Инкрементальные сборки
Зачем пересобирать весь проект, если изменился один файл? Инкрементальные сборки трогают только модули, зависящие от изменений.
В монорепозиторном мире Nx и Turborepo умеют считать «affected»-пакеты — те, что задело конкретное изменение. Команда nx affected:test гоняет тесты не по всем 200 пакетам, а по тем, чьи зависимости затронуты текущим diff-ом. На крупной монорепе это сокращает количество задач в CI на 80–95%.
Bazel идёт дальше — у него граф зависимостей работает на уровне файлов, а не пакетов. Изменение одного .java-файла триггерит пересборку и перепрогон тестов ровно для тех целей (targets), которые от него зависят. Ни одной лишней задачи.
Remote execution
Remote execution — следующий шаг после remote caching. Задачи сборки выполняются не на CI-сервере и не на ноутбуке разработчика, а на кластере мощных машин, параллельно.
Bazel Remote Execution — самый зрелый вариант. Поднимается кластер worker-ов (или берётся managed-сервис), и Bazel сам распределяет задачи. Проект, собирающийся на ноутбуке за 20 минут, на сотне worker-ов укладывается в 2: независимые задачи разносятся по машинам и идут одновременно.
EngFlow, BuildBuddy и Depot — managed-сервисы для Bazel remote execution, избавляющие от своей инфраструктуры. Минус — сложность настройки и стоимость, оправданная только для крупных монореп с сотнями инженеров.
Docker layer caching
Если CI собирает Docker-образы, а Dockerfile устроен так, что каждая сборка стартует с npm install или pip install — зависимости пересобираются при любом изменении кода. Docker layer caching переиспользует неизменившиеся слои.
Правило простое: часто меняющиеся файлы (код) — в конце Dockerfile, редко меняющиеся (зависимости) — в начале. Сначала копируйте package.json и package-lock.json, запускайте npm install, только потом несите исходники. GitHub Actions поддерживает Docker layer caching через docker/build-push-action с параметром cache-from.
С чего начать
CI идёт дольше 10 минут — начинайте с профилирования. Какие шаги съедают больше всего? Чаще всего ответ — тесты или сборка зависимостей.
Тесты — параллелизация. Бейте на группы и пускайте по нескольким jobs.
Сборка — кэширование. Gradle Build Cache включается одной строкой в gradle.properties. Turborepo remote cache на self-hosted сервере поднимается за полчаса.
Docker — оптимизированный Dockerfile плюс layer caching.
И измерения. Время каждого прогона CI пишется в метрики, тренды строятся, на рост ставятся алерты. Кодовая база растёт, тестов прибавляется, и без управляемого процесса время CI медленно, но уверенно ползёт вверх.