Кэш в CI/CD: что сохранять, что нет

Сборка frontend-проектов в CI/CD почти всегда ограничивается не вычислительной мощностью сервера, а количеством повторно выполняемых операций: загрузкой зависимостей, транспиляцией, минификацией, повторным анализом модулей и генерацией артефактов. Webpack активно использует кэширование для сокращения этих затрат, однако в среде CI/CD стратегия работы с кэшем отличается от локальной разработки.

В локальной среде кэш живёт долго и постепенно прогревается. В CI/CD окружение часто является эфемерным: контейнеры пересоздаются, файловая система очищается, а пайплайны запускаются параллельно. Неправильно организованный кэш либо не приносит пользы, либо начинает вызывать нестабильность сборок.

Основная задача — определить:

  • какие данные действительно ускоряют pipeline;
  • какие кэши безопасны;
  • какие кэши нельзя переносить между окружениями;
  • когда кэш должен инвалидироваться;
  • какие артефакты лучше пересоздавать заново.

Типы кэша в инфраструктуре CI/CD

В контексте Webpack и CI/CD обычно используются несколько независимых уровней кэширования.

Кэш менеджера пакетов

Наиболее важный и безопасный уровень:

  • npm cache;
  • Yarn cache;
  • pnpm store;
  • node_modules.

Этот кэш позволяет не скачивать зависимости повторно.

Кэш Webpack

Webpack 5 поддерживает persistent cache:

module.exports = {
  cache: {
    type: 'filesystem'
  }
};

Содержит:

  • результаты парсинга модулей;
  • AST;
  • результаты loader’ов;
  • данные dependency graph;
  • результаты трансформаций Babel;
  • промежуточные оптимизации.

Кэш Babel

Если используется babel-loader, может применяться отдельный кэш:

{
  loader: 'babel-loader',
  options: {
    cacheDirectory: true
  }
}

Кэш TypeScript

TypeScript incremental build:

{
  "compilerOptions": {
    "incremental": true
  }
}

Создаёт .tsbuildinfo.

Кэш линтеров

ESLint:

eslint --cache

Stylelint:

stylelint --cache

Docker layer cache

Если сборка выполняется внутри Docker, может кэшироваться:

  • установка зависимостей;
  • build layers;
  • промежуточные filesystem layers.

Что обязательно стоит кэшировать

Кэш зависимостей

Это наиболее эффективный тип кэша в CI/CD.

npm

cache:
  paths:
    - ~/.npm

Yarn

cache:
  paths:
    - .yarn/cache

pnpm

cache:
  paths:
    - ~/.pnpm-store

Скачивание тысяч пакетов из registry значительно медленнее чтения локального кэша.


node_modules

Иногда кэшируют полностью каталог node_modules.

Преимущества:

  • максимально быстрое восстановление;
  • отсутствие повторной установки;
  • ускорение больших monorepo.

Недостатки:

  • огромный размер;
  • проблемы совместимости между ОС;
  • проблемы при смене Node.js;
  • повреждение бинарных модулей.

Когда это оправдано

Кэширование node_modules особенно полезно:

  • в GitLab CI;
  • на self-hosted runners;
  • в монолитных проектах;
  • при стабильной версии Node.js.

Когда лучше отказаться

Нежелательно использовать:

  • на разных ОС;
  • при частом обновлении Node.js;
  • в ephemeral cloud runners;
  • при использовании native-зависимостей.

Webpack filesystem cache

Webpack persistent cache даёт очень серьёзный прирост скорости.

Пример:

module.exports = {
  cache: {
    type: 'filesystem',
    cacheDirectory: path.resolve(__dirname, '.webpack-cache')
  }
};

В CI/CD это может сокращать время:

  • transpilation;
  • tree shaking;
  • module graph generation;
  • minimization preparation.

Особенно полезно при:

  • большом количестве loader’ов;
  • TypeScript;
  • Babel;
  • PostCSS;
  • огромных dependency graph.

Babel cache

Babel является одним из самых дорогих этапов сборки.

{
  loader: 'babel-loader',
  options: {
    cacheDirectory: true,
    cacheCompression: false
  }
}

В CI/CD кэш Babel особенно полезен при:

  • large React apps;
  • monorepo;
  • множестве entry points;
  • heavy transpilation.

TypeScript incremental cache

При использовании ts-loader или отдельного tsc incremental cache существенно ускоряет проверки типов.

{
  "incremental": true,
  "tsBuildInfoFile": ".tsbuildinfo"
}

Что обычно НЕ стоит кэшировать

Готовый dist

Частая ошибка — кэширование каталога сборки:

cache:
  paths:
    - dist/

Проблемы:

  • риск устаревших артефактов;
  • неправильные hash;
  • конфликт environment variables;
  • проблемы reproducible builds;
  • случайная публикация старых файлов.

dist должен считаться одноразовым артефактом.


Source maps

Source maps могут занимать гигабайты.

Их кэширование редко оправдано:

  • генерация обычно быстрее загрузки из remote cache;
  • высокий network overhead;
  • быстрое устаревание.

Минифицированные ассеты

