Кеш против артефактов: ускоряем сборки

Разбираем кеш, его отличие от артефактов и как им ускорять установку зависимостей.

Кеш (cache) — файлы, которые сохраняются между запусками пайплайнов для ускорения, например скачанные зависимости.

Проблема медленных зависимостей

Каждая джоба в чистом контейнере заново скачивает зависимости: npm ci, pip install, bundle install могут занимать минуты. Если на каждый запуск тянуть всё из интернета — пайплайн тормозит. Решение — кеш: сохранить папку зависимостей и переиспользовать в следующих запусках.

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

test:
  image: node:20
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
  script:
    - npm ci
    - npm test

Здесь node_modules/ кешируется, а ключ кеша считается из package-lock.json. Пока lock-файл не менялся, кеш переиспользуется; изменился — ключ другой, кеш пересоберётся.

Ключ кеша — самое важное

Ключ определяет, какой кеш брать. Варианты: фиксированная строка (один кеш на всё), $CI_COMMIT_REF_SLUG (свой кеш на ветку), или files — хеш по файлам зависимостей. Последний вариант самый правильный для пакетов: кеш инвалидируется ровно тогда, когда меняется список зависимостей. Плохой ключ ведёт либо к устаревшему кешу, либо к его постоянному пересозданию.

За выбором ключа стоит фундаментальный компромисс кеширования, известный любому, кто сталкивался с инвалидацией кеша: слишком «липкий» ключ отдаёт устаревшие данные, слишком «дёрганый» — обнуляет выгоду от кеша вовсе. Разберём варианты по этой оси. Фиксированная строка вроде key: global даёт один кеш на весь проект: он почти всегда «попадает», но никогда не инвалидируется сам — добавили пакет, а кеш всё ещё содержит старый набор, и приходится вычищать его вручную. Ключ по ветке ($CI_COMMIT_REF_SLUG) изолирует кеши разных веток, что удобно, когда ветки сильно расходятся по зависимостям, но создаёт холодный кеш для каждой новой ветки и плодит множество почти одинаковых архивов. Ключ по files хеширует содержимое lock-файла, и в этом его красота: кеш живёт ровно до тех пор, пока зависимости не изменились, и инвалидируется автоматически в тот самый коммит, где правится package-lock.json. Никакого ручного вмешательства — поэтому для менеджеров пакетов с lock-файлами это эталонная стратегия, а остальные варианты остаются для случаев без lock-файла.

Кеш против артефактов

Свойствоcacheartifacts
Назначениеускорение (зависимости)передача результатов
Между джобами одного пайплайнане гарантируетсягарантирована
Между пайплайнамида, переиспользуетсянет (только хранятся)
Видно в MR/скачатьнетда

Правило: кеш — для того, что можно пересоздать (зависимости); артефакты — для того, что нужно надёжно передать (сборки, отчёты). Никогда не полагайтесь на кеш как на средство передачи результата между джобами — он может оказаться пустым.

Эта таблица — не просто список различий, а отражение двух разных гарантий, которые даёт платформа. Артефакты — это обещание: «эти файлы будут доставлены потребителю». GitLab архивирует их синхронно, привязывает к джобе и гарантирует, что зависимая джоба их получит. Кеш — это всего лишь подсказка к оптимизации: «попробуй переиспользовать, если получится». Если кеша ещё нет (первый запуск), если он на другом раннере, если архив повредился или истёк — джоба просто отработает медленнее, скачав зависимости заново, но не сломается. Отсюда и главное практическое следствие: правильно написанная джоба обязана корректно работать с пустым кешем. Команда npm ci отлично уживается с кешем node_modules/ именно потому, что она самодостаточна — есть кеш, она ускорится; нет — установит всё сама. А вот код, который рассчитывает найти в кеше готовый собранный артефакт и без него падает, рано или поздно сломается на запуске, где кеш окажется пустым. Думайте о кеше как о тёплом старте, а не как о канале передачи данных.

policy: pull / push

