29 июл. 2024 г.·7 мин чтения

Правила кэша GitLab CI для монорепозиториев на дешёвых раннерах

Правила кэша GitLab CI для монорепозиториев сокращают время задач на дешёвых раннерах, когда команды кэшируют зависимости по языкам, не хранят весь workspace и регулярно чистят старые слои.

Правила кэша GitLab CI для монорепозиториев на дешёвых раннерах

Почему кэши в монорепозиториях быстро превращаются в хаос

Монорепозиторий в Git выглядит аккуратно. Его кэш — почти никогда. В одном репозитории могут жить React-приложение, Go API, Python-воркер, Terraform-файлы и сборка для мобильного приложения. Каждая часть тянет свои папки с зависимостями, файлы пакетных менеджеров и артефакты сборки. Если считать всё это одним кэшем, архив быстро превращается в ящик с хламом.

Именно поэтому правила кэша GitLab CI для монорепозиториев требуют больше внимания, чем пайплайн одного приложения. Задаче фронтенда не нужны файлы модулей Go. Задаче линтинга бэкенда не нужен кэш пакетов Node. Когда все задания делят один ключ кэша, раннеры продолжают скачивать файлы, которые им никогда не понадобятся.

Обычно ошибка — кэшировать весь checkout или большие его части. Сначала это кажется удобным. Но объём быстро растёт. Файлы блокировок меняются, сгенерированные файлы накапливаются, а временный результат сборки случайно попадает внутрь. В итоге кэш включает папки зависимостей, скомпилированные ассеты, остатки тестов и случайное состояние инструментов сразу для нескольких языков.

И каждый раз за это платит раннер. Дешёвые GitLab runners обычно имеют более медленные диски, меньше CPU и более жёсткие сетевые ограничения. Большой кэш может дольше скачиваться и распаковываться, чем сам шаг установки, который он должен был заменить. Архив размером 1 ГБ на маленьком раннере может съедать минуту или больше ещё до старта тестов.

Проблема усугубляется по мере роста репозитория. Одна команда обновляет пакеты Node, другая меняет инструменты Python, третья добавляет сервис на Rust. Если все задания отправляют данные в один широкий кэш, каждое изменение перемешивает архив для всех остальных. Пайплайны начинают казаться случайными: один запуск быстрый, следующий тянется, потому что раннер подхватил кучу бесполезных файлов.

Простой пример это хорошо показывает. Представьте, что изменение в документации запускает лёгкую проверку бэкенда, но задание всё равно восстанавливает пакеты фронтенда, кэши сборки мобильного приложения и старый скомпилированный результат. Раннер работу сделал, но ничего из этого не помогло задаче закончиться быстрее.

Кэш должен убирать повторные скачивания зависимостей. Если он превращается в движущийся снимок всего репозитория, он перестаёт быть ускорением и становится лишней нагрузкой.

Кэшируйте зависимости, а не весь checkout

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

На дешёвых GitLab runners кэш полного checkout часто мешает больше, чем помогает. GitLab упаковывает исходники, временные файлы, отчёты тестов и старый результат сборки в один большой архив. Потом раннер тратит время на сжатие и распаковку мусора, который задаче даже не нужен.

Хорошие цели для кэша — папки, которые инструменты используют для хранения уже скачанных пакетов. Вспомните pnpm store, npm cache, pip cache, Cargo registry, Maven repository или Gradle dependency cache. Эти папки меняются реже, чем код, поэтому дают повторяемый выигрыш в скорости без лишнего разрастания хранилища.

Не кладите результаты сборки в общие пути кэша. Папки вроде dist, build, target, coverage и сгенерированные каталоги SDK быстро разрастаются и ещё быстрее устаревают. Когда одно задание восстанавливает старые скомпилированные файлы из другой ветки, ошибка может выглядеть случайной, и искать причину становится неприятно.

Если задаче нужны сгенерированные файлы, по возможности создавайте их внутри самой задачи. Генерация Prisma types, API clients или кода из схем обычно занимает секунды. Пересобрать их из исходников часто дешевле, чем гоняться за сломанным кэшем, который приехал со вчерашнего запуска.