Кэширование:

  • .min.js;
  • .min.css;
  • compressed assets;

обычно бессмысленно.

Причины:

  • быстрый rebuild;
  • сильная зависимость от commit hash;
  • высокий риск несоответствия.

Temporary runtime data

Нельзя кэшировать:

  • lock-файлы runtime;
  • PID;
  • temp directories;
  • socket files;
  • build metadata CI runner.

Инвалидация кэша

Главный принцип

Кэш должен инвалидироваться автоматически при изменении:

  • зависимостей;
  • конфигурации;
  • версии Node.js;
  • версии Webpack;
  • loader’ов;
  • окружения сборки.

Инвалидация через lock file

Наиболее распространённая стратегия:

key:
  files:
    - package-lock.json

или:

key:
  files:
    - yarn.lock

Изменение зависимостей автоматически сбрасывает кэш.


Учет версии Node.js

Очень важно включать Node.js version в cache key.

Пример:

key: node-18-yarn-${{ hashFiles('yarn.lock') }}

Иначе возможны:

  • ABI conflicts;
  • binary corruption;
  • native module crashes.

Инвалидация Webpack cache

Webpack filesystem cache умеет автоматически отслеживать:

  • webpack config;
  • loader configuration;
  • buildDependencies.

Пример:

cache: {
  type: 'filesystem',
  buildDependencies: {
    config: [__filename]
  }
}

Разделение кэшей

Раздельный cache namespace

Плохая практика:

key: webpack-cache

Хорошая практика:

key: webpack-${CI_COMMIT_REF_SLUG}-${NODE_VERSION}

Иначе возникают:

  • race conditions;
  • повреждение кэша;
  • смешивание веток;
  • несовместимые данные.

Separate cache per branch

Для feature branches желательно использовать отдельный cache namespace.

Причины:

  • разные зависимости;
  • разные webpack config;
  • разные environment flags.

Кэш и Docker

Layer caching

Правильный Dockerfile:

COPY package.json yarn.lock ./

RUN yarn install

COPY . .

Тогда dependencies layer кэшируется отдельно от source code.


Ошибочная структура Dockerfile

COPY . .

RUN yarn install

Любое изменение файла полностью инвалидирует install layer.


BuildKit cache mounts

Современный Docker BuildKit поддерживает mount cache:

RUN --mount=type=cache,target=/root/.npm npm install

Это значительно ускоряет CI.


Remote cache

Локальный cache runner’а

Подходит для:

  • self-hosted CI;
  • dedicated agents;
  • long-lived runners.

Недостаток — кэш не переносится между агентами.


Distributed cache

Используются:

  • S3;
  • Redis;
  • Artifactory;
  • Nx Cloud;
  • Turborepo Remote Cache.

Преимущества:

  • reuse между pipeline;
  • reuse между developers;
  • reuse между branches.

Недостатки:

  • network latency;
  • cache upload overhead;
  • сложность invalidation.

Кэширование в monorepo

В monorepo влияние кэша особенно велико.

Без кэша:

  • пересобираются все packages;
  • повторно выполняется transpilation;
  • повторно строятся shared chunks.

Targeted cache

Кэш должен быть сегментирован:

  • per package;
  • per app;
  • per build target.

Пример:

key: webapp-${HASH}

а не:

key: monorepo-global

Опасности чрезмерного кэширования

Устаревшие артефакты

Наиболее опасная проблема.

Проявления:

  • код не соответствует commit;
  • production использует старую версию;
  • broken sourcemaps;
  • mismatch JS/CSS.

Нестабильные сборки

Если кэш повреждён:

  • сборка может падать случайным образом;
  • ошибки невозможно воспроизвести;
  • возникают intermittent failures.

Cache poisoning

При shared cache возможна ситуация, когда:

  • одна ветка создаёт несовместимый cache;
  • другая ветка использует его;
  • сборка становится некорректной.

Практическая стратегия для Webpack CI/CD

Минимально безопасный набор

Почти всегда стоит кэшировать:

cache:
  paths:
    - ~/.npm
    - .webpack-cache

Для больших проектов

Дополнительно:

cache:
  paths:
    - ~/.npm
    - .webpack-cache
    - .eslintcache
    - .tsbuildinfo

Для monorepo

Обычно используются:

  • distributed cache;
  • package-level cache;
  • task graph cache;
  • build artifact cache.

Пример CI-конфигурации

GitHub Actions

- uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      .webpack-cache
    key: |
      node-${{ matrix.node }}-${{ hashFiles('package-lock.json') }}

GitLab CI

cache:
  key:
    files:
      - package-lock.json
  paths:
    - node_modules/
    - .webpack-cache/

Оптимальный баланс

Слишком маленький кэш:

  • не ускоряет pipeline;
  • увеличивает cold start;
  • создаёт постоянные reinstall/rebuild.

Слишком большой кэш:

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

Наиболее эффективный CI cache:

  • небольшой;
  • хорошо сегментирован;
  • быстро инвалидируется;
  • зависит от lock file;
  • зависит от Node.js version;
  • не содержит production artifacts;
  • не хранит временные build outputs.