Стратегия кэша и стоимости: иерархия restore-keys, стена вытеснения 10 ГБ и Docker layer cache
Кэш GitHub Actions даёт репозиторию хранилище 10 ГБ с LRU-вытеснением. Точный key плюс широкие restore-keys превращают холодный кэш в тёплый; Docker layer caching через бэкенд gha кэширует слои сборки, но раздутый кэш, вытесняющий сам себя, может стоить больше отсутствия кэша.
Команда добавила кэширование зависимостей для ускорения CI, и неделю это работало — установки по 90 секунд упали до 8. Потом hit rate кэша тихо рухнул почти до нуля, и никто не заметил, пока счёт за минуты раннеров не пополз обратно вверх. Причиной были потолок кэша 10 ГБ на репозиторий и LRU-вытеснение (вытеснение по принципу «давно не использовалось»): кто-то начал кэшировать весь node_modules и Docker build cache и кэш бинарника Cypress, каждый с настолько свободным ключом, что каждая ветка писала свежую запись на 2 ГБ. За день кэши репозитория проскочили 10 ГБ, и LRU начал вытеснять кэш зависимостей — с самым высоким hit rate — чтобы освободить место под per-branch Docker-кэши, каждый из которых использовался единожды. Команда платила стоимость записи кэша без выгоды чтения. Фиксом было не больше кэша, а меньше: убрать per-branch Docker-кэш до единственного скоупа main, затянуть ключи и дать высокоценному кэшу зависимостей пережить вытеснение.
Ключ и лестница restore-keys
Когда добавляешь кэширование в CI, ты делаешь ставку: «это будет читаться чаще, чем стоит писать». Структура key и restore-keys — это то, что решает, окупится ставка или будет тихо сливать деньги каждый запуск.
Cache-action восстанавливает по первичному key и списку фолбэков restore-keys:
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-key — это точное совпадение: хэш lockfile означает, что ключ меняется, только когда меняются зависимости, так что неизменный lockfile получает идеальное попадание, а изменённый — чистый промах (без устаревших зависимостей). На промахе restore-keys пробуются как префиксные совпадения, от самого свежего: npm-Linux- восстанавливает свежайший кэш от любого прошлого lockfile, так что бамп зависимости в одну строку всё равно тёпло стартует от вчерашнего node_modules и доустанавливает лишь дельту. Паттерн — специфичный key, широкий фолбэк — точный для корректности, префиксы для тепла. Кэш пишет новую запись только на промахе ключа; если точный ключ попал, новый кэш не сохраняется.
Workflow ключует кэш зависимостей на hashFiles('package-lock.json') с restore-key префиксом npm-Linux-. PR добавляет одну новую зависимость, меняя lockfile. Что происходит на этом запуске?
Стена 10 ГБ и LRU-вытеснение
Каждый репозиторий получает общий бюджет кэша 10 ГБ. Когда записи толкают его за 10 ГБ, GitHub вытесняет записи по least-recently-used — а кэши также вытесняются, если не тронуты 7 дней. Два следствия гонят реальную стоимость:
- Branch scope: кэш, созданный на ветке, читается этой веткой и дочерними ветками; кэши дефолтной ветки читаются всеми ветками. Так что кэширование на
mainзасевает каждый PR, тогда как кэширование на каждой feature-ветке плодит записи с низким переиспользованием, лишь жрущие бюджет 10 ГБ. - Вытеснение молчаливо и слепо к ценности: LRU не знает, что у твоего кэша зависимостей hit rate 95%, а у per-branch Docker-кэша 1%. Если низкоценные кэши пишутся свежее, они выталкивают высокоценный. Кэширование большего числа вещей может сделать CI медленнее, вытеснив кэш, который был важен.
Дисциплина: кэшируй немногие высокоценные, часто-переиспользуемые вещи на дефолтной ветке с затянутыми ключами и сопротивляйся желанию кэшировать всё. Кэш, что пишется каждый запуск, но редко читается, — чистые накладные: ты платишь время аплоада и давление вытеснения без выгоды восстановления.
▸Почему это работает
Почему переизбыток кэша активно вредит, а не просто нейтрален? Три складывающиеся стоимости. Первая: каждое сохранение кэша аплоадит артефакт, добавляя реальное время джобу, даже когда он никогда не восстанавливается. Вторая: каждая запись конкурирует за фиксированные 10 ГБ, так что низкоценная запись может вытеснить высокоценную, превращая будущее попадание в промах. Третья: вызванный ею промах — дорогой — полная холодная установка или пересборка — так что один потраченный впустую per-branch кэш на 2 ГБ может превратить восстановление зависимостей с 95% попаданий в 90-секундную холодную установку на следующем запуске другой ветки. Бюджет кэша — общий, дефицитный, LRU-управляемый ресурс; отношение к нему как к бесплатному ведёт прямо к более медленному и дорогому пайплайну.
Docker layer cache: мощно и легко злоупотребить
Для сборок контейнеров BuildKit может использовать кэш GitHub Actions как бэкенд layer-кэша (cache-from/cache-to: type=gha). Сделанное правильно, это пропускает пересборку неизменных слоёв — многоминутная сборка образа падает до секунд, когда изменился лишь слой приложения. Две ловушки: mode=max кэширует все промежуточные слои (отличный hit rate, но крупно — легко несколько ГБ, что съедает бюджет 10 ГБ и триггерит вытеснение), тогда как mode=min кэширует только финальные слои (меньше, ниже hit rate). И упорядочивание Dockerfile так, чтобы зависимости устанавливались до копирования кода, — вот что вообще заставляет кэш попадать; скопируй код первым, и каждая сборка инвалидирует слой зависимостей. Layer-кэширование — это рычаг, но жирный mode=max кэш, вытесняющий твой кэш зависимостей, или плохо упорядоченный Dockerfile, который никогда не попадает, стоит больше, чем холодная сборка.
CI репозитория стал медленнее после того, как команда добавила Docker layer cache с type=gha,mode=max на каждой feature-ветке. Шаги установки зависимостей, что раньше попадали в кэш, теперь часто промахиваются. Какой наиболее вероятный механизм?
- 01Объясни различие key против restore-keys и почему паттерн — специфичный key, широкий фолбэк.
- 02Почему добавление большего числа кэшей может сделать CI медленнее, и что управляет хранилищем 10 ГБ?
Кэширование GitHub Actions — рычаг, только когда тёплый путь реально попадает, и это держится на трёх вещах: ключе, фолбэках и бюджете. Первичный ключ должен быть точным значением — обычно хэшем lockfile — так чтобы он попадал, когда ничего не изменилось, и чисто промахивался, когда зависимости изменились, никогда не отдавая устаревшее состояние. Список restore-keys даёт префиксные фолбэки, пробуемые от свежайшего, так что изменённый lockfile всё равно тёпло стартует от свежайшего прошлого кэша и доустанавливает лишь дельту: специфичный ключ для корректности, широкие префиксы для тепла, с новой записью, сохраняемой только на промахе точного ключа. Жёсткое ограничение — хранилище: 10 ГБ на репозиторий, вытесняемые по least-recently-used и истекающие после 7 дней простоя, и вытеснение слепо к ценности — оно не отличит твой кэш зависимостей с 95% попаданий от per-branch Docker-кэша, использованного единожды. Вот почему кэширование большего может сделать CI медленнее: крупный низко-переиспользуемый кэш конкурирует за фиксированный бюджет и может вытолкнуть высокоценный, превращая будущее дешёвое восстановление в дорогую холодную установку, поверх стоимости аплоада, что каждый сейв платит даже когда не читается. Branch scope усугубляет это — кэши дефолтной ветки засевают каждый PR, тогда как per-feature-branch записи плохо переиспользуются и лишь жгут бюджет. Docker layer-кэширование через бэкенд type=gha мощно: оно пропускает неизменные слои и роняет многоминутную сборку до секунд, но mode=max кэширует каждый промежуточный слой и может вырасти до нескольких ГБ, что вытеснят твой кэш зависимостей, а Dockerfile, копирующий код до установки зависимостей, не попадает вовсе. Сквозная мысль — сдержанность: кэшируй немногие высокоценные, часто-переиспользуемые артефакты на дефолтной ветке с затянутыми ключами и хорошо упорядоченными Dockerfile, и относись к 10 ГБ как к дефицитному общему ресурсу, которым они и являются. Теперь, когда увидишь, что hit rate кэша рухнул после того, как кто-то «добавил ещё кэширования», знаешь куда смотреть: какая новая запись LRU-вытесняет ту, что реально работала.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.