Сравнение Gitlab cache и Gitlab artifacts

Привет, на связи Олег Казаков из 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/

Несколько важных нюансов:

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

202d61b1bdf3c48c97046f78bc975078.png

  • Параметр 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 можно указать, сохранять ли артефакт последнего успешного пайплайна.

c93ee8ca4bae9ff93e9449569f316752.png

В этом случае самый последний артефакт не будет удален до тех пор, пока не появится новый успешный пайплайн, даже если прошло время expire_in.

b92ce497da6b3febb8644c2932b28871.png

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

7a4cc4d808d7024ad6b7464d6ab896ab.png

  • Артефакты доступны только в рамках одного пайплайна. То есть через настройку в .gitlab-ci.yml нельзя настроить получение артефакта из какого-то другого пайплайна. Но при этом у артефактов есть API, то есть это ограничение можно обойти и выкачивать нужные артефакты по API, зная ID джоба и имея токен для работы с API.

  • В отличии от кеша артефакт по итогу работы джоба может быть только один. То есть все файлы, помеченные как артефакт, будут добавлены в один общий архив.

Где хранятся данные

Gitlab Cache

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

Тут есть 2 варианта решения:

  1. Использовать все же только один Gitlab Runner, ограничивать выполнение через теги/настройки проекта.

  2. Использовать распределенный кеш, который позволяет сохранять кеш в s3-хранилище. В этом случае нужно доработать настройки в config.toml для конкретных раннеров, чтобы они сохраняли кеш в s3 хранилище, а не на диск.

GitLab Artifacts

Артефакты хранятся в самом Gitlab. Если это облачный Gitlab, то где-то на серверах Gitlab«а, а если это self-managed-версия, то на вашем же сервере. Между этапами/джобами артефакт передается по https.

e6fcb83bfdbd5e74e79f00ba1f2a3edf.png

Управление данными

Gitlab Cache

Весь кеш хранится на раннерах либо в s3-хранилище, поэтому управление этими данными в основном за пределами Gitlab.

Ниже информация о том, где хранится кеш. Для shell-исполнителей лежит в рабочей папке юзера gitlab-runner, для docker-исполнителей — в Docker-томах.

b4374a83c427722d6dea3813b0787325.png

Единственное, что можно сделать внутри Gitlab, это сбросить весь кеш.

e56220b8d44e798f63f684817b11ebc7.png

По сути, данная кнопка просто меняет индекс, который содержится в имени папки, в которой лежит кеш, то есть имя формата cache-.

543ac0cbc684c83aa26c690aa4f09c14.png

Таким образом, Gitlab «забывает» про существующий кеш и начинает использовать новый. Старый кеш остается лежать мертвым грузом на диске, удалять его нужно вручную.

Если посмотрим на хранение на диске, выше на скрине для Docker-исполнителя 

/var/lib/docker/volumes//_data////cache.zip, где

примерно такого формата: runner-dwkt1irb-project-145-concurrent-0-cache-3c3f060a0374fc8bc39395164f415a70, где  dwkt1irb — id раннера;  

project-145 — собственно проект с id 145;

concurrent-0 — номер параллельного задания, выполняющегося раннером;

/ — это тот же путь до репозитория, что и в Gitlab (включая все группы/подгруппы).

Уже внутри лежат папки с кешем

da50de0d714024a5e0ab1ef2097f33bb.png

Часть имени папки посередине — это и есть ключ кеша.

Цифра в самом конце — это как раз индекс кеша, который мы можем увеличивать через кнопку «Clear runner caches» в Gitlab.

Внутри этих папок лежит один архив cache.zip.

GitLab Artifacts

Как я уже писал выше, артефакты хранятся в самом Gitlab, также Gitlab предоставляет довольно функциональное API для работы с артефактами.

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

cb661d3d3f689a521d10e5943444d168.png

Файл job.log — это тот лог, который вы видите при переходе на детальную страницу задания. Если удалить этот файл из артефакта, то на детальной странице джоба появится следующая ошибка:

cd1bffd11f32e2852f74473f1d6f3b31.png

Артефакт же, который мы сохраняем в рамках джоба, выглядит следующим образом:

96cca1ddec5fbf02d9209d9980bd6a84.png