Артефакты решают другую задачу. Используйте их только тогда, когда следующему шагу нужно прочитать ровно те файлы, которые создал предыдущий шаг, например собранное приложение, тестовый пакет или сгенерированный отчёт. Кэш — для повторно используемых слоёв зависимостей. Артефакты — для прямой передачи.

Простое правило хорошо работает: кэшируйте то, что скачиваете, пересобирайте то, что выводите из исходников, и передавайте между задачами только то, что действительно нужно. Так правила кэша GitLab CI для монорепозиториев остаются практичными, особенно когда у раннеров маленькие диски и слабые CPU.

Разделяйте кэши по языкам и инструментам

Когда в одном монорепозитории живут JavaScript, Python, Go и Rust, общий кэш превращается в ящик с хламом. Задача фронтенда не должна тянуть Python-кэш, а сборка Go не должна тащить с собой артефакты Rust, которые ей никогда не пригодятся. На дешёвых GitLab runners лишнее время на восстановление и загрузку может полностью съесть выигрыш в скорости.

Дайте каждому пакетному менеджеру свой путь кэша. Для npm или pnpm кэшируйте хранилище пакетов или кэш загрузок, а не node_modules для всего репозитория. Хранилище меняется реже, лучше сжимается и подходит для большего числа задач. Задайте ему отдельный путь кэша и ключ, основанный на lockfile именно этой части репозитория.

Python нуждается в таком же разделении, но граница там ещё важнее. Храните загрузки pip в отдельном кэше, а виртуальные окружения не кладите в общие кэши. Virtualenv завязан на пути к интерпретатору, скомпилированные wheels и детали конкретного раннера. Он быстро устаревает, особенно если меняется базовый образ.

Go и Rust тоже лучше работают, когда загрузки отделены от результатов сборки. Для Go кэшируйте скачанные модули, например module cache, а скомпилированные бинарники храните отдельно. Бинарники зависят от ОС, архитектуры и флагов сборки, поэтому быстро устаревают. Для Rust держите Cargo registry и git data отдельно от каталога target. Registry обычно хорошо подходит для общего кэша. Папка target может сильно разрастаться и часто помогает только той же задаче на том же образе раннера.

Один простой принцип экономит неожиданно много времени: если задача не работает с языком, не подключайте кэш вообще. Задаче с документацией обычно не нужен Cargo. Задаче линтинга для одного Node-пакета не нужны Go modules. Тянуть 700 МБ ненужного кэша, чтобы сэкономить 30 МБ загрузок, — плохая сделка.

Такое разделение сильно упрощает жизнь с правилами кэша GitLab CI для монорепозиториев. Хиты кэша становятся чище, хранилище — меньше, а если один слой ломается, можно очистить только его, не трогая всё остальное.

Выбирайте ключи кэша, которые истекают вовремя

Дешёвые раннеры дважды наказывают за плохие ключи кэша. Сначала вы теряете время на промахах кэша, а потом продолжаете платить за старые архивы, которые никто не использует. Хороший ключ кэша должен меняться, когда меняются зависимости, а не каждый раз, когда кто-то правит код приложения.

Для большинства кэшей зависимостей lockfile — самый чистый триггер. В монорепозитории package-lock.json, pnpm-lock.yaml, poetry.lock, go.sum или Cargo.lock показывают, когда действительно изменилась прослойка зависимостей. Изменение README, правка CSS или небольшой рефакторинг не должны заставлять полностью обновлять кэш. Это одно из самых полезных правил кэша GitLab CI для монорепозиториев.

Если ваши раннеры не делят одну и ту же среду, добавьте это в ключ тоже. Нативные модули могут отличаться между Alpine и Ubuntu, и та же проблема возникает между amd64 и arm64. Если не включить ОС, образ или архитектуру в ключ кэша, одна ветка может восстановить кэш, который выглядит валидным, но ломается странным образом.

Хорошо работает такой небольшой шаблон:

cache:
  key:
    files:
      - package-lock.json
    prefix: "node-$CI_JOB_IMAGE-$CI_RUNNER_EXECUTABLE_ARCH-v3"

Lockfile управляет тем, когда кэш меняется. Prefix разделяет окружения. Суффикс v3 даёт вам ручной переключатель сброса.

