Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Скорость CI: кеширование зависимостей и слоёв сборки

Быстрый CI: кеш зависимостей, слоёв и тестов вместо 20-минутных сборок

Как ускорить пайплайн: кеширование pip/npm и docker-слоёв, параллелизация тестов, разделение джоб по критичности и измерение времени сборки как метрики.

Прямой ответ

Долгий CI — налог на каждый коммит (20-минутная сборка × 30 пушей в день команды — часы ожидания, сорванный flow, соблазн «смержу без зелёного» — качество страдает напрямую); ускорение — инженерная задача с измеримым результатом, и начинается она с замера (время по шагам в логах джоб — GitHub Actions-тайминги/--durations=20 у pytest — топ-20 самых долгих тестов — обычно 80% времени в 20% тестов — оптимизация вслепую без этого — лотерея). Четыре рычага по убыванию эффекта: (1) кеширование зависимостей — actions/cache с ключом по лок-файлу (key: pip-${{ hashFiles('**/requirements.lock') }} — лок-файл не менялся — кеш попадание — установка секунд вместо минут; npm — cache с hashFiles package-lock.json; правило: ключ по контенту лока, restore-keys по префиксу — частичное попадание лучше промаха; «закешивать venv целиком» — работает, но хрупко (пути/версии) — кеш pip-каталога (~/.cache/pip) надёжнее); (2) docker-кеш слоёв — buildx cache (registry-кеш --cache-from type=registry,ref=.../ci-cache — self-hosted/облачные раннеры без локального состояния — registry-кеш между прогонами — docker-оптимизация: порядок слоёв «зависимости раньше кода» — COPY requirements → pip install → COPY . — код меняется, слой зависимостей жив); (3) параллелизация — тесты по shard'ам (Actions-матрица: matrix: shard [0..3] + pytest --splits или pytest-xdist внутри джобы — 8 минут → 2.5; балансировка шардов по истории длительностей — неравномерный шард держит всю матрицу), независимые джобы — параллельно (lint/test/build — не последовательно — needs только где реальная зависимость); (4) разделение по критичности — быстрые проверки на каждый пуш (lint+юниты 3 минуты — обратная связь мгновенная), тяжёлые — на merge/ночь (интеграционные с контейнерами (testcontainers), e2e (playwright), нагрузочные — locust) — «всё на каждый пуш» — расточительство; плюс мелочи, дающие в сумме минуты: checkout с fetch-depth=1, concurrency-группы (новый пуш отменяет устаревший прогон ветки — concurrency: group: ${{ github.ref }}, cancel-in-progress: true — оплата за актуальное, не за мёртвое), тёплые раннеры (self-hosted с постоянным кешем — раннер-практика — локальные кеши не пересоздаются).

Кеширование: зависимости и артефакты

Механика actions/cache: key — точное совпадение → полный hit; restore-keys — префиксное совпадение → partial (старый кеш лучше ничего — pip доустановит дельту); paths — что кешировать (~/.cache/pip, node_modules (npm ci воспроизводима — безопасно), .venv (с оговорками — пути Python абсолютны в venv — переносимость между раннерами того же ОС ок)); ограничения — 10 ГБ на репозиторий (LRU-эвикшен — кеш больших монорепо вытесняется — дробить ключи по пакетам — pip-backend-, pip-frontend- — независимые циклы жизни), кеш между ветками (общий по restore-keys — фича-ветка наследует main-кеш — задумано и полезно). Инвалидация-грабли: ключ только по имени (key: pip-cache) — вечный кеш с гнилыми зависимостями («обновили requirements, CI собрал старое» — классика) — контент-хеш лока обязателен; лок-файла нет (requirements.txt без пина — «зависимости те же по имени, разные по содержанию» — кеш попадание, результат разный — пиннинг-дисциплина — requirements.lock/uv-lock — условие корректного кеша); docker registry-кеш: buildx --cache-to/--cache-from (тип registry — слои в отдельный тег реестра — :ci-cache — push/pull слоёв между прогонами; gha-тип — кеш Actions для buildx — интеграция удобнее, лимиты те же 10 ГБ); порядок Dockerfile-слоёв решает всё (COPY . раньше pip install — любое изменение кода инвалидирует слой установки — попаданий в кеш ноль — «зависимости → установка → код» — канон — multistage); BuildKit inline-кеш (--cache-from с типом registry требует push слоёв — CI-джоба сборки пушит кеш-тег — стоимость трафика против выигрыша — на частых сборках окупается). Артефакты между джобами — actions/upload/download-artifact (собранное в build — переиспользуется в deploy/test — не пересобирать; артефакт ≠ кеш (артефакт — результат прогона, кеш — ускоритель) — не смешивать семантику).

