Производительность пайплайна: кэш, параллелизм, fail-fast
Сделай один пайплайн быстрым и дешёвым. Кэшируй зависимости по хэшу lock-файла с фоллбэком restore-keys, гони дешёвые проверки первыми за needs, распараллеливай только там, где это окупается, и держи flaky-тесты в карантине, чтобы параллелизм не покупал простой раннеров.
Пул-реквест — в одну строку: переименованная переменная. 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 lint + typecheck — секунды, ловит самые частые ошибки (гейт)
- 2 юнит-тесты — минута-две, needs: lint
- 3 сборка — параллельно с unit, needs: lint
- 4 e2e-матрица (3 браузера) — 15–25 мин, needs: [unit, build], fail-fast: true
- 5 деплой — только на зелёном, needs: e2e
▸lesson.inset.note
Карантин flaky-тестов (повтор из юнита про тестирование): тест, который на том же коде то проходит, то падает, разъедает доверие и, хуже того, заминается авто-ретраями, что могут спрятать настоящую регрессию. Не давай известному flaky-тесту гейтить пайплайн. Помечай его, выноси в отдельную неблокирующую джобу (или ставь test.fixme/continue-on-error) и веди в списке на починку — чтобы зелёная галочка оставалась осмысленной, а реальный провал не был «переретраен».
- 01Почему ключ кэша вроде `build-${{ github.run_id }}` делает кэш бесполезным, и какой ключ плюс фоллбэк верный?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.