Fallback по веткам помогает, но только если набор зависимостей остаётся довольно стабильным. Если большинство feature-веток использует те же пакеты, что и main, fallback может сильно сэкономить время на маленьких раннерах. Если ветки часто добавляют нативные пакеты, переключают версии языков или тестируют крупные обновления, fallback превращается в ловушку. Вы восстанавливаете старый кэш, а потом десять минут отлаживаете то, что на самом деле просто устаревшие зависимости.

Ручной суффикс версии важнее, чем многим кажется. После крупного обновления Node, Python или базового образа просто увеличьте v3 до v4 и двигайтесь дальше. Одно это изменение быстро убирает мёртвый груз и помогает избежать недель полусломанных кэшей.

Простое правило работает хорошо: привязывайте кэш к lockfile, добавляйте детали окружения, если сборки отличаются, используйте fallback по веткам только для стабильных слоёв зависимостей и меняйте суффикс версии, когда ситуация меняется.

Простая схема, которая работает на маленьких раннерах

Маленькие раннеры не прощают ленивое кэширование. Если одно задание упаковывает весь workspace, все последующие шаги платят за это временем скачивания, временем загрузки и местом на диске. Более аккуратная схема держит кэши узкими и предсказуемыми.

Сначала запишите, что именно устанавливает каждое задание. Во многих монорепозиториях этот список короче, чем кажется. Задаче фронтенда может быть нужен только pnpm, Python-воркеру — только pip, а Go-сервису — только Go modules. Если задача не устанавливает инструмент, не давайте ей этот кэш.

Смешанный монорепозиторий часто хорошо работает со схемой вроде такой:

  • Задания Node кэшируют pnpm или npm store, а ключ кэша строится на lockfile
  • Задания Python кэшируют папку загрузок pip или Poetry, а ключ кэша строится на requirements или poetry.lock
  • Задания Go кэшируют каталог загрузки модулей, а ключ кэша строится на go.sum

Ограничьтесь одной путевой папкой кэша и одним ключом кэша на язык. Это правило кажется строгим, но оно предотвращает обычный беспорядок, когда один кэш тихо собирает несвязанные файлы месяцами.

Тестовые задания обычно должны только читать кэш. Им полезны тёплые зависимости, но они не должны выгружать свежие кэши на каждом запуске. Это быстро снижает churn кэша, особенно на дешёвых GitLab runners, где и сеть, и диск ограничены.

Задания установки могут обновлять кэш, но только когда меняются lockfile. На практике это означает небольшой prepare job для каждого языка с правилами, привязанными к нужным файлам. Если версии пакетов не меняются, кэш тоже не меняется. В этом и смысл кэширования слоёв зависимостей.

Если нужен простой ориентир для правил кэша GitLab CI в монорепозиториях, используйте такой: тестовые задания читают кэши, задания установки обновляют их, и у каждого языка свой кэш.

Потом неделю смотрите на цифры. Отслеживайте среднее время задания, размер кэша и частоту промахов. Если кэш экономит 15 секунд, но разрастается до нескольких гигабайт, избавьтесь от него. На дешёвых раннерах лучше работают меньшее число кэшей, которые ведут себя предсказуемо.

Реалистичный пример из смешанного монорепозитория

Представьте репозиторий с четырьмя частями: frontend на Next.js в apps/web, Python API в services/api, Go-воркер в workers/queue и документация в docs. Команда использует дешёвые GitLab runners с маленькими дисками, поэтому держит кэши узкими. Каждое задание восстанавливает один слой зависимостей, а потом собирает проект из свежего checkout.

Задача фронтенда кэширует pnpm store, а не node_modules и не всю папку приложения. Lockfile управляет кэшем. Если меняется pnpm-lock.yaml, меняется и кэш. Если команда правит только UI-код, задача повторно использует store и пропускает ещё одно полное скачивание зависимостей.

Python получает то же обращение. Задача API кэширует загрузки wheel в папке вроде .cache/pip и привязывает этот кэш к requirements.txt. Полное виртуальное окружение она не кэширует. Так кэш остаётся меньше и не вызывает странных ошибок, когда меняется образ раннера или версия Python.

