Артефакты и кэш: передача файлов между джобами и кэш, который не попадает
Артефакты двигают файлы между джобами и наружу из запуска (дефолтное хранение 90 дней); кэш ускоряет повторные запуски через restore-ключ. Классический провал: ключ кэша без хэша lockfile, поэтому он не инвалидируется, или не попадает через окно вытеснения 10 ГБ / 7 дней.
«Кэш сломан — он никогда не попадает». Каждый запуск CI всё ещё занимал восемь минут на переустановку зависимостей, хотя команда добавила actions/cache неделями ранее. Логи говорили Cache not found for input keys почти на каждом запуске. Проблема была в ключе: кто-то написал key: node-modules — константную строку. Запись кэша пишется под своим ключом, только если записи с этим точным ключом ещё нет; раз node-modules сохранён на самом первом запуске, он стал неизменяемым, поэтому каждый последующий запуск его находил… но команда также недоумевала, почему апгрейды зависимостей никогда не вступали в силу в CI. Оба симптома — один баг с двух сторон: ключ без хэша содержимого никогда не инвалидируется (так устаревший кэш служит вечно) и, из-за несвязанной опечатки в пути, сохранённая запись была пуста, так что restore был no-op. Фиксом был канонический паттерн — key: node-${{ hashFiles('**/package-lock.json') }} плюс префикс restore-keys: — который привязывает идентичность кэша к lockfile, так что он инвалидируется ровно тогда, когда меняются зависимости, и переиспользует близкое совпадение в остальных случаях.
Артефакты против кэша: две разные задачи
Они выглядят похоже — оба хранят файлы — но решают противоположные проблемы:
- Артефакты выносят файлы из workflow: выходы сборки, тест-отчёты, coverage, логи — то, что человек или поздний джоб скачивает. Загружай через
actions/upload-artifact, забирай черезactions/download-artifact. Это единственный способ передать файлы между джобами, ведь каждый джоб выполняется на свежем раннере. Дефолтное хранение — 90 дней (настраивается per-upload вниз до 1 дня или задаётся на весь репозиторий), после чего они удаляются. - Кэш ускоряет будущие запуски той же работы: каталоги зависимостей (
~/.npm,node_modules,~/.cargo), промежуточные результаты сборки. Используйactions/cache. Запись кэша ключуется и неизменяема после записи — и она best-effort (по возможности, без гарантии), не гарантирована: по политике вытеснения GitHub удаляет кэши, не запрашивавшиеся 7 дней, и когда кэши репозитория превышают 10 ГБ суммарно, он вытесняет least-recently-used записи, чтобы остаться под лимитом.
Ментальное правило: артефакт = «мне нужен этот файл позже или человеку», кэш = «пересчёт этого медленный, а входы редко меняются». Никогда не используй кэш для выходов сборки, которые нельзя терять (он может быть вытеснен в любой момент); никогда не используй артефакты как кэш скорости (нет restore по ключу, а 90-дневное хранение стоит места).
- uses: actions/upload-artifact@v4
with:
name: dist
path: build/
retention-days: 7 # сократи с дефолта 90 дней для эфемерных сборок
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-Ключ кэша — это весь дизайн
Ключ кэша должен делать две противоречивые вещи: инвалидироваться при изменении входов и попадать, когда они не меняются. Канонический паттерн решает это двумя полями:
keyдолжен включать хэш содержимого входов —hashFiles('**/package-lock.json'). Когда lockfile меняется, хэш меняется, ключ меняется, и старый кэш правильно не используется. Ключ без хэша (key: npmиз Hook) никогда не меняется, поэтому либо служит устаревшим кэшем вечно, либо — раз записи неизменяемы — фиксирует то, что сохранено первым.restore-keys— упорядоченный список префиксов, пробуемых при промахе точногоkey. При изменении lockfile точный ключ ещё не существует, ноrestore-keys: npm-${{ runner.os }}-совпадает с предыдущим кэшем как частичное попадание — так ты стартуешь отnode_modulesпрошлой сборки и устанавливаешь только дельту, затем сохраняешь под новым точным ключом. Это и превращает холодный кэш в тёплый сквозь подъёмы зависимостей.
Семантика сохранения ловит людей: actions/cache сохраняет автоматически в конце джоба только если точный key ещё не существовал (попадание по точному ключу означает отсутствие сохранения). Неизменяемость означает, что нельзя перезаписать запись — чтобы «обновить» кэш, надо изменить ключ. А из-за вытеснения по 7-дневному простою и 10 ГБ-LRU кэш редко запускаемого workflow может просто исчезнуть, что правильное поведение, а не баг — кэш это оптимизация, обязанная терпеть промахи.
Workflow использует key: deps-${{ hashFiles('package-lock.json') }} с restore-keys: deps-. PR поднимает одну зависимость, меняя lockfile. Что происходит на том запуске и что сохраняется?
▸Почему это работает
Почему записи кэша неизменяемы, а не перезаписываются при сохранении? Потому что иначе параллельные джобы гонялись бы за запись одного ключа, и читатель мог бы получить полузаписанный кэш, или две ветки могли бы затереть состояние зависимостей друг друга под общим ключом. Неизменяемость делает ключ стабильной идентичностью содержимого: один ключ всегда означает те же байты, поэтому попаданию можно доверять без перепроверки. Цена в том, что «обновить» кэш невозможно — ты делаешь новый ключ (что хэш содержимого делает за тебя автоматически при изменении входов) и даёшь LRU-вытеснению удалить старый. Вот почему хэш содержимого в ключе — не опциональный лоск; это то, что делает неизменяемую модель корректной, а не вечно устаревшей.
Размеры и гигиена
Кэши и артефакты оба стоят места и оба имеют лимиты, кусающие на масштабе. Одна запись кэша должна быть каталогом зависимостей, а не всем workspace — кэширование node_modules плюс ~/.npm плюс выхода сборки утраивает размер и толкает к 10 ГБ-LRU-обрыву быстрее, вытесняя другие полезные кэши. Артефакты по умолчанию хранятся 90 дней, что для per-PR выходов сборки расточительно; сократи retention-days до недели или меньше для эфемерных данных, а долгое хранение оставь релизам и аудит-логам. Сжимай перед загрузкой крупных артефактов и вообще не загружай node_modules как артефакт — для этого есть кэш. Дисциплина — сопоставлять каждое хранилище его задаче: долговечная передача → артефакт с намеренным хранением; оптимизация скорости с терпимыми промахами → кэш с ключом, хэширующим содержимое.
Команда кэширует выход сборки (финальный скомпилированный бинарь) вместо загрузки его как артефакт, чтобы «сэкономить время на скачивании». Почему это неправильное хранилище и что ломается?
- 01Почему ключ кэша должен включать хэш содержимого и что restore-keys добавляет поверх него?
- 02Противопоставь артефакты и кэш по долговечности и применению и назови числа вытеснения и хранения.
Артефакты и кэш оба сохраняют файлы, но отвечают на противоположные нужды. Артефакты — долговечный канал: они выносят выходы сборки, отчёты, coverage и логи из workflow человеку или нижестоящему джобу, и поскольку каждый джоб выполняется на свежем раннере, они единственный способ двигать файлы между джобами — хранятся 90 дней по умолчанию, что стоит сокращать через retention-days для эфемерных per-PR данных, а долгое оставлять релизам и аудит-следам. Кэш — best-effort ускорение повторной работы вроде каталогов зависимостей и промежуточных результатов сборки: записи ключуются и неизменяемы после записи, и они явно вытесняемы — GitHub удаляет кэши, нетронутые 7 дней, и, за 10 ГБ суммарного кэша репозитория, вытесняет least-recently-used записи, так что промах — корректное поведение, которое дизайн обязан терпеть. Ключ кэша несёт всё бремя корректности: включи хэш содержимого вроде hashFiles('**/package-lock.json'), чтобы ключ менялся ровно при изменении входов — бесхэшевый константный ключ либо служит устаревшим кэшем вечно, либо, при неизменяемости, замораживает то, что сохранено первым — и добавь префиксы restore-keys, чтобы свежий промах точного ключа всё равно восстановил предыдущий кэш как тёплое частичное попадание, ставя только дельту перед сохранением под новым ключом. Сопоставляй каждое хранилище его задаче: долговечную передачу, которую нельзя терять, — в артефакт с намеренным хранением; медленно-пересчитываемые, редко-меняющиеся входы — в кэш по хэшу содержимого, для которого промахи бесплатны. Кэшируй каталог зависимостей, а не весь workspace, чтобы держаться вдали от 10 ГБ-LRU-обрыва, вытесняющего твои другие полезные кэши. Теперь, когда встретишь запуск CI, переустанавливающий всё каждый раз, — первым делом проверяй ключ: константная строка или хэш не того файла виноваты почти всегда, а фикс — один вызов hashFiles().
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.