Hard Авторский

Оптимизация Build Times

Remote caching, параллелизация и другие способы ускорить сборку

Почему 30-минутная сборка — это дорого

В главе про feedback loops мы установили, что время CI напрямую влияет на продуктивность: после 10–15 минут ожидания разработчик переключается на другой контекст и теряет десятки минут на возвращение. Посчитаем конкретнее. Если в команде 15 инженеров, каждый пушит 3 раза в день, и прогон CI занимает 30 минут — это 22.5 часа ожидания в день. При стоимости часа инженера в 3000–4000 рублей это 70–90 тысяч в день, 1.5–2 миллиона в месяц, около 20 миллионов в год — без учёта потерь от переключения контекста.

Компании с хорошей инженерной культурой понимают эту арифметику и инвестируют в оптимизацию build times целенаправленно. Stripe, мигрировав 300+ сервисов в монорепозиторий на Bazel с remote caching, сократил среднее время сборки с 30 минут до 5. Shopify публично описывал путь от 15-минутного CI до 5-минутного за счёт параллелизации и умного кэширования.

Remote caching: не собирать то, что уже собрали

Основная идея remote caching проста: если кто-то в команде уже собрал этот артефакт с этими входными данными — зачем собирать его повторно? Build-система вычисляет хэш от исходников, зависимостей и конфигурации сборки, и если в удалённом кэше есть артефакт с таким хэшем — скачивает готовый результат вместо повторной сборки.

Bazel реализует эту идею наиболее полно. Bazel моделирует сборку как граф задач, каждая задача — чистая функция от своих входных данных. Это свойство (герметичность, или hermeticity) позволяет Bazel гарантировать, что закэшированный артефакт идентичен тому, который был бы получен при повторной сборке. Remote cache в Bazel — это HTTP-сервер (или Google Cloud Storage, S3), куда Bazel записывает результаты и откуда их читает. При попадании в кэш задача выполняется за миллисекунды (время скачивания) вместо секунд или минут (время компиляции).

Turborepo от Vercel предлагает remote caching для JavaScript/TypeScript-монорепозиториев. Vercel предоставляет managed remote cache (бесплатный для open-source, платный для коммерческих проектов), и подключение сводится к npx turbo login && npx turbo link. После этого каждый turbo run build проверяет remote cache, и если другой инженер или CI уже собрал этот пакет с текущими исходниками — результат скачивается.

Nx от Nrwl тоже поддерживает remote caching (Nx Cloud), и кроме того умеет распределять задачи между CI-агентами — distributed task execution. Это комбинация кэширования и параллелизации, которая особенно эффективна для крупных монорепозиториев.

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 минут последовательно, разбивка на 4 параллельных 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 минут, на кластере из 100 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 — двумя командами.

Для Docker — оптимизируйте Dockerfile и включите layer caching.

И измеряйте: записывайте время каждого прогона CI, стройте тренды, ставьте алерты на рост. Кодовая база растёт, тестов становится больше, и без осознанного управления время CI ползёт вверх.