Go-воркер кэширует загрузки модулей по go.sum. Go и так хорошо управляет состоянием модулей, поэтому кэш может оставаться сосредоточенным на GOMODCACHE, а не на всём дереве сборки. Пересборки идут быстро, а старые данные модулей не просачиваются в другие задачи.

Задача документации — исключение. Если она только собирает Markdown или небольшой статический сайт, лучше вообще обойтись без кэшей зависимостей. Чистый запуск проще, и обычно задача и так заканчивается быстро.

web:
  cache:
    key:
      files: [apps/web/pnpm-lock.yaml]
    paths: [.pnpm-store]

api:
  cache:
    key:
      files: [services/api/requirements.txt]
    paths: [services/api/.cache/pip]

worker:
  cache:
    key:
      files: [workers/queue/go.sum]
    paths: [.go/pkg/mod]

docs:
  cache: []

Вот как выглядят правила кэша GitLab CI для монорепозиториев, когда они работают при небольшом бюджете. Один репозиторий может содержать несколько языков, но каждое задание восстанавливает только те папки, которыми реально может пользоваться. Это помогает ускорить pipeline в монорепозитории, не забивая хранилище раннеров устаревшим мусором.

Ошибки, которые съедают время и место

Самая частая ошибка с кэшем — считать весь workspace тем, что каждое задание должно восстанавливать. В монорепозитории это быстро разрастается. Временные файлы, тестовый вывод, состояние инструментов и папки зависимостей накапливаются, пока каждое задание не начинает тратить больше времени на распаковку мусора, чем на реальную работу.

node_modules часто создаёт именно такую проблему. Команды кэшируют его один раз, а потом подключают к lint, test, build и deploy. Это кажется удобным, но раздувает каждое восстановление и съедает диск на маленьких раннерах. Более лёгкий кэш, например хранилище загрузок пакетного менеджера, обычно даёт большую часть скорости при гораздо меньшем балласте.

Один ключ кэша для всех веток создаёт другой беспорядок. Старые файлы остаются после изменения lockfile, а feature-ветки могут подхватывать устаревшие зависимости из чужой работы. Задача при этом всё ещё может пройти, и из-за этого проблему сложнее заметить. Используйте ключи, привязанные к файлам зависимостей, и добавляйте область ветки, когда общее состояние начинает вести себя неожиданно.

Ещё одна плохая привычка — разрешать каждому заданию отправлять кэш обратно. Если четыре задачи восстанавливают один и тот же архив и потом все снова его загружают, раннер постоянно сжимает почти одинаковые данные. Это создаёт churn без реальной пользы. Выберите одно install-задание для обновления кэша. Остальные пусть только читают его.

Результаты сборки тоже не должны надолго попадать в кэши. Сохранение папок dist, build или target может скрыть медленные шаги компиляции и сломанные инкрементальные сборки. Если другому заданию нужен этот результат, передайте его как артефакт именно на этот прогон pipeline, а не храните днями.

Старые ключи кэша тоже тихо накапливаются. Дешёвые раннеры не ломаются в первый день. Они просто становятся медленнее неделя за неделей, пока восстановление кэшей не начинает тянуться, а предупреждения о диске не начинают появляться.

Безопасный шаблон прост:

  • Кэшируйте слои зависимостей, а не весь checkout.
  • Разделяйте кэши по языкам или инструментам.
  • Пусть только одно задание пишет в кэш.
  • Удаляйте старые ключи кэша по расписанию.

С правилами кэша GitLab CI для монорепозиториев обычно выигрывают скучные решения. Если время восстановления остаётся предсказуемым, а использование диска перестаёт расти, кэш делает свою работу.

Что проверить перед тем, как оставить правило кэша

Хорошие правила кэша GitLab CI для монорепозиториев скучны. Вы должны уметь объяснить каждое из них за несколько секунд: что оно экономит, как часто срабатывает и из-за чего истекает.

Начните с повторяющейся работы. Если задание устанавливает одни и те же пакеты почти при каждом запуске, кэш может помочь. Если задача и так завершается быстро, или пакетный менеджер уже хорошо переиспользует локальные данные, дополнительный шаг кэша может отнять больше времени, чем сэкономить.

