open atlas
↑ К треку
CI/CD-пайплайны CICD · 06 · 01

Monorepo CI: affected-сборки и remote-кэширование

В большом монорепо «собирать и тестировать всё на каждый PR» перестаёт масштабироваться. Запускай только затронутый diff'ом набор, восстанавливай выходы из remote-кэша по хэшу и шарди остаток — но ключ кэша, упустивший вход, отгрузит устаревшую сборку.

CICD Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Однострочная правка текста в docs-пакете открывает PR. CI стартует, и через сорок минут становится зелёным — пересобрав и протестировав все 180 проектов монорепо, включая три сервиса и мобильное приложение, которые ничего не импортируют из тронутого тобой пакета. Пайплайн делает это на каждый PR. Команда тихо смирилась с сорокаминутной петлёй обратной связи как с ценой монорепо, а один контрибьютор начал паковать несвязанные изменения в один PR, чтобы амортизировать ожидание. Ничего не сломано. Всё просто медленно. Сборка отвечает на вопрос, который никто не задавал: что могло измениться? — тогда как единственный важный вопрос: что реально изменилось?

Affected: от git diff к подмножеству графа задач

К концу урока ты поймёшь, почему 40-минутная сборка случается даже на однострочном PR — и какие три рычага дают CI масштабироваться с diff’ом, а не с репозиторием.

«Собирать и тестировать всё на каждый PR» — корректно и, в большом монорепо, разорительно. Стоимость растёт с размером репозитория, тогда как ценность любого отдельного PR зависит лишь от того, что он изменил. Affected-сборки разрывают эту связь. Инструменты — Nx, Turborepo, Bazel — моделируют репозиторий не как плоский список пакетов, а как граф проектов: узлы — проекты, рёбра — отношения import/зависимостей. Над ним лежит граф задач: build, test, lint для каждого проекта, связанные так, что build проекта зависит от build его зависимостей.

Вычисление affected-набора — два шага. Сначала инструмент спрашивает у git, какие файлы изменились между двумя коммитами — --base и --head — и сопоставляет каждый тронутый файл с проектом, которому он принадлежит. Это даёт напрямую изменённые проекты. Затем он обходит граф проектов в обратную сторону: любой проект, который транзитивно зависит от изменённого, тоже затронут, потому что изменение выше по графу может его сломать. Объединение — это affected-набор, и инструмент запускает запрошенные задачи ровно для этого подмножества.

# В CI: base = последний успешный SHA main, head = вершина PR.
# nrwl/nx-set-shas экспортирует их, чтобы не диффать против устаревшей точки.
nx affected -t build,test,lint --base=origin/main --head=HEAD

# Turborepo выражает ту же идею через граф пакетов + git:
turbo run build test lint --filter='...[origin/main]'

База важнее, чем кажется. Установи её на последний успешный коммит main, а не на фиксированную точку ветвления — иначе долгоживущий PR диффает против старой базы, пересчитывает огромный affected-набор, и выигрыш теряется. В CI это подключают хелпером вроде nrwl/nx-set-shas.

Remote-кэш: хэшируй входы, восстанавливай выходы

Affected говорит, что запускать. Кэш говорит, нужно ли вообще. Перед выполнением задачи инструмент считает хэш по всему, что может повлиять на выход: исходники проекта, разрешённые хэши его зависимостей, конфигурация задачи, версии инструментов и любые объявленные переменные окружения. Этот хэш — ключ кэша. Если задача с ровно таким ключом уже выполнялась раньше — кем угодно, на любой машине — её записанный выход (dist/, результат теста, логи) скачивается и проигрывается вместо пересчёта.

Рычаг в том, что кэш общий и адресуемый по содержимому. Turborepo делит это на глобальный хэш и хэш задачи; меняется любой — задача промахивается. Коллега, уже собравший main локально, или прошлый прогон CI наполняют remote-кэш (Nx Cloud, Turborepo Remote Cache), из которого читает твой прогон. Попадание в кэш, превращающее многоминутный build в восстановление артефакта за доли секунды, — это и есть разница между CI, который масштабируется с diff’ом, и тем, что масштабируется с репозиторием.

СтратегияЧто бежит на маленьком PRВремяГлавный риск
Собирать и тестировать всёВсе 180 проектов~40 мин, каждый PRНет — просто медленно и дорого
Только affectedИзменённые + зависящие (часто 5–25%)МинутыКорневой файл метит всё как affected
Affected + remote-кэшAffected минус попадания в кэшСекунды–минутыУпущенный вход → ложное «попадание»
Почему это работает

Кэш корректен ровно настолько, насколько корректны его входы. Хэш обязан захватить каждый вход — а забывают чаще всего переменные окружения. Turborepo прямо предупреждает: значение, читаемое во время сборки, но отсутствующее в хэше, — тихий яд. Переменные делят на env/globalEnv (влияют на хэш) и passThroughEnv/globalPassThroughEnv (доступны в рантайме, но не меняют ключ). Положи NEXT_PUBLIC_API_URL не в ту корзину — и две сборки с разными API-URL делят ключ: вторая получает «попадание» и отгружает зашитый URL первой. Баг выглядит невозможным — тот же коммит, не та сборка — потому что отличавшийся вход никогда не хэшировался.

Sharding: распредели остаток по раннерам

Affected и кэширование сокращают, какие задачи бегут. Sharding сокращает, как долго бегут выжившие, запуская их параллельно на N раннерах. Классическая форма — матрица CI: разбей набор из 2000 тестов на шарды 1/4 … 4/4, дай каждому свой раннер — и сорокаминутный прогон финиширует за ~10 (четырёхкратно ≈ на 75% быстрее); маленький набор падает с 3 минут до 30 секунд. Nx Cloud и Turborepo идут дальше и распределяют сам граф задач по агентам, соблюдая порядок зависимостей, чтобы нижестоящий build ждал свой вышестоящий.

# GitHub Actions matrix: 4 параллельных тестовых шарда.
strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: npx jest --shard=${{ matrix.shard }}/4   # или: nx affected -t test --parallel

Одно правило отделяет быструю матрицу от медленной: балансируй по времени выполнения, а не по числу тестов. Четыре шарда равные по числу файлов, но перекошенные по времени, оставят три раннера простаивать, пока один молотит — самый медленный шард задаёт твоё общее время, так что выигрыш не лучше твоего худшего шарда.

Выбери лучший вариант

Монорепо на 180 проектов гоняет ~40 мин build+test на каждый PR, при этом большинство PR трогают 1–3 проекта. Выбери основную стратегию масштабирования.

Честный трейдофф: affected + remote-кэш — самый высокорычажный ход, но вся его ценность держится на том, что хэш полон, а вычисление affected узко. Упустишь вход — отгрузишь устаревшее; дашь корневому файлу раздуть affected-набор — заплатишь за настройку впустую.

Режимы отказа, стирающие выигрыш

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

Радиус поражения корневого файла. Измени tsconfig.base.json, nx.json или — классика — lock-файл, и инструмент метит всё как affected. Случай lock-файла — намеренная подстраховка: бамп зависимости мог бы затронуть любой проект, поэтому по умолчанию Nx перестраховывается и пересобирает всё. В итоге рутинное обновление package-lock.json детонирует полную сорокаминутную сборку, и команда, не понимающая почему, заключает «affected не работает». Он работает; ты задел провод глобального входа. Настраиваешь это через namedInputs, чтобы глобальными считались лишь действительно глобальные файлы — но неверно очертив glob {workspaceRoot}, ты сам воссоздаёшь радиус поражения.

Устаревшее попадание от незахэшированного входа. Разобрано выше: переменная окружения, версия инструмента или конфиг-файл, влияющие на выход, но отсутствующие в хэше. Кэш возвращает сборку, неверную для текущих входов, и поскольку попадание тихое и быстрое, оно может пройти ревью и доехать до прода. Это самый опасный отказ, потому что он не падает — он успешно отдаёт неверный ответ.

Отравление remote-кэша / доверие. Remote-кэш — это общая поверхность записи. Если недоверенный актор (PR из форка, скомпрометированный токен) может писать артефакты, он подкладывает вредоносный dist/ под ключ, который позже прочитает доверенная сборка, и твоё «попадание в кэш» запускает его код. Поэтому Turborepo подписывает артефакты через HMAC-SHA256 и проверяет их при скачивании, считая любой не прошедший подпись артефакт промахом — и поэтому доступ на запись в кэш ограничивают узко и не дают недоверенным PR наполнять кэш, который потребляют доверенные прогоны.

Викторина

PR меняет только листовой docs-пакет, но nx affected пересобирает все 180 проектов. Самая вероятная причина?

Викторина

Две сборки одного коммита дают разные бандлы, но вторая отмечена как ПОПАДАНИЕ в remote-кэш и отгружает неверный API-URL. Что пошло не так?

Вспомните перед уходом
  1. 01
    Пройди по шагам, как инструмент вычисляет affected-набор из PR, и почему устаревший --base или смена lock-файла его ломают.
  2. 02
    Что именно входит в ключ кэша задачи, и как неполный ключ порождает устаревшее «попадание», уезжающее в прод?
Итог

«Собирать и тестировать всё на каждый PR» — корректно и, после некоторого размера репозитория, неподъёмно: стоимость масштабируется с монорепо, тогда как ценность PR — лишь с его diff’ом. Affected-сборки разрывают эту связь: инструмент моделирует репозиторий как граф проектов и граф задач, читает файлы, изменившиеся между —base и —head, сопоставляет их с владеющими проектами, затем обходит граф в обратную сторону, добавляя каждого транзитивно зависящего — и запускает задачи только для этого affected-набора. Remote-кэш по хэшу содержимого (Nx Cloud, Turborepo) идёт дальше, хэшируя полные входы каждой задачи — исходники, хэши зависимостей, конфиг, версии инструментов, переменные окружения — в ключ и восстанавливая прежний выход вместо пересчёта, общий для CI и машин разработчиков, так что многоминутная сборка становится восстановлением за доли секунды. Sharding затем распараллеливает выживших по N раннерам (балансируй по времени, а не по числу тестов) примерно на 75% при четырёхкратном. Три отказа стирают выигрыш: корневой файл (lock, tsconfig.base.json), метящий всё как affected — для lock это по замыслу, настраивается через namedInputs; вход, упущенный в хэше, превращающий «попадание» в тихо устаревшую сборку, успешно отдающую неверный ответ; и отравление remote-кэша, из-за которого артефакты подписываются HMAC-SHA256 и проверяются при скачивании, а доступ на запись держится узким. Масштабируйся с изменением, а не с репозиторием — и держи хэш полным, а базу узкой, потому что именно там живёт корректность. Теперь, когда увидишь PR, пересобирающий всё, первый вопрос — какой глобальный вход задет: lock-файл, корневой конфиг или неверно очерченный glob?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.