Иногда нужно тонко управлять: одна джоба наполняет кеш (policy: push), остальные только читают (policy: pull). Это экономит время на выгрузку кеша в джобах, которые его не меняют.

Зачем вообще разделять чтение и запись кеша? По умолчанию каждая джоба и скачивает кеш в начале, и выгружает его обратно в конце — а выгрузка большого node_modules/ на сервер тоже стоит времени. Если в пайплайне пять джоб используют одни и те же зависимости, нет смысла, чтобы все пять перезаписывали идентичный кеш: это пять лишних выгрузок одного и того же. Разумная схема — выделить одну «подготовительную» джобу с policy: push, которая единожды установит зависимости и наполнит кеш, а всем остальным проставить policy: pull, чтобы они только читали готовый кеш и не тратили время на бессмысленную перезапись. Это особенно ощутимо в широких пайплайнах с десятком параллельных джоб: грамотная политика кеша срезает с каждой из них накладные расходы на выгрузку, и суммарная экономия складывается в заметное ускорение всего конвейера.

Как работает под капотом

В начале джобы раннер по ключу скачивает архив кеша (если есть) и распаковывает в рабочую папку. В конце, если кеш изменился и политика разрешает push, упаковывает указанные пути и сохраняет под тем же ключом. Хранилище кеша может быть локальным на раннере или общим (S3) — поэтому в распределённой системе важно настроить общее хранилище, иначе разные раннеры будут видеть разные кеши.

Вопрос хранилища заслуживает отдельного внимания, потому что именно он рождает самые загадочные баги «кеш то есть, то нет». По умолчанию многие раннеры держат кеш локально — прямо на той машине, где выполнялась джоба. Пока у вас один раннер, всё работает идеально: кеш, записанный вчера, доступен сегодня. Но как только вы масштабируетесь до нескольких раннеров (а в облачных автоскейл-сетапах их число вообще плавает), каждая машина обзаводится своим изолированным кешем. Джоба, попавшая на «свежий» раннер, не найдёт кеша, наполненного на соседнем, и честно скачает зависимости заново — кеш будто бы «не работает», хотя ключ верный. Лекарство — общее распределённое хранилище: настроить раннеры на S3-совместимый бакет, куда они все пишут и откуда все читают. Тогда кеш по данному ключу один на весь флот, и любой раннер видит то, что записал любой другой. Это ключевое инфраструктурное решение для команд: без него кеш в распределённой среде остаётся ненадёжной лотереей, а с ним — превращается в стабильное ускорение для всего парка раннеров.

Частые ошибки

  • Использовать фиксированный ключ для зависимостей — кеш не инвалидируется при смене пакетов, ловите старые версии.
  • Передавать через кеш собранный артефакт — он может не дойти; используйте artifacts.
  • Ожидать общий кеш между раннерами без настроенного распределённого хранилища.
  • Не разделять policy: pull/push в широких пайплайнах — десяток джоб бессмысленно перезаписывает один и тот же кеш.
  • Писать джобу так, что без кеша она падает, — кеш всегда может оказаться пустым.

Итоги

  • Кеш ускоряет, переиспользуя зависимости между запусками; ключ задаёт инвалидацию.
  • Лучший ключ для пакетов — по lock-файлу через files.
  • Кеш — для пересоздаваемого, артефакты — для надёжной передачи результатов.
  • Кеш — лишь подсказка к оптимизации без гарантий; джоба обязана работать и с пустым кешем.
  • В распределённой среде кеш требует общего хранилища (S3), иначе разные раннеры видят разные кеши.
Проверьте себя
1. В чём ключевое отличие cache от artifacts?
ACache шифрует данные, artifacts нет
BCache — для ускорения (пересоздаваемые зависимости) и живёт между пайплайнами; artifacts надёжно передают результаты
CОни полностью идентичны
DArtifacts работают только с Docker
2. Какой ключ кеша правильнее для node_modules?
AФиксированная строка на весь проект
BХеш по package-lock.json через files
CСлучайное значение каждый раз
DИмя джобы