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

Производительность пайплайна: кэш, параллелизм, fail-fast

Сделай один пайплайн быстрым и дешёвым. Кэшируй зависимости по хэшу lock-файла с фоллбэком restore-keys, гони дешёвые проверки первыми за needs, распараллеливай только там, где это окупается, и держи flaky-тесты в карантине, чтобы параллелизм не покупал простой раннеров.

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

Пул-реквест — в одну строку: переименованная переменная. CI крутится 35 минут — установка, сборка, юнит-тесты, потом полный Playwright e2e по трём браузерам — и затем падает. Ошибка — опечатка: lenght вместо length, ровно то, что typecheck ловит за две секунды. Но typecheck был шагом внутри e2e-джобы, после старта браузеров, поэтому самый дешёвый и самый верный провал ждал за самой медленной и самой нестабильной работой пайплайна. Помножь на сорок пушей в день — и команда жжёт часы человеческого ожидания и сотни раннер-минут ради того, что двухсекундная джоба могла сказать первой.

Кэш: правильный ключ — это хэш lock-файла

Столкнулся с медленным пайплайном и не знаешь, с чего начать? Ответ почти всегда один: закэшируй детерминированное и поставь быстрые проверки первыми. Вот как устроена каждая из этих частей.

Каждая CI-джоба стартует на чистом раннере, поэтому без кэша она переделывает каждый дорогой детерминированный шаг — прежде всего установку зависимостей — с нуля на каждом запуске. Кэш это чинит, но только если правильный его ключ. Ключ — это строка; кэш попадает, когда эта точная строка уже существует, иначе промахивается, и в конце успешной джобы сохраняется новый кэш. Значит, ключ обязан меняться ровно тогда, когда должно меняться содержимое, и никогда иначе.

Для зависимостей вход, определяющий node_modules, — это lock-файл, поэтому хэшируй его через hashFiles. Добавь restore-keys как упорядоченный список префиксов: на промахе GitHub идёт по ним по порядку и восстанавливает самый свежий кэш, чей ключ начинается с префикса, — частичное попадание, так что npm ci доустанавливает только дельту, а не все 1200 пакетов по сети.

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node22-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node22-
      ${{ runner.os }}-

Стоит различать три слоя кэша, каждый ключуется так же: кэш зависимостей (~/.npm, ~/.cache/pip, кэш Go-модулей — часто автоматом через cache: npm у setup-node), кэш сборки (инкрементальный вывод компилятора, удалённый кэш Turborepo/Nx) и кэш слоёв Docker (type=gha с buildx). Антипаттерн, убивающий все три, один и тот же: волатильный ключ. Запеки в ключ таймстамп или ${{ github.run_id }} — и он уникален на каждом запуске, поэтому никогда не попадает: кэш молча сохраняется и не читается ни разу, а ты вечно платишь полное время установки, веря, что у тебя есть кэш.

Fail-fast: самое дешёвое и самое вероятное падение — первым

needs превращает плоский список джоб в DAG: джоба с needs: [lint] не стартует, пока lint не пройдёт успешно. Одно это ключевое слово даёт закрыть медленную дорогую работу за дешёвой — так что двухсекундный провал lint обрубает запуск ещё до того, как двадцатиминутный e2e-набор поднимет хоть один браузер. Правило порядка — самое дешёвое и самое вероятное падение первым: lint и typecheck бегут за секунды и ловят самые частые ошибки, поэтому они идут в начало DAG, а всё медленное висит на них через needs.

ПроверкаТипичное времяПорядокПотрачено на PR с опечаткой
lint / typecheck~2–15 спервой (гейт)находит за секунды
юнит-тесты~1–3 минneeds: lintне бегут — пропущены
e2e (3 браузера)~15–25 минneeds: [unit, build]не бегут — пропущены
плоско (без needs)~25 минвсё разомплатит полный e2e, потом падает на опечатке
jobs:
  lint:                       # ~10 с — дешёвый гейт
    runs-on: ubuntu-latest
    steps: [{ uses: actions/checkout@v4 }, { run: npm run lint && npm run typecheck }]

  unit:
    needs: lint               # не стартует, если lint упал
    runs-on: ubuntu-latest
    steps: [{ uses: actions/checkout@v4 }, { run: npm test }]

  build:
    needs: lint               # параллельно с unit — обоим нужен только lint
    runs-on: ubuntu-latest
    steps: [{ uses: actions/checkout@v4 }, { run: npm run build }]

  e2e:
    needs: [unit, build]      # медленный набор закрыт за всем дешёвым
    strategy:
      fail-fast: true         # один шард упал → отмена остальных
      matrix:
        browser: [chromium, firefox, webkit]
    runs-on: ubuntu-latest
    steps: [{ uses: actions/checkout@v4 }, { run: npx playwright test --project=${{ matrix.browser }} }]
Почему это работает

fail-fast: true — матричный близнец упорядочивания через needs: это значение по умолчанию, и оно отменяет идущие и стоящие в очереди шарды в тот миг, когда один шард падает. На PR-проверке это и нужно — ты уже знаешь, что сборка красная, зачем платить за два других браузера до конца. Переключай на false только для ночного или релизного запуска, где нужен полный отчёт о падениях по каждой ячейке, а не самый быстрый красный.

