infra
August 11, 2022

Чистка build-агентов Gitlab

Спешим поделиться с вами нашим инструментом для поддержания чистоты и порядка на наших (RNDSOFT) сборочных серверах gitlab-janitor. О том, как мы к нему пришли, и каков первый опыт - далее по тексту.

С каждым годом инфраструктура RNDSOFT, обеспечивающая процессы CI/CD, растёт. Появляются новые проекты, усложняются процессы (pipelines) сборки и тестирования, растет количество сборщиков (build agents), и всё это приносит дополнительные накладные расходы. Сегодня мы рассмотрим конкретную проблему - чистку/освобождение ресурсов на самих сборочных серверах. Мы используем Gitlab и несколько Gitlab runners с докером (docker executor) под капотом. Вот с проблем и начнём.

Проблемы

Вся терминология будет опираться на Gitlab, но всё это применимо и к другим решениям, опирающимся на docker.

Типовой процесс сборки и доставки состоит из 4х больших этапов (stages), по несколько задач (jobs) в каждом:

  • сборка docker-образа (обычно одного на проект, но бывает и больше).
  • тестирование. Тут мы используем подход, когда тестируем не сам код, а весь контейнер. Конечно, есть юнит-тесты, интеграционные, графические и прочие, но запускаем мы их непосредственно внутри боевого контейнера. А что? У нас ruby - можем себе позволить 💎 :)
  • тэгирование (image promoting). Этап в зависимости от результатов тестов и QA перетегируется во что-то вроде stable или release, или как-то еще, в зависимости от конкретного проекта, команды и принятых процессов.
  • доставка (deploy). Тут всё как обычно - дев/тест стенды, QA-стенды, динамические стенды для MR и всякое разное вроде документации.

Давно хочу написать отдельно про процесс image promotiong, про то, как мы таскаем образы между стадиями, но руки не доходят. Если кого сильно интересует - пишите в комментариях, и мне придётся найти в себе силы :)

В результате описанных выше процессов (особенно тестирования) на сборщиках остаётся много мусора, именно это является проблемой. Далее по порядку.

Подвисшие контейнеры (dangling containers)

Тут речь, конечно, идёт не о повисании ПО внутри контейнера, а о самих "ненужных" контейнерах. Например, для графических тестов поднимается целый стек: Postgres, Chrome, Selenium, само приложение, контейнер, из которого запускаются тесты, Redis и бог еще знает что. Если весь процесс (pipeline) или непосредственно задача (job) будут прерваны, то никто не погасит за нас эти контейнеры, и они продолжат висеть и потреблять ресурсы. Проблема!

Ненужные образы

В процессе сборки имя образа претерпевает примерно следующие изменения: image:$SHA -> image:stable -> image:release. Конечно, это примерно, шагов может больше или именования другие, но сути это не меняет. При последовательных комитах сратыестарые образы остаются лежать мёртвым грузом.

В день мы генерим примерно 75GB образов. ~25GB на каждом из трех сборщиков. И это летом, когда многие в отпуске :)

Ситуация усугубляется тем, что один и тот же образ находится (с максимальной вероятностью) сразу на всех сборщиках, поскольку разные типы тестов запускаются параллельно и попадают на все сборщики и занимают x3 места. Проблема! Дважды проблема, поскольку с образами всё не так просто и очень интересно - об этом ниже.

Безымянные и кеширующие тома (unnamed and cache volumes)

Многие контейнеры при создании аллоцируют в докере временные анонимные тома/диски (мы же всегда про докер говорим, верно?) и оставляют их после себя. Также сам Gitlab, если вы не используете распределённые кеш (shared cache) на базе S3, всё равно создаёт кеш-тома, если ему явно не запретить это делать через disable_cache. Проблема!


Конечно, эти проблемы не простой "мусор" - старые образы могут ускорить последующую сборку, кеш-тома также теоретически могут ускорять сборку, так что они не совсем бесполезны. Однако, проблемы ускорения и кеширования мы решаем немного иначе - с помощью определённых схем именования образов, используя buildkit, который умеет подтягивать кешированные слои прямо из реестра образов (в нашем случае harbor) и пр.

Что же делать?

Проблемы появляются не сразу. Когда-то у нас был один сборщик, там вообще не было проблем с перетягиванием образов между этапами, а образы чистились через docker rmi на последнем шаге процесса. Когда сборщиков стало больше, и на них докинули ресурсы, пришлось немного усложнить схему - сначала были bash-скрипты, которые по расписанию удаляли образы по шаблону имени. Затем периодически стали использовать docker system prune. Но эти решения очень негибкие, плохо поддерживаются и масштабируются и обладают фундаментальной проблемой - частыми кеш-промахами (cache miss), что периодически сильно тормозило процессы (pipelines). И вот однажды терпеть это стало невозможно 😜 :)))

Нам нужно хорошее решение, желательно с баристой и массажисткой! Встречаем - gitlab-janitor!

Gitlab-janitor

Основными задачами были:

  • консолидация всех чисток сборщика в одном месте;
  • уменьшение кеш-промахов, насколько это возможно;
  • простота работы.

Для чистки места на сборщиках уже есть проект gitlab-runner-docker-cleanup, он и явился идейным вдохновителем нашей утилиты, но, к сожалению, не выполнял всех необходимых нам функций.

Написав своё решение, мы решили опубликовать его в открытом виде - и зеркало на github, и образы на docker-hub, и даже документация с примерами. Ну и не мог я удержаться от разнообразных бейджиков :)

Проще всего запускать его в докере на каждом сборщике (для этого мы используем nomad 🥂 ):

docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v /persistent/janitor:/store \
  -e REMOVE=true \
  -e INCLUDE="*integr*, *units*" \
  -e EXCLUDE="*gitlab*" \
  -e CONTAINER_DEADLINE="1h10m" \
  -e VOLUME_DEADLINE="4d" \
  -e IMAGE_DEADLINE="4d" \
  -e CACHE_SIZE="10G" \
  -e IMAGE_STORE="/store/images.txt" \
  rnds/gitlab-janitor:latest

Заметили IMAGE_STORE? С остальными параметрами всё просто - шаблоны для имён образов, томов, сроки хранения, а вот для образов всё гораздо интереснее!

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

Для решения этой проблемы в gitlab-janitor сделан простенький механизм - когда образ встречается первый раз, он сохраняется в файл с временной меткой - это считается датой появления. Когда подходит срок - образ удаляется. Однако, если gitlab-janitor увидит созданный контейнер из этого образа - то временная метка в этом файле сбрасывается. Именно для этого и используется параметр IMAGE_STORE. Данный файл можно монтировать с хост-машины, а можно и нет - в этом случае история образов будет теряться при пересоздании/обновлении контейнера-чистильщика:

selenium/standalone-chrome:latest sha256:c01aea5eb0bf279df5f745e3c277e30d7d9c81f15b9d1d4e829f1075c31ed5b1 1660205645
postgres:10-alpine sha256:d7023df56cb7cbe961f0c402888f7170397c98c099fb76fbe16b5442a236ad51 1660205040
redis:latest sha256:3edbb69f9a493835e66a0f0138bed01075d8f4c2697baedd29111d667e1992b4 1660205645

С остальными параметрами, документацией и примерами можно ознакомиться на гитхабе.

Результаты

Вот и подъехали результаты объективного контроля за работой gitlab-janitor на трех наших сборщиках:

Все чистки с помощью cron+bash, docker prune и пр. выключены. Параметры, оптимально подходящие под наши процессы, мы еще подбираем (и будем подбирать), но уже видно, что процесс вошел в стабильную фазу, и наш чистильщик выполняет свою работу!