Перейти к содержимому

Понемногу — но каждый день.

CI с нуля на Nx affected: от «пайплайна нет» до 9 минут на все staging-приложения

6 мин чтения

На этой странице
Коротко
В монорепозитории с десятками приложений собирать всё на каждый коммит — долго и бессмысленно: большинство приложений изменение не задело. Nx умеет посчитать, что затронуто, — nx affected. Сложность не в команде, а в вопросе «затронуто относительно чего?». Ниже — путь от ручного деплоя скриптами до пайплайна, который проверяет MR и собирает только нужное, и четыре грабли, на которые я наступил по дороге.

Точка ноль: деплой руками

Сначала пайплайна не было. Был shell-скрипт: собрать приложение локально и скопировать файлы на сервер. Проблемы очевидные:

  • собирается то, что лежит у тебя на диске, а не то, что в репозитории;
  • никаких проверок до деплоя;
  • деплой может сделать только тот, у кого есть доступ и скрипт.

Первый пайплайн повторял скрипт: установить зависимости → собрать все приложения → скопировать. Уже лучше, но собирались все приложения на каждый пуш.

Идея affected, если по-простому

Nx знает граф проекта: какое приложение от каких библиотек зависит. Если ты поменял файл в библиотеке users, Nx идёт по графу «вверх» и находит все приложения, которые эту библиотеку используют. Остальные не затронуты, их можно не собирать и не тестировать.

npx nx affected -t build --base=<коммит> --head=HEAD

Nx берёт изменённые файлы между base и head (по сути, git diff), находит их проекты, добавляет всё, что от них зависит, и запускает build только для этого набора.

Это обычный обход графа. Если библиотеки — вершины, а импорты — рёбра, то «затронутые» — все вершины, из которых по рёбрам можно дойти до изменённой.

Главный вопрос: base — это что?

Команда простая, а ошибиться с base — легко. Для разных задач ответ разный.

Для проверки MR: точка ответвления

Для merge request база — коммит, от которого ветка ответвилась от целевой (merge-base). В GitLab это переменная CI_MERGE_REQUEST_DIFF_BASE_SHA.

Важная деталь: по умолчанию GitLab клонирует репозиторий с ограниченной глубиной истории. Без полной истории git не найдёт merge-base, и affected посчитает неправильно. Поэтому:

variables:
  GIT_DEPTH: "0"   # полная история

Для сборки staging: последний задеплоенный коммит

Первая мысль — сравнивать с предыдущим коммитом ветки (CI_COMMIT_BEFORE_SHA). Это ошибка, и вот почему.

Пайплайны на staging у нас прерываемые (interruptible: true): если пришёл новый пуш, старая сборка отменяется — её всё равно перекроет новая. Представь:

  1. Коммит A меняет приложение X. Пайплайн A стартует.
  2. Через минуту коммит B меняет приложение Y. Пайплайн A отменён, стартует B.
  3. B сравнивает себя с A и собирает только Y.

Изменения X не попали ни в одну сборку. На сервере старая версия X, и никто не знает почему.

Правильная база — последний коммит, который реально дошёл до сервера. Его запоминает отдельный job после успешного деплоя:

# учебный пример
mark-deployed:
  stage: mark_deployed
  needs: [deploy]
  cache:
    key: deployed-sha
    paths: [.ci-cache/]
    policy: push
  script:
    - mkdir -p .ci-cache
    - echo "$CI_COMMIT_SHA" > .ci-cache/deployed-sha

Сборка читает этот файл и считает affected от него. Пишется он только после успешного деплоя, поэтому изменения упавших и отменённых пайплайнов автоматически попадают в следующую сборку.

И обязательно — запасной выход: собрать всё, если:

  • базы нет (первый запуск, кэш очищен);
  • такого коммита нет в истории (например, после force-push);
  • изменился сам CI или список приложений;
  • кто-то запустил пайплайн с FULL_BUILD=true.

Лучше иногда собрать лишнее, чем однажды не собрать нужное.