Параллелизм и matrix: дроби работу, но fan-out не бесплатен

Когда джобы независимы — unit и build выше обоим нужен только lint — они бегут параллельно автоматически, срезая wall-clock. matrix — компактный способ размножить одну джобу по списку (браузеры, версии Node, ОС), порождая по джобе на ячейку. Подвох — стоимость. GitHub считает минуты каждой джобы независимо и округляет каждую вверх до целой минуты, так что матрица 3 браузера × 2 ОС — это шесть отдельных джоб, каждая платит свой checkout, установку и восстановление кэша. Параллелизм меняет оплачиваемые минуты на латентность: пять шардов по минуте финишируют за минуту wall-clock, но в счёте — пять минут. Распараллеливай там, где выигрыш по латентности реален; ограничивай разрастание fan-out через max-parallel.

Стоимость: минуты, размер раннера и простой

Прежде чем брать раннер побольше или раздувать матрицу, спроси себя: эта джоба реально ускорится, или дополнительные ядра просто будут простаивать и увеличивать счёт?

Раннер-минуты — это счёт. Linux — дешёвая база; Windows стоит примерно 2× за минуту, а macOS примерно 10×, так что неосторожная macOS-матрица — кратчайший путь к сюрпризу в инвойсе. Ещё два рычага режут в обе стороны. Раннер побольше (больше vCPU) заканчивает CPU-bound джобу быстрее, но стоит пропорционально дороже за минуту — окупается, только если джоба реально параллелится по ядрам; 16-ядерный раннер на однопоточной сборке просто платит 8× ни за что и простаивает. И время восстановления кэша не бесплатно: вытянуть кэш в несколько сотен МБ по сети может стоить дороже, чем пересборка, которую он экономит, — поэтому кэшируй медленно-производимое (зависимости, скомпилированный вывод), а не всё подряд.

Викторина

Ключ кэша воркфлоу — `build-${{ github.run_id }}`. Сборки по-прежнему медленные, и кэш будто никогда не помогает. Почему?

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

Wall-clock 35-минутного пайплайна забивает однопоточный `npm run build` плюс `npm ci`, переустанавливающий 1200 пакетов каждый запуск. Хочешь наибольший срез за наименьшую добавку оплачиваемой стоимости. Какой ход первым?

Расставь шаги по порядку

Упорядочь эти CI-джобы в fail-fast DAG — самое дешёвое и самое вероятное падение первым, самое медленное закрыто последним:

  1. 1 lint + typecheck — секунды, ловит самые частые ошибки (гейт)
  2. 2 юнит-тесты — минута-две, needs: lint
  3. 3 сборка — параллельно с unit, needs: lint
  4. 4 e2e-матрица (3 браузера) — 15–25 мин, needs: [unit, build], fail-fast: true
  5. 5 деплой — только на зелёном, needs: e2e
lesson.inset.note

Карантин flaky-тестов (повтор из юнита про тестирование): тест, который на том же коде то проходит, то падает, разъедает доверие и, хуже того, заминается авто-ретраями, что могут спрятать настоящую регрессию. Не давай известному flaky-тесту гейтить пайплайн. Помечай его, выноси в отдельную неблокирующую джобу (или ставь test.fixme/continue-on-error) и веди в списке на починку — чтобы зелёная галочка оставалась осмысленной, а реальный провал не был «переретраен».

Вспомните перед уходом
  1. 01
    Почему ключ кэша вроде `build-${{ github.run_id }}` делает кэш бесполезным, и какой ключ плюс фоллбэк верный?
  2. 02
    Объясни, как `needs` и fail-fast режут время обратной связи, и в чём ловушка стоимости при размножении всего в матрицу.
Итог

Быстрый и дешёвый пайплайн рождается из трёх ходов, наложенных вместе. Первое: кэшируй дорогие детерминированные шаги по ключу, который есть хэш lock-файла — hashFiles('**/package-lock.json') — с упорядоченными префиксами restore-keys, чтобы изменённый lock-файл всё равно восстанавливал частичный кэш и доустанавливал только дельту; роковая ошибка — волатильный ключ (таймстамп или run_id), уникальный на каждом запуске, поэтому он никогда не попадает, и ты платишь полное время установки, веря, что закэширован. Три слоя кэша — зависимостей, сборки и слоёв Docker — подчиняются одному правилу ключа. Второе: упорядочивай джобы — самое дешёвое и самое вероятное падение первым: needs строит DAG, так что двухсекундный провал lint/typecheck обрубает запуск до старта двадцатиминутного e2e, а матричный fail-fast: true отменяет остальные шарды, как только один краснеет. Третье: уважай стоимость: параллелизм и matrix режут латентность, но каждая джоба платит свои округлённые вверх минуты, раннеры Windows и macOS стоят примерно 2× и 10× Linux, а раннер побольше окупается лишь на реально многоядерной работе — иначе простаивает, платя больше. Держи известные flaky-тесты в неблокирующей джобе, чтобы авто-ретраи не прятали реальную регрессию и зелёная галочка продолжала что-то значить. Теперь, когда смотришь на медленный пайплайн, первые два вопроса: меняется ли ключ при смене lock-файла и стоит ли lint перед e2e?

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.