Хорошо работает простой тест:

  • Сравните время установки с кэшем и без кэша на трёх обычных прогонах pipeline.
  • Проверьте размер архива кэша, а не только размер папки внутри задания.
  • Измените один lockfile и посмотрите, какие задания потеряют кэш.
  • Измерьте время восстановления на реальном раннере, а не на своём ноутбуке.
  • Ещё раз прочитайте правило и спросите себя, понял бы его новый участник команды.

Размер важнее, чем многие ожидают. Если архив кэша разрастается до 1 ГБ, чтобы не скачивать 250 МБ, это обычно плохая сделка на дешёвых GitLab runners. Сжатие, загрузка, восстановление и хранение — всё это стоит времени. Кэширование слоёв зависимостей работает лучше всего, когда кэш меньше той сетевой работы, которую он заменяет.

Изоляция тоже важна. Одно изменение lockfile должно ломать один кэш, а не все кэши в репозитории. Изменение Python requirements не должно сбрасывать Node cache. Обновление Rust не должно заставлять Java-задачи стартовать с нуля. Если ваш ключ кэша смешивает несвязанные файлы, сначала исправьте это.

Потом проверьте путь восстановления. У некоторых раннеров медленные диски, слабые CPU или общее хранилище. На таких машинах распаковка большого кэша может занять больше времени, чем переустановка пакетов из ближайшего зеркала реестра. Измерьте реальное время восстановления, прежде чем оставлять правило.

И наконец, проверьте, будет ли правило иметь смысл через два месяца. Монорепозитории меняются быстро. Команды делят папки, меняют пакетные менеджеры или перестают использовать инструмент. Добавьте короткий комментарий рядом с необычными ключами кэша и время от времени пересматривайте их. Если никто не может объяснить, зачем кэш существует, удалите его и посмотрите, заметит ли это кто-нибудь.

Что делать дальше

Сначала удалите любой кэш, который хранит весь workspace. Такой кэш выглядит удобным, но на дешёвых GitLab runners он обычно превращается в медленные загрузки, устаревшие файлы и раздутое хранилище. Замените его одним небольшим кэшем зависимостей в одной задаче, а потом несколько раз прогоните pipeline и сравните результаты.

Ведите короткую заметку для каждого оставшегося пути кэша. Если никто не может объяснить, зачем этот путь нужен, скорее всего, ему не место в конфигурации.

  • Укажите точный путь.
  • Отметьте, какая задача его использует.
  • Запишите, что именно он сохраняет: npm-пакеты, pip wheels или файлы Gradle.
  • Зафиксируйте, что должно его инвалидировать, например изменение lockfile.
  • Удалите всё, что экономит мало времени или растёт бесконтрольно.

Этот навык особенно важен в монорепозитории, потому что старые правила кэша живут месяцами. Пакет удалили, инструмент поменяли, одна команда перестала использовать язык, а кэш всё равно продолжает съедать место. Хорошие правила кэша GitLab CI для монорепозиториев по задумке немного скучны: маленький охват, понятная цель, легко удалить.

После этого назначьте одну ежемесячную проверку. Смотрите только на три числа: размер кэша, долю попаданий в кэш и среднее время задания. Если кэш экономит 10 секунд, но хранит несколько гигабайт, урежьте его. Если кэш промахивается большую часть времени, исправьте ключ или удалите правило. Если время задач улучшается, но диски раннеров заполняются, сильнее чистите кэши по языкам и инструментам.

Простой принцип помогает: каждый кэш должен заслуживать своё место. Кэширование слоёв зависимостей обычно заслуживает. Кэширование всего checkout — обычно нет.

Если ваша команда всё ещё платит слишком много за медленные pipeline, попросите ещё одну пару глаз посмотреть на схему. Oleg помогает пересматривать правила кэша, подбирать размер раннеров и проектировать CI/CD с практическим упором на стоимость и скорость. Короткая консультация может быстро показать обычные потери, особенно в смешанных монорепозиториях, где мелкие ошибки кэша расползаются по каждому заданию.

Лучший следующий шаг — маленький и измеримый: удалите один широкий кэш, оставьте один узкий и посмотрите на следующие пять прогонов pipeline.