Тесты: шардирование, селективность, параллелизм

Внутри джобы: pytest-xdist (-n auto — процессы по ядрам — GIL-контекст — CPU-bound тесты ускоряются кратно; требования — независимость тестов (общие ресурсы/порты/файлы — гонки — изоляция фикстурами — дисциплина); флаппинг после -n — признак скрытых зависимостей между тестами — лечить тесты, не отключать параллелизм). Между джобами (шардирование): матрица ×4 + разделение набора (по длительностям из прошлого прогона — плагин pytest-split / k6-подходы — равномерные шарды; без истории — по алфавиту/файлам — неравномерно, но работает); баланс: шард = общее_время/число_раннеров + overhead джобы (~30–60 с на джобу — setup/checkout — чрезмерное дробление (16 шардов по 40 с) — overhead съест выигрыш — оптимум 4–8 для средних наборов). Селективный запуск — только затронутые тесты (pytest-testmon/detect-changes по diff — «изменил модуль X — его тесты» — радикальное ускорение на больших монорепо; цена — риск пропустить кросс-влияние (testmon строит граф зависимостей — надёжен, но «полный прогон на merge» остаётся страховкой — селективность на пуш, полнота на merge); самые медленные тесты — отдельная работа: интеграционные с БД — контейнеры переиспользуются session-фикстурами (БД на сессию, транзакционный откат на тест — фабрики), сетевые моки вместо реальных API (мок-паттерны), sleep-и в тестах — на фиксированные условия/поллинги (sleep(10) «чтобы успело» — 10 с × каждый прогон — тест-гигиена); --durations=20 в CI-отчёте — постоянная видимость топ-медленных (деградация нового теста видна сразу).

Практический сценарий: CI с 22 минут до 4

Исходник: Python-сервис, один workflow на пуш: установка зависимостей 4 мин (каждый раз с нуля), линт 1 мин, тесты 14 мин (один процесс, 900 тестов), docker build 3 мин — итого 22 мин; команда 6 человек, ~40 пушей в день — 15 часов машинного времени и сорванный flow. Замер (неделя метрик из логов): топ-тесты — 18 интеграционных с testcontainers (9 минут суммарно — половина набора), 12 сетевых с реальными таймаутами (2.5 мин), sleep'ы в 9 тестах (суммарно 100 с). Оптимизации (по рычагам): (1) кеш pip по hashFiles(requirements.lock) — установка 4 мин → 25 с (hit-rate 92% — лок меняется редко — пиннинг уже был); (2) concurrency cancel-in-progress (устаревшие прогоны ветки гасятся — экономия ~30% машинного времени — побочный эффект); (3) тесты: session-фикстура контейнера БД (9 мин → 3.5 — один контейнер на сессию, транзакционный откат), моки вместо реальных внешних API (2.5 → 0.4 мин — таймауты исчезли), sleep → wait-условия (−90 с), затем xdist -n 4 внутри джобы (6 мин → 2 мин); (4) docker: buildx + gha-кеш слоёв + перестановка COPY (зависимости раньше кода) — 3 мин → 45 с на типичном пуше (слой зависимостей переживает); (5) структура: быстрый контур на пуш — lint+юниты+build (итог 4 мин), тяжёлый — на merge в main (полные интеграционные+e2e — 12 мин — но раз в день-два, не 40 раз); (6) метрика времени в дашборд (Grafana на CI-вебхуки/статусы — тренд времени сборки виден — регресс «новый тест добавил 3 минуты» заметен в день введения — мониторинг самого CI). Итог: пуш-контур 22→4 мин (×5.5), машинное время −70% (кеш+конкурентность+селективность контуров), «смержу без проверки» исчезло (4 минуты ждать — не 22 — культура вернулась сама); находки-бонусы: xdist вскрыл 4 теста с гонками за общий файл (починены — скрытый баг тестов), топ-медленный тест оказался с реальным сном на внешний сервис (замокан — и задокументирован контракт — моки); следующий шаг в плане — sharding на росте набора (когда юниты перерастут 5 минут — матрица готова в шаблоне).

FAQ

Кешировать node_modules или кеш npm/pnpm? Зависит от менеджера: npm ci + кеш ~/.npm (кеш пакетов — установка каждый раз, но без сети — надежно); node_modules целиком — быстрее (установка пропускается), но хрупко (нативные модули/пути/версии node — при смене раннера/OS — порча — ключ по node-версии+локу обязателен); pnpm store-кеш (content-addressed store — жёсткие ссылки — самый экономный паттерн — pnpm задуман под CI-кеш); yarn — аналогично npm (berry — кеш каталога); правило — кеш менеджера (store) надёжнее кеша результата (node_modules) — разница секунды, надёжность выше.

Почему docker build не попадает в кеш, хотя «ничего не менялось»? Чек-лист: порядок слоёв (COPY . раньше установки зависимостей — любой код инвалидирует установку — переставить), изменчивый контекст (build arg с датой/commit sha в раннем слое — каждый билд уникален — sha в LABEL в конце, не ARG в начале), разные базовые образы/теги (FROM python:3.12 — digest меняется при republish — пин по digest для стабильности), кеш не подключён (buildx без --cache-from всегда с нуля — локальный docker-кеш ≠ CI-кеш на облачных раннерах), .dockerignore отсутствует (контекст гигабайтами — .git/node_modules внутри — медленный upload контекста и инвалидации — docker-гигиена).

Self-hosted раннеры против GitHub-hosted для скорости? Self-hosted: постоянные локальные кеши (pip/npm/docker-слои живут на машине — hit-rate 100%, нет upload/download кеша), мощнее железо за те же деньги (свой сервер 16 ядер против 2–4 hosted) — выигрыш ×2–4 на тяжёлых сборках (раннер-практика); цена — обслуживание (обновления/безопасность/очистка диска — раннер-машина как питомец), конкуренция джоб (одна машина — очередь против эластичности hosted); hosted: ноль обслуживания, эластичность, изоляция (чистая машина на джобу — воспроизводимость), но кеши через actions/cache (сеть/лимиты) и слабое железо в базе; практика — гибрид: тяжёлые сборки на self-hosted, лёгкие/изолированные на hosted; безопасность — self-hosted на приватных репо (публичные + fork PR — RCE-риск на вашем железе — известная уязвимость модели).

Стоит ли платить за большие раннеры вместо оптимизации? Порядок действий: сначала замер и дешёвые рычага (кеш/concurrency/параллелизм — статьи выше — обычно ×2–5 без копейки) — они же улучшают и на больших раннерах (оптимизация и железо умножаются, не заменяются); большие раннеры (GitHub larger runners ×2–8 ядер) — когда оптимизации уперлись и время всё ещё болит (сборка 15 мин → 5 на ×4 — реальное решение за деньги); «купить раннер вместо починки сна в тестах» — деньги на маскировку (техдолг — долг останется и вырастет); метрика для решения: стоимость машинного времени + стоимость ожидания команды против цены раннеров — обычно починка окупается, железо — добивает остаток.

Источники: документация GitHub Actions (caching), Docker build caching, pytest usage — ссылки в шапке.