artifacts.zip — сам архив со всеми файлами, которые мы указали для сохранения в артефакт.

metadata.gz — это метаданные (как можно догадаться из названия), в которых хранится информация по каждому файлу из архива: путь, размер, дата модификации, права доступа.

Можно скачать файлы, удалить, а также посмотреть детальную информацию о файлах из артефакта (по всей видимости, как раз на основе файла метаданных).

63eba084ea9edeba32238b367085a7ad.png

Срок хранения

Gitlab Cache

Как вы могли заметить, параметра expire_in нет в cache, и это довольно странно. Issue с просьбой добавить данный параметр висит уже 5 лет, но пока есть только «workaround» в виде очистки всего кеша.

Точной информации о сроке «жизни» кеша в открытых источниках тоже нет. Где-то говорят одна неделя, где-то один месяц, а где-то пишут, что кеш вообще не удаляется и его приходится удалять вручную. В нашем случае как раз последний вариант — кеш сам по себе не удаляется.

GitLab Artifacts

В отличие от Cache, здесь довольно гибкое управление временем жизни артефактов. Поддерживается множество разных вариантов написания:

e4dd29889901d1024ced2b6c492cda8d.png

Также можно удалять артефакты внутри конкретного джоба через интерфейс/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«а, когда все проверки пройдены успешно.

При первом запуске получилось следующее время:

cc8ff175cadb7e274c916f4b78f636b3.png

Примерно 2,5 минуты происходит сборка без кеша, далее почти столько же выполняются тесты (это уже с валидным кешем) и затем 5 минут идет сборка.

То есть за счет введения build_dependencies стадии мы уже экономим 2,5 минуты этапа сборки.

При повторном запуске, когда кеш валиден, видим следующий результат:

adf7d59fac95b0a966593f88fe52c74b.png

Результат тестов и сборки в рамках погрешности, а вот установка зависимостей ускорилась более чем в 2 раза.

Теперь попробуем проверить как будет работать с распределенным кешем, для этого обновим конфиг раннера и проверяем еще раз. 

Видим, что теперь кеш сохраняется в s3:

ba0d5f81863464b8dc6a59052f1a5df5.png

Смотрим результат:

6d687b4e88d3d35052fb26bbd1830c15.png

Опять же в пределах погрешности, то есть по скорости работы с s3 не уступает локальному хранению. Конечно, тут есть много нюансов в скорости диска и скорости интернета на конкретном сервере, но в целом извлечение кеша занимает малую долю во времени выполнения джоба (в нашем случае примерно 215 МБ менее чем за 20 секунд), поэтому можем считать, что выбор варианта доставки файлов на скорость выполнения задач особо не влияет.

8ed33d87244125d00acad872d60ba7de.png

Следующий шаг оптимизации: сейчас у нас в 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, и если она существует на момент запуска скрипта, значит есть кеш и он успешно скачался. Ниже результат:

5a15759b79db2b4d2f03ed53421a71d9.png

Удалось ускорить примерно на 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 не запускается:

e42f931438e777f94a3ab331a537d615.png

Вроде удалось максимально оптимизировать, единственный нюанс в последней реализации: если по какой-то причине не будет валидного кеша, а изменений в указанных файлах не будет, то последующие джобы упадут. Решается это либо добавлением какого-то изменения в yarn.lock или в директорию patches, либо временно можно убрать changes в rules, чтобы создался валидный кеш, и затем вернуть это изменение обратно.

GitLab Artifacts

Попробуем решить ту же задачу используя функциональность Gitlab Artifacts.

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

c59e19a8ba76bab7f735c363105dc78a.png

Получаем примерно те же секунды, что и в первой реализации работы с кешем, когда кеша еще нет. Чуть подольше получилась стадия тестов, но чуть быстрее стадия сборки (при сборке специально используется параметр --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 происходит проверка, и если есть хеш в переменной окружения и там существует артефакт, то на этом джоб завершается. Если артефакта нет или это новый хеш, то происходит установка зависимостей, создание артефакта и обновление значения в переменной окружения.

Ниже результат:

bd54b35606856ba395f3ae15bc837416.png

Если сравнивать с последней реализацией 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 можно существенно расширять возможности, но это довольно сильно увеличивает сложность сопровождения.

© Habrahabr.ru