Привет, на связи Олег Казаков из Spectr. В этой статье поговорим о двух важных инструментах GitLab, которые помогают передавать данные между этапами CI/CD-пайплайна — Cache и Artifacts.
Если вы сталкивались с задачами оптимизации пайплайнов или передачи данных между этапами, то наверняка задавались вопросом, чем отличаются эти механизмы и в каких случаях использовать каждый из них.
Мы подробно разберем:
как работают Cache и Artifacts;
их ключевые отличия;
примеры использования для ускорения работы и автоматизации процессов.
Введение
CI/CD представляет собой последовательность этапов, каждый из которых выполняет определенные задачи: от скачивания кода и сборки приложения до тестирования, проверки качества и развертывания. Эти этапы зависят друг от друга, и зачастую возникает необходимость передачи данных между ними.
Gitlab предлагает 2 возможных решения:
GitLab Cache — механизм кеширования файлов, используемый для ускорения выполнения CI/CD-пайплайнов.
GitLab Artifacts — механизм сохранения и передачи файлов между этапами CI/CD-пайплайна.
Звучит почти одинаково, но «дьявол кроется в деталях». Давайте вместе разбираться в этих деталях.
Ниже ответ самого Gitlab, на вопрос чем кеш отличается от артефакта:
Как начать использовать
Для примера будем считать, что у нас уже есть некоторая стадия для сборки зависимостей «build_dependencies» и мы хотим сохранить результат сборки.
Gitlab Cache
build_dependencies:
…….
cache:
paths:
- node_modules/
- .yarn/
В данном случае мы указываем, что в кеш должны сохраниться папки node_modules и .yarn.
Для того чтобы управлять ключом кеша, можно использовать параметр «key». Это может быть как какой-то генерируемый ключ:
build_dependencies:
…….
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- node_modules/
- .yarn/
Так и ключ на основе файлов, то есть при наличии изменений в этих файлах будет изменение и самого ключа кеша:
build_dependencies:
…….
cache:
key:
files:
- yarn.lock
- patches/
paths:
- node_modules/
- .yarn/
Несколько важных нюансов:
Внутри проекта есть настройка кеша, которая позволяет использовать разный кеш для защищенных и незащищенных веток. Иногда это может быть полезно, если на дев-окружении используются дополнительные зависимости.
-
Параметр policy позволяет управлять поведением при работе с кешем. Есть 3 варианта:
pull. В этом случае при выполнении задания кеш только выкачивается в рабочую папку, но если есть изменения в файлах кеша, они не будут сохранены в кеше;
push. В этом случае кеш не выкачивается в рабочую папку, но все файлы, которые подходят под правила, будут сохранены в кеше;
pull-push. Это значение по умолчанию, в этом случае и кеш выкачивается, и все изменения сохраняются в кеше.
Можно использовать несколько ключей для разных кешей в рамках одного задания (например, имеются разные зависимости для разных инструментов). В этом случае надо просто указать несколько ключей внутри cache (до 4 кешей одновременно).
cache:
- key:
files:
- Gemfile.lock
paths:
- vendor/ruby
- key:
files:
- yarn.lock
paths:
- .node_modules/
GitLab Artifacts
build_dependencies:
…….
artifacts:
paths:
- node_modules/
- .yarn/
В данном случае мы указываем, что мы хотим сохранить папки node_modules и .yarn в артефакт.
Также в артефакт можно добавить все неотслеживаемые файлы, то есть файлы, которых нет в репозитории и которые были созданы внутри задания.
build_dependencies:
…….
artifacts:
untracked: true
В данных двух примерах создается артефакт в виде zip-файла, далее он доступен во всех заданиях в рамках пайплайна, то есть данный артефакт автоматически разархивируется в последующих джобах и можно обращаться к файлам из артефакта.
Несколько важных нюансов:
У артефактов есть ключ expire_in, в нем можно указать, в течение какого времени артефакт будет храниться, по умолчанию это один месяц. По истечении этого времени артефакт удалится.
В настройках проекта в Gitlab можно указать, сохранять ли артефакт последнего успешного пайплайна.
В настройках можно указать максимальный размер артефакта, по умолчанию это 100 МБ.
Если превысить размер этого ограничения, то джоб будет падать с такой ошибкой:
Артефакты доступны только в рамках одного пайплайна. То есть через настройку в .gitlab-ci.yml нельзя настроить получение артефакта из какого-то другого пайплайна. Но при этом у артефактов есть API, то есть это ограничение можно обойти и выкачивать нужные артефакты по API, зная ID джоба и имея токен для работы с API.
В отличии от кеша артефакт по итогу работы джоба может быть только один. То есть все файлы, помеченные как артефакт, будут добавлены в один общий архив.
Где хранятся данные
Gitlab Cache
По умолчанию весь кеш хранится на локальном диске Gitlab Runner’а. То есть, если у вас несколько раннеров, которые могут выполнять одни и те же джобы, то на первый взгляд у вас могут быть проблемы, т. к. эти кеши не будут синхронизированы.
Тут есть 2 варианта решения:
Использовать все же только один Gitlab Runner, ограничивать выполнение через теги/настройки проекта.
Использовать распределенный кеш, который позволяет сохранять кеш в s3-хранилище. В этом случае нужно доработать настройки в config.toml для конкретных раннеров, чтобы они сохраняли кеш в s3 хранилище, а не на диск.
GitLab Artifacts
Артефакты хранятся в самом Gitlab. Если это облачный Gitlab, то где-то на серверах Gitlab’а, а если это self-managed-версия, то на вашем же сервере. Между этапами/джобами артефакт передается по https.
Управление данными
Gitlab Cache
Весь кеш хранится на раннерах либо в s3-хранилище, поэтому управление этими данными в основном за пределами Gitlab.
Ниже информация о том, где хранится кеш. Для shell-исполнителей лежит в рабочей папке юзера gitlab-runner, для docker-исполнителей — в Docker-томах.
Единственное, что можно сделать внутри Gitlab, это сбросить весь кеш.
По сути, данная кнопка просто меняет индекс, который содержится в имени папки, в которой лежит кеш, то есть имя формата cache-<index>.
Таким образом, Gitlab «забывает» про существующий кеш и начинает использовать новый. Старый кеш остается лежать мертвым грузом на диске, удалять его нужно вручную.
Если посмотрим на хранение на диске, выше на скрине для Docker-исполнителя
/var/lib/docker/volumes/<volume-id>/_data/<user>/<project>/<cache-key>/cache.zip, где
<volume-id> примерно такого формата: runner-dwkt1irb-project-145-concurrent-0-cache-3c3f060a0374fc8bc39395164f415a70, где dwkt1irb — id раннера;
project-145 — собственно проект с id 145;
concurrent-0 — номер параллельного задания, выполняющегося раннером;
<user>/<project> — это тот же путь до репозитория, что и в Gitlab (включая все группы/подгруппы).
Уже внутри лежат папки с кешем
Часть имени папки посередине — это и есть ключ кеша.
Цифра в самом конце — это как раз индекс кеша, который мы можем увеличивать через кнопку «Clear runner caches» в Gitlab.
Внутри этих папок лежит один архив cache.zip.
GitLab Artifacts
Как я уже писал выше, артефакты хранятся в самом Gitlab, также Gitlab предоставляет довольно функциональное API для работы с артефактами.
Кроме этого, внутри Gitlab есть отдельный раздел для работы с артефактами в рамках проекта. Если зайти на данную страницу, то можно увидеть, что артефакты генерируются всегда, для каждого задания:
Файл job.log — это тот лог, который вы видите при переходе на детальную страницу задания. Если удалить этот файл из артефакта, то на детальной странице джоба появится следующая ошибка:
Артефакт же, который мы сохраняем в рамках джоба, выглядит следующим образом:
artifacts.zip — сам архив со всеми файлами, которые мы указали для сохранения в артефакт.
metadata.gz — это метаданные (как можно догадаться из названия), в которых хранится информация по каждому файлу из архива: путь, размер, дата модификации, права доступа.
Можно скачать файлы, удалить, а также посмотреть детальную информацию о файлах из артефакта (по всей видимости, как раз на основе файла метаданных).
Срок хранения
Gitlab Cache
Как вы могли заметить, параметра expire_in нет в cache, и это довольно странно. Issue с просьбой добавить данный параметр висит уже 5 лет, но пока есть только «workaround» в виде очистки всего кеша.
Точной информации о сроке «жизни» кеша в открытых источниках тоже нет. Где-то говорят одна неделя, где-то один месяц, а где-то пишут, что кеш вообще не удаляется и его приходится удалять вручную. В нашем случае как раз последний вариант — кеш сам по себе не удаляется.
GitLab Artifacts
В отличие от Cache, здесь довольно гибкое управление временем жизни артефактов. Поддерживается множество разных вариантов написания:
Также можно удалять артефакты внутри конкретного джоба через интерфейс/API.
Скорость
Попробуем на каком-то практическом примере понять, как можно значительно оптимизировать время выполнения пайплайна. Допустим, у нас есть репозиторий фронтенда и у нас есть два этапа:
проверка кода линтерами и запуск тестов (в нашем случае это eslint, tsc --noEmit и vitest);
сама сборка docker-образа.
На обоих этапах нужны зависимости, и чтобы нам не устанавливать зависимости каждый раз, добавим отдельную стадию build_dependencies, где будем устанавливать зависимости и сохранять в кеш/артефакт.
Gitlab Cache
Добавим шаблоны для работы с кешем:
.cache_common: &cache_common
key:
files:
- yarn.lock
- patches/
unprotect: true
paths:
- node_modules/
- .yarn/
.cache_pull_push: &cache_pull_push
<<: *cache_common
policy: pull-push
.cache_pull: &cache_pull
<<: *cache_common
policy: pull
cache_common — общая настройка.
cache_pull_push и cache_pull — два шаблона, которые зависят от политики работы с кешем.
Ниже опишем все три стадии:
build_dependencies:
stage: pre-build
image: mcr.microsoft.com/playwright:v1.40.0-jammy
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
tags:
- docker_build
script:
- yarn install --immutable
cache: !reference [.cache_pull_push]
premerge_tests:
stage: premerge
image: mcr.microsoft.com/playwright:v1.40.0-jammy
cache: !reference [.cache_pull]
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
tags:
- docker_build
script:
- yarn premerge
build:
stage: build
image: docker:23.0.6
cache: !reference [.cache_pull]
before_script:
- cat "$ENV_FILE" > ".env.production"
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
services:
- docker:23.0.6-dind
rules:
- if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"
- when: never
tags:
- docker_build
script:
- docker build -f docker/Dockerfile -t ${IMAGE_NAME}:${CI_COMMIT_SHORT_SHA} .
- docker push ${IMAGE_NAME}:${CI_COMMIT_SHORT_SHA}
build_dependencies — задача для сборки зависимостей, кеш с политикой pull-push, если есть, мы его выкачиваем и затем заменяем кеш.
premerge_tests — задача для проверок и тестов, кеш с политикой pull, то есть только выкачивается.
build — сборка docker-образа, кеш тоже с политикой pull.
Видим, что build_dependencies и premerge_tests выполняются в момент создания MR в ветку main, а build выполняется уже при принятии MR’а, когда все проверки пройдены успешно.
При первом запуске получилось следующее время:
Примерно 2,5 минуты происходит сборка без кеша, далее почти столько же выполняются тесты (это уже с валидным кешем) и затем 5 минут идет сборка.
То есть за счет введения build_dependencies стадии мы уже экономим 2,5 минуты этапа сборки.
При повторном запуске, когда кеш валиден, видим следующий результат:
Результат тестов и сборки в рамках погрешности, а вот установка зависимостей ускорилась более чем в 2 раза.
Теперь попробуем проверить как будет работать с распределенным кешем, для этого обновим конфиг раннера и проверяем еще раз.
Видим, что теперь кеш сохраняется в s3:
Смотрим результат:
Опять же в пределах погрешности, то есть по скорости работы с s3 не уступает локальному хранению. Конечно, тут есть много нюансов в скорости диска и скорости интернета на конкретном сервере, но в целом извлечение кеша занимает малую долю во времени выполнения джоба (в нашем случае примерно 215 МБ менее чем за 20 секунд), поэтому можем считать, что выбор варианта доставки файлов на скорость выполнения задач особо не влияет.
Следующий шаг оптимизации: сейчас у нас в build_dependencies происходит попытка скачать кеш, далее установить зависимости и затем запушить кеш. Мы понимаем, что нам нет смысла ставить зависимости, если уже есть валидный кеш, поэтому можем доработать скрипт следующим образом:
build_dependencies:
stage: pre-build
image: mcr.microsoft.com/playwright:v1.40.0-jammy
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
tags:
- docker_build
script:
- if [ -d "node_modules" ]; then echo "кеш существует, пропускаем"; else yarn install --immutable; fi
cache: !reference [.cache_pull_push]
То есть мы добавили проверку на наличие папки node_modules, и если она существует на момент запуска скрипта, значит есть кеш и он успешно скачался. Ниже результат:
Удалось ускорить примерно на 20–25 секунд.
Но опять же есть неэффективность, которую многие заметят в случае наличия валидного кеша, — мы его выкачиваем и затем пушим обратно. Именно это, по сути, и выполняется эти 50 секунд. Что можно сделать?
Можно использовать очень эффективный параметр changes в rules, который позволяет ограничить добавление задания в пайплайн в зависимости от того, были ли изменения в определенных файлах. В нашем случае мы можем просто добавить в ограничение те же файлы, из которых состоит ключ кеша:
build_dependencies:
stage: pre-build
image: mcr.microsoft.com/playwright:v1.40.0-jammy
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
changes:
- yarn.lock
- patches/*
tags:
- docker_build
script:
- if [ -d "node_modules" ]; then echo "кеш существует, пропускаем"; else yarn install --immutable; fi
cache: !reference [.cache_pull_push]
Теперь, если не было изменения в этих файлах, джоб build_dependencies не запускается:
Вроде удалось максимально оптимизировать, единственный нюанс в последней реализации: если по какой-то причине не будет валидного кеша, а изменений в указанных файлах не будет, то последующие джобы упадут. Решается это либо добавлением какого-то изменения в yarn.lock или в директорию patches, либо временно можно убрать changes в rules, чтобы создался валидный кеш, и затем вернуть это изменение обратно.
GitLab Artifacts
Попробуем решить ту же задачу используя функциональность Gitlab Artifacts.
Как уже отмечалось ранее, по умолчанию артефакты передаются только между этапами одного пайплайна. Это значит, что у нас при старте нового пайплайна будет создаваться новый артефакт с нуля, то есть тут мы можем сравнить время с самым первым запуском в Gitlab Cache, когда у нас не было никакого кеша.
Получаем примерно те же секунды, что и в первой реализации работы с кешем, когда кеша еще нет. Чуть подольше получилась стадия тестов, но чуть быстрее стадия сборки (при сборке специально используется параметр --no-cache, чтобы кеш докера не влиял на время работы джоба).
А дальше, по сути, все — другой реализации по умолчанию в .gitlab-ci.yml не предусмотрено. Но, как я писал ранее, есть довольно функциональный API, который позволяет скачивать артефакт, если знаешь id джоба.
Подходим к этому творчески и получается следующий план:
Пишем скрипт, который по id джоба выкачивает артефакт и разархивирует его (я написал на go).
Пишем свой шаблон создания хеша на основе yarn.lock и папки patches (то есть аналогично ключу кеша)
.hash: &hash
- HASH=$(([ -d patches ] && find yarn.lock patches -type f -exec sha256sum {} + | sort | sha256sum || sha256sum yarn.lock) | awk '{ print $1 }')
Нам нужно хранить где-то состояние, то есть привязку хешей к id джобов. Для этого я использовал специально созданную для этого переменную окружения. Формат хранения: json в виде хеш таблицы (ключ — это хеш, значение — id джоба).
Таким образом, внутри build_dependencies происходит проверка, и если есть хеш в переменной окружения и там существует артефакт, то на этом джоб завершается. Если артефакта нет или это новый хеш, то происходит установка зависимостей, создание артефакта и обновление значения в переменной окружения.
Ниже результат:
Если сравнивать с последней реализацией Gitlab Cache, то у нас есть лишний джоб, но он занимает всего 10 секунд, что примерно в пределах погрешности по общему времени выполнения всего пайплайна. При этом в отличие от cache, нам не нужно будет заниматься «танцами», если вдруг по какой-то причине не будет валидного кеша при отсутствии изменений в yarn.lock и patches.
Но из минусов: это необходимость реализации своего собственного скрипта, который нужно поддерживать, также довольно сильно усложняется и сам .gitlab-ci.yml.
Итог
Ниже таблица сравнения по тем пунктам, которые мы рассмотрели выше.
Характеристика |
GitLab Cache |
GitLab Artifacts |
Основное назначение |
Оптимизация и ускорение пайплайнов |
Передача файлов между этапами |
Где хранятся данные? |
На уровне Runner'а, либо в распределенном хранилище (s3) |
На уровне проекта в GitLab |
Срок хранения |
Неограниченный, нужно очищать самому |
Гибко настраиваемый в .gitlab-ci.yml |
Доступность файлов |
Только в контексте Runner'а (локально на сервере с раннером, либо в s3-хранилище) |
Доступ через веб-интерфейс и API |
Скорость |
Высокая (локальный доступ) либо через https при распределенном хранении (может быть медленнее, зависит от сети) |
Через https (может быть медленнее, зависит от сети) |
Вернемся к тому, с чего все началось в этой статье. Сам Gitlab в своей документации пишет, что Gitlab Cache лучше использовать для зависимостей, и мы на практике в этом убедились. Как раз под это Gitlab Cache лучше всего подходит довольно понятная и простая настройка в .gitlab-ci.yml. Есть только нюансы, с которыми нужно будет мириться:
Пока что нет возможности управления временем «жизни» кеша и придется либо удалять вручную, либо придумывать какой-то скрипт для этого.
Есть ограничение в том, что кеш по умолчанию хранится локально внутри раннера. Чтобы это обойти, придется настраивать распределенное s3-хранилище (например, MinIO), либо использовать облачную версию.
Gitlab Artifacts больше всего подходит для передачи какой-то технической информации между этапами одного пайплайна (например, номер версии). За счет функциональности API можно существенно расширять возможности, но это довольно сильно увеличивает сложность сопровождения.
Комментарии (2)
Zerpico
24.12.2024 22:49Artifacts тоже можно хранить в S3 через настройки artifacts_object_store_*
https://docs.gitlab.com/ee/administration/object_storage.html#transition-to-consolidated-form
vasyakrg
На счет пятилетнего запроса на управление сроком жизни кеша в раннерах: видимо не добавляют, т.к. сами хранят распределенно и чистят политиками на бакетах в s3, собственно как и я делаю.
Другой вопрос, что один и тот же раннер может и дев и прод собирать, а хранит всё в одном бакете, тут конечно хотелось бы крутилку.
В любом случа, кеши на раннерах выглядят в целом симпатичнее (если не мульти сборка из разных репо конечно млм не сборка конечного релиза-артефакта), а тех. значения можно передать и вычитывая их из файлов в репе в переменные в пре-скрипах и передавая по шагам.