Проверка MR: только то, что изменилось

Сборка — это половина. Вторая половина — не пускать ошибки в staging. Стадия verify на каждый MR, всё по affected:

JobЧто делает
lintESLint только по изменённым файлам + проверка стилей
typecheckкомпилятор Angular без вывода (ngc --noEmit): типы и шаблоны
testunit-тесты затронутых проектов, отчёт JUnit и покрытие в виджет MR

Три job-а идут параллельно. Старые нарушения линтера записаны в файл-базлайн и не мешают; падает только новое.

И то же самое одной командой локально (pnpm verify). Если проверка есть только в CI, человек узнаёт об ошибке через десять минут. Если она есть и локально — до пуша.

Грабли 1. Две сборки параллельно — OOM

Хотелось ускорить: собирать по два приложения одновременно (--parallel=2). Сборки начали падать со странной ошибкой esbuild: all goroutines are asleep - deadlock!.

Ошибка вводит в заблуждение. Настоящая причина — память. Две параллельные сборки Angular занимали около 13 ГБ на хосте с 15,5 ГБ. Ядро Linux (OOM killer) убивало процесс сборки, а дочерний процесс esbuild, оставшись без родителя, падал с этим сообщением.

Урок: при странной ошибке в CI сначала смотри ресурсы — память, диск, время. Решение — --parallel=1. Медленнее на одну сборку, но affected и так уменьшил их число.

Грабли 2. Кэш, который прятал тесты

Nx умеет кэшировать результаты: если входы задачи не менялись, он не запускает её, а восстанавливает результат из кэша (в том числе удалённого, Nx Cloud).

В отчёте MR вдруг стало 237 тестов из 944. Тесты никто не удалял. Причина: из кэша восстанавливаются только объявленные выходы задачи (outputs). Файл junit.xml для отчёта в них не входил. Проект из кэша — «зелёный», но без отчёта, и GitLab его просто не показывает.

Решение для тестов в MR — --skip-nx-cache. Для отчёта нужен честный прогон. Альтернатива — добавить junit.xml в outputs задачи.

Урок: кэш ускоряет, но всё, что ты потом читаешь из файлов, должно быть в outputs. Иначе оно тихо исчезает.

Грабли 3. Собирать то, что никто не использует

Из 17 приложений в staging реально тестировали 7. Остальные собирались просто потому, что были в списке. Отключили лишние в карте деплоя — минус больше половины работы без единой строки умного кода.

Самая быстрая сборка — та, которой нет.

Грабли 4. Правки CI через веб-редактор

Первую версию пайплайна я правил прямо в веб-редакторе GitLab: в истории за неделю осталось около двадцати пяти коммитов «Update .gitlab-ci.yml file». Каждый — запустить, подождать, посмотреть, поправить.

Что делать иначе:

  • писать CI в ветке и проверять в MR;
  • проверять YAML встроенным линтером GitLab (CI Lint) до пуша;
  • выносить логику из YAML в скрипты (node tools/scripts/...), которые можно запустить и отладить локально.

Мелочи, которые тоже экономят минуты

  • Кэш стора pnpm по ключу от pnpm-lock.yaml: зависимости не скачиваются каждый раз заново.
  • Один job пишет кэш, остальные только читают (policy: pull): иначе три параллельных job-а по очереди архивируют и выгружают одно и то же.
  • Source maps загружаются в систему мониторинга ошибок в том же job, что и сборка, и удаляются перед деплоем: на сервер они не нужны, артефакт меньше.

Итог

БылоСтало
ручной деплой скриптомпайплайн на каждый пуш
нет проверокlint + typecheck + тесты на каждый MR
сборка всех приложенийтолько затронутые с последнего деплоя
? минут~9 минут на все staging-приложения

Главное, что я вынес: nx affected — это не про команду, а про правильный ответ на вопрос «относительно чего?». И про запасной выход «собери всё», когда ответа нет.

Связанные посты: nx-monorepo-from-copies, tailwind-v3-to-v4-monorepo, custom-eslint-rules.