Переезд с TeamCity на GitLab CI + K8s
Привет, Хабр! Меня зовут Даниил Мильков, я старший C# разработчик в команде Формы, которая входит в состав продукта Контур.Экстерн. Если вкратце, Экстерн позволяет бизнесу сдавать отчётность в контролирующие органы (ФНС, Росстат и тп.) через интернет.
Сразу хочу предупредить читателей, что про взаимодействие с k8s здесь сказано достаточно мало, разве что в разделе Kubernetes и PVC. На эту тему будет отдельная статья.
Начнём. Однажды наша команда решила перейти с TeamCity на GitLab CI…
Почему мы решили перейти на GitLab CI
TeamCity ушёл из России. Лицензии сложно получить, обновления недоступны, нет техподдержки, проблемы приходится исправлять на ощупь.
Одновременно с этими событиями из нашей команды ушли все инженеры-эксплуататоры и остались только разработчики. Им не хотелось заниматься поддержкой огромного парка виртуалок.
Контейнеры — мировой стандарт. В мире активно развивается использование контейнеров и оркестраторов, в частности Kubernetes. Мы тоже это хотим. Тем более, это согласуется как с нашим нежеланием заниматься виртуалками, так и с целями команды техкачества по переходу на Linux и shared-хосты. А ещё мы хотим, чтобы наши сервисы были доступны как on-premise решение, которое очень удобно (а чаще — необходимо) распространять, как контейнеры. В общем, без этого никуда.
Миграция
Переезжать мы хотели так, чтобы пользовательский опыт не менялся радикальным образом, и поэтому постарались сделать интерфейс CI в GitLab максимально приближенным к ТС. Как будто что-то поменялось, стало чуть-чуть непривычно, но в целом кнопочки остались те же самые.
С нашей же точки зрения, как тех, кто занимался этим переездом, это были весьма существенные перемены. Изменились:
Инструмент (TeamCity на GitLab)
Операционная система (Windows на Linux)
Способ хостинга рабочей нагрузки (виртуальные машины на контейнеризацию в куберовском кластере)
Общие шаблоны
К тому моменту, как мы решили заезжать на GitLab, команда девопсов базовой инфраструктуры уже подготовила общие шаблоны конфигураций GitLab CI.
В GitLab CI шаблоны (templates) — это готовые части конфигураций, которые можно переиспользовать в разных проектах. На основе этих шаблонов получилось быстро спрототипировать первые джобы на новой платформе. Но сейчас мы практически не используем общие шаблоны, потому что:
Они ориентированы только на небольшие проекты без хитрых сценариев, сложно кастомизируемы.
Зачастую сложно разбираться в параметрах, которые выполнены в виде системных переменных. Не всегда очевидно, какое эффективное значение переменной попадает в итоге в конечную команду.
Эти ограничения касаются всех общих шаблонов, которые может сделать кто угодно. Сделать их более удобными и при этом функциональными очень сложно. Может быть, вовсе невозможно. Не зря gitlab добавили в CI конфигурацию step«ы.
Несмотря на то что использование таких шаблонов зачастую неудобно, есть сценарии, когда это оправдано. Например, мы с их помощью запускаем анализатор кода для дотнетовского солюшена. Поэтому посмотрим, как выглядит минимальная CI конфигурация с использование общих шаблонов:
include:
- project: 'idevops/ci-templates' # Проект, из которого подключается шаблон
file: 'all.yaml' # Файл, который подключает все доступные шаблоны в вашем проекте
stages:
- inspect-code
Inspect code: # имя джобы
stage: inspect-code # в какой stage входит эта джоба
extends:
- .dotnet_inspect_code # имя шаблона, который хотим использовать
variables: # переменные, с помощью которых настроим поведение шаблона
DOTNET_PROJECT_OR_SOLUTION_PATH: "path/to/solution/file" # относительно корня вашего репозитория
Можно вставить это в файл .gitlab-ci.yml
в корне вашего проекта, подставить путь до солюшена, и всё заработает.
Найти свой пайплайн можно в разделе Pipelines вашего репозитория (Build → Pipelines)

Пример нашей джобы
А вот как выглядит джоба уже без общих шаблонов:
Integration tests:
extends: .test-base
variables: # Конфигурируем лимиты и реквесты для кубера
KUBERNETES_CPU_REQUEST: "3"
KUBERNETES_CPU_LIMIT: "10"
KUBERNETES_MEMORY_REQUEST: "12Gi"
KUBERNETES_MEMORY_LIMIT: "24Gi"
KUBERNETES_EPHEMERAL_STORAGE_REQUEST: "17Gi"
script: | # Скрипт с основной работой
#
.gitlab-ci/common/scripts/dotnet_restore_and_build.sh
.gitlab-ci/common/scripts/resource_build.sh
.gitlab-ci/common/scripts/start-services.sh
.gitlab-ci/stages/tests/integration-tests.sh
Команды вынесены в файлы .sh
только для того, чтобы оформить удобное логирование.
И примерно так, например, выглядит dotnet_restore_and_build.sh
#!/bin/bash
source $(dirname "$0")/logging.sh
solution="${1:-Main.sln}"
args="${3:-""}"
# В функции startLogCollapsedSection в консоль пишется специальную страшную строка (приведу её ниже)
# по ней гитлаб понимает, что надо начать секцию, которую можно
# схлапывать и для которой автоматически посчитается время выполнения
section_id="dotnet build $solution"
startLogCollapsedSection "$section_id"
dotnet build $solution --configuration Release $args
return_code=$?
# Здесь в консоль пишется строка для закрытия секции
endLogSection "$section_id"
if [ $return_code -ne 0 ]; then
logError "Ошибка при билде $solution (exitCode $return_code)"
fi
exit $return_code
Страшные строки для закрытия и открытия секций:
echo -e "\e[0Ksection_start:`date +%s`:section_id_dotnet_build_main_sln[collapsed=true]\r\e[0K\e[36;1mdotnet build Main.sln\e[0;m\e[0;m"
echo -e "\e[0Ksection_end:`date +%s`:section_id_dotnet_build_main_sln\r\e[0K"
А за подключенным в секции extends
уже нашим шаблоном .test-base
скрываются такие строки (общие для всех наших тестовых джоб):
.test-base:
stage: tests
needs: [] # Говорит о том, что джоба не должна ждать никакие джобы в предыдущем stage
services: # рядом с контейнером джобы будут запущены контейнеры с указанными образами
- name: docker-proxy.host/elasticsearch:6.6.0 # в данном случае c elasticsearch - он нужен для ряда интеграционных тестов
command: [ "bin/elasticsearch", "-Expack.security.enabled=false", "-Ediscovery.type=single-node" ]
before_script: # команды, которые выполняются до основной секции script
- !reference [.common_before_script, before_script] # здесь шаблон общий вообще для всех джоб, так выполняется преднастройка джобы.
- # В этой части мы скачиваем дополнительные репозитории, которые нужны джобе (см. "Работа с несколькими репозиториями")
script:
- echo "Hello, Dan! Replace 'script' section in your new job"
artifacts: # настройка артефактов, которые надо будет загрузить в хранилище после выполнения джобы
when: always # always - загружать всегда, даже если джоба упала
expire_in: 1 week # ttl
paths: # маски путей, где надо искать артефакты для загрузки
- $CI_PROJECT_DIR/junit/*.xml
- $CI_PROJECT_DIR/annotations/*.json
- $CI_PROJECT_DIR/logs/**/*.log
reports: # некоторые из артефактов гитлаб может обрабатывать по особому
junit: $CI_PROJECT_DIR/junit/*.xml # строить тестовый отчёт
annotations: $CI_PROJECT_DIR/annotations/*.json # добавлять кастомную информацию в джобу
Что наделали, чтобы переехать
Аналитика тестовой истории
Гитлаб коммитоцентричен. Это здорово. Но вот такого разреза данных, то есть истории по запускам какой-то джобы, из коробки там нет.

Как и истории конкретного тест-кейса. Единственное встроенное, что есть в гитлабе, это количество падений тест-кейса за последние две недели. Не очень информативно.

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

Подробный тестовый отчёт. В нём можно отфильтровать тест-кейсы по имени, по статусу или отсортировать по длительности выполнения. Встроенное в гитлаб отображение тестового репорта такие фокусы делать не умеет. А ещё тут можно скачать список тест-кейсов в csv-формате и даже проанализировать, какие массивы тест-кейсов отнимают больше всего времени с использованием диаграммы treemap.

А это история конкретного тест-кейса:

Чтобы из джобы был удобный доступ к этому сервису, добавили в неё пару аннотаций со ссылками на подробный тестовый отчёт и на историю по конкретному типу джобы:

Пара слов про внутрянку:
Данные хранятся в ClickHouse (CH). Бекенд проксирует SQL запросы с фронта до CH
Попадают данные в базу через краулер, который ходит через GitLab API по джобам, скачивает их артефакты и парсит JUnit xml отчёт внутри. То есть для того чтобы ваши тестовые отчёты появились в сервисе, вам ничего не надо менять ни в вашей ci конфигурации, ни в коде. Достаточно поднять сам сервис, CH, и краулер (либо отправлять данные в CH любым способом по вашему желанию)
Сейчас мы уже вынесли решение за пределы своей команды, подключив к нему пару других, а в самом ближайшем будущем планируем сделать, чтобы для пользователей GitLab CI в Контуре этот инструмент был доступен из коробки.
Если в комментах будет большое количество желающих использовать такой сервис, подумаем, над тем, как можно вынести его во вне.
Отчёт о кодовых инспекциях
В GitLab нет возможности просматривать отчёт о кодовых инспекциях, как это опять же реализовано в TeamCity:

На самом деле возможность есть, но только в платной версии GitLab. Мы сделали удобное отображение этого отчёта, через банальный рендер его в html.
О каких вообще инспекциях идёт речь. Мы для анализа кода запускаем утилиту jetbrains«a — inspectcode
, которая распространяется через dotnet tool JetBrains.ReSharper.GlobalTools
. Иногда эти проверки показывают действительно серьёзные проблемы, так что очень рекомендую их использовать.
Чтобы воспользоваться этим рендером необходимо:
Выполнить в джобе проверки с помощью inspectcode, сохранив при этом файл с отчётом по пути $CI_PROJECT_DIR/inspectcode/code-inspection.xml
Добавить себе в репозиторий файл code-inspection-after-script.sh
Вызвать этот скрипт после завершения работы анализатора. Он сгенерирует рендер отчёта в $CI_PROJECT_DIR/inspectcode/code-inspection.html и аннотацию в $CI_PROJECT_DIR/annotations/$(date +%s).json
Загрузить получившуюся html страницу с отчётом в артефакты
Добавить аннотацию в джобу
Пример итоговой джобы при использовании общего шаблона, за которым фактически спрятан только запуск inspectcode, выглядит так.
Inspect code:
stage: inspect-code
extends:
- .dotnet_inspect_code
variables:
DOTNET_PROJECT_OR_SOLUTION_PATH: "path/to/solution/file”
after_script: code-inspection-after-script.sh # путь до скачанного скрипта
artifacts:
when: always
expire_in: 1 week
paths:
- $CI_PROJECT_DIR/inspectcode/*
- $CI_PROJECT_DIR/annotations/*.json
reports:
codequality:
- $CI_PROJECT_DIR/inspectcode/gl-code-quality-report.json
annotations: $CI_PROJECT_DIR/annotations/*.json
В джобе появится вот такая ссылка, которая ведёт на отрендеренный отчёт:

Сам отчёт выглядит так:

Работа с несколькими репозиториями
Для большинства наших конфигураций требуются сразу несколько реп: репа с сервисами, несколько реп с ресурсами, репа с фронтом. Соответственно, нужна была логика работы с репозиториями, похожая на работу VCS Roots в TeamCity — чтобы дополнительные репозитории в репе фетчились и чекаутились на нужную ветку, если её нет, то фетчились на дефолтную, которая может быть разной для каждого репозитория, если репа ещё не склонирована — склонировать, когда надо — почистить репу и тд. И отвечая на очевидный вопрос — нет, мы не хотели связываться с git submodules.
Реализовали нужную функциональность на шарпе в виде консольной утилиты, потому что так проще тестировать, логи и метрики из коробки, и, конечно, чтобы не писать это на bash.
Пример использования:
ci-utils clone --remotePath="gitlab/repo/path/" --localPath="$CI_PROJECT_DIR/../repo" --branch=$CI_COMMIT_BRANCH --defaultBranch="master"
Shared resources
Shared resources — полезная фича TeamCity. Вы указываете массив строк, например имена площадок. Конфиги, которые используют этот shared resources, могут брать один из этих ресурсов и блокировать его. В этом случае остальные конфигурации не смогут получить доступ к занятому ресурсы, а если свободных ресурсов нет, то встанут в очередь.
Эта фича очень пригодилась нам для реализации автоматического прогона системных тестов. В них мы запускаем сервисы и ресурсы с нужного коммита, в одном из четырех окружений.
Зачем нам эти окружения. Каждое из окружений соответствует площадке (VM), на которых у нас развернут ключевой сервис продукта, назовём его K
. Cервис K, к сожалению, ещё на старой технологии хостинга и не готов к динамическим окружениям. Именно для этого нам и нужны shared resources — чтобы брать свободную площадку с сервисом K.
Что мы с этим сделали: реализовали занимание ресурсов на основе распределенной блокировки и json-хранилища также в виде консольной утилиты.
Пример использования:
ci-utils getResource --resources="stg1,stg2,stg3,stg4" --user=$CI_JOB_ID --output="$CI_PROJECT_DIR/captured_resource"
ci-utils freeResource --resources="stg1" --user=$CI_JOB_ID
Эти две утилиты и ещё пара полезных команд находятся в составе одной cli’шки, которую распространяем через dotnet tool.
Если подобная утилита нужна кому-то ещё, пишите в комменты.
Сходу вынести её за пределы Контура немного сложно, так как, например, для работы shared resources используются наши внутренние сервисы распределенной блокировки и хранилища json«ов. Но при большом отклике мы чего-нибудь придумаем.
Kubernetes и PVC
Как использовать Kubernetes в GitLab CI — это тема для отдельной статьи. А сейчас поговорим только об одном аспекте: хранении данных в джобах.
В гитлабе в связке с кубером джобы по умолчанию запускаются в подах. Под — это контейнер для контейнеров, он эфемерный. После завершения джобы под вместе со всеми данными внутри себя (например, склонированные репозитории) удаляется. Если вы хотите поведение, похожее на то, как это работает в ТeamCity, вам нужны постоянные хранилища. Они позволят хранить данные между запусками джоб. Под удаляется, хранилище с файлами остаётся жить и затем подключается к следующему поду. Особенно это полезно на толстых репах, потому что позволяет не клонировать их при каждом запуске, а всего лишь фетчить. Плюс можно сохранять кэш NuGet-пакетов и вообще всё, что хочется.
Называются эти постоянные хранилища — PV (persistent volume). В рамках подов нельзя использовать PV напрямую. Необходимо использовать Persistent Volume Claim (PVC), который позволяет запросить постоянный том с нужными параметрами, и затем использовать его в рабочих нагрузках.
В данный момент мы не используем PVC в своём основном CI, а просто каждый раз клонируем нужные репозитории. Плюс у этого подхода в том, что не надо думать про очистку файлов, как в постоянном хранилище. Минус понятно какой — отсутствие кэша. Но кажется, что использование PVC поможет достаточно существенно сократить время подготовки джобы (скачивание реп, билд солюшена), поэтому сейчас мы экспериментируем с подключением PVC, а итогами этого поделимся отдельно, думаю, как раз в статье про Кубер.
Для тех, кто тоже хочет подключить себе PVC, приведу набор действий, которые для этого нужны:
Вы должны использовать свои куберовские раннеры, которые контролируете именно вы, а не, например, базовая инфраструктура вашей компании. Раннер — это штуковина, которая порождает\контролирует\убивает поды для джоб в кубере.
В конфигурацию раннера надо добавить такие настройки:
[runners.custom_build_dir]
enabled = true
[[runners.kubernetes.volumes.pvc]]
name = "build-pvc-$CI_CONCURRENT_ID"
mount_path = "/builds"
3. При этом значение переменной $CI_CONCURRENT_ID
надо задать, для CI конфигурации из которой вы деплоите раннер, вот так — CI_CONCURRENT_ID: "$CI_CONCURRENT_ID"
, делается это для того, чтобы в конфигурацию раннера в кубере, попало такая строчка — name = "build-pvc-$CI_CONCURRENT_ID"
, а не строчка с уже подставленным значением, например, name = "build-pvc-7"
4. В конфигурацию вашего CI добавить такую переменную:
GIT_CLONE_PATH: "$CI_BUILDS_DIR/$CI_PROJECT_PATH"
Эта переменная позволит клонировать репы по путям build/git/repo/path
вместо дефолтного build/5/Ytdk54Kdf/git/repo/path
. Не вдаваясь сильно в подробности, проблема с дефолтным путём в том, что при деплое нового раннера (например, при изменении его настроек), этот путь меняется (Ytdk54Kdf
— это id раннера) и туда надо всё выкачивать заново. А ещё директории со старым id не удаляются, надо чистить их руками.
Всё. У вас есть PVC. Вы прекрасны.
Красивые уведомления
В Контуре для рабочего общения мы используем Mattermost (кстати есть эпичная статья, как мы туда переезжали). И соответственно, там же находятся и каналы, в которые летят всякие алерты и оповещения.
В TeamCity по дефолту можно отправлять уведомления только о статусе конкретной конфигурации. А из-за того, что гитлаб коммитоцентричен, у нас есть возможность отправлять такие красивые уведомления о состоянии всех тестов на конкретном коммите из коробки. Это гораздо более информативно и позволяет, например, быстро найти последний зелёный коммит, перейти в него и выкатить на прод.

Неочевидное поведение
workflow: rules
По умолчанию пайплайны создаются на каждый пуш, MR, и тд. Для того чтобы управлять созданием пайплайнов, можно описать правила, когда они должны создаваться, а когда не должны в секции workflow:rules
. Особо аккуратно надо описывать правила, когда вы хотите по каким-то причинам не создавать пайплайн.
Расскажу про ситуацию, которая произошла у нас. Написали следующее правило:
workflow:
rules:
- if: # если
changes: # в диффах коммита
- "**/*.md" # есть файлы с раширением .md
when: never # никогда не создавай пайплайн
- when: always # иначе создавай
«Ну нет же никакого смысла запускать тесты на правки в документации. И список пайплайнов останется чище!» — думали мы.
В начале всё работало хорошо, но спустя некоторое время начали происходить странности: иногда не создавались пайплайны, даже если в коммите и близко не было markdown-файлов. Сразу никто эти проблемы не связал с rules, потому что прошло время с добавления этих правил до первых симптомов. В ходе расследования стало ясно, что пайплайный не создаются при ребейзе «старых» веток. На сколько именно должна быть старой ветка, чтобы пайплайн не создался, выяснить не удалось.
Хотелось увидеть в логах инстанса гитлаба конкретные сообщения о логике отказа от создания пайплайна, но, к сожалению, максимально допустимый уровень логирования для нашего инстансы GitLab не показывал такую детализацию.
По итогу, перешерстив конфиг, нашли это самое правило, удалили, всё починилось.
Да, в документации есть абзац про то, что changes иногда работают неочевидным образом.
Кодировка файлов конфигурации
Если сохранить любой YAML-файла конфигурации в кодировке UTF-8 with BOM
, то конфигурация не разваливается, как это бывает когда, например, сделаешь очепятку в ключевом слове. А спокойно продолжает работать, но с интересными особенностями.
Частично пропали переменные окружения, которые заданы для конфигурации
Ещё более странно, чем раньше, работала директива changes, о которой я уже говорил
А из всех джоб, описанных в файле с битой кодировкой, отображалась только первая
Поменяли кодировку — всё заработало, как часы, включая директиву changes.
Отмена джобы (починилось)
Да, это поведение уже починили, но рассказать всё равно хочется :)
Джобы не хотели корректно завершаться при ручной отмене через UI или даже при срабатывании таймаута. Оказалось, что процессы запущенные в секции script не завершаются после команды на отмену, хотя джоба и помечается для гитлаба, как отменённая\зафейленная.
Почему это плохо:
Такие джобы ещё какое-то время висят в k8s как поды и занимают ресурсы
В after_script некоторых джоб находится логика освобождения ресурсов, которая не срабатывала
После after_script выполняется стандартный процесс выгрузки артефактов и кэша из джобы в хранилище, который тоже не срабатывал
Как чинили: написали bash код в after_script, который находил процесс основной секции script и прибивал его.
В одном из обновлении куберовского раннера, эту проблему пофиксили. Точно не работало на версии 17.0, на последней 17.8 точно работает (по крайней мере, при последнем тестировании у меня всё отменялось корректно).
Утилиты в K8s
Помимо конфигураций тестов и деплоя, которые мы двинули в GitLab CI, у нас было множество утилитных конфигураций, которые тоже жили на TeamCity. Например, актуализация тестовых площадок, различные напоминалки в каналы ММ, десяток граберов по сайтам, которые отслеживают аналитики нашей команды и прочее.
Все эти утилиты мы теперь запускаем прямо на kubernetes в виде cronjob
.
Пара слов о том, как мы запускаем эти утилиты в кубере:
Деплоим через GitLab CI с использованием шаблонов helm charts, написанных в нашей компании. Это позволяет описывать только самые необходимые свойства в конфигурации приложений в файле values.yaml и избавиться от бойлерплейт кода.
Фактически в этих конфигах описано только получение исходников кода утилит и команд для их запуска. Исходники мы получаем простым клонированием\фетчем репы с кодом. Плюс такого подхода в том, что очередной запуск каждой из утилит происходит на последнем коммите мастера, без необходимости что-то отдельно деплоить.
Что мы получили от переезда?
Снижение затрат. Нет лицензий, меньше инфраструктурных затрат. При расчете использовали прайс 2024 года и получилось, что если бы мы использовали весь 2024 год GitLab, мы бы заплатили на 40% меньше, чем при использовании TeamCity.
Не тратим время на поддержку виртуалок. Удалось выкинуть большую часть виртуальных машин, не тратим время на их поддержку. В том числе это помогло справляться с задачами без своего инженера-эксплуататора.
Динамическое масштабирование. Есть возможность простого горизонтального масштабирования. Запросили дополнительные мощности и изменили одну цифру (параллельность) в настройках раннера. Не нужно создавать и настраивать дополнительные виртуалки. Просто и быстро.
Эффективная утилизация ресурсов. Мощности в кластере не простаивают как виртуалки. Так как ими пользуются разные команды, нагрузка гораздо более равномерно распределена во времени, чем на ВМ. Ещё у нас в планах раскидать нашу нагрузку на кластер по времени, чтобы, например, несрочные джобы выполнялись ночью, а не занимали мощности днём.
Одинаковое поведения на любой платформе. Тестовое окружение теперь предоставляет из себя docker образ, что позволяет быстро поднять его где угодно, на кубере, на виртуалке или на локальной тачке разработчика и быть уверенным, что приложения в нём будут работать одинаково.
Кастомизируемость. GitLab это ПО с открытым исходным кодом, то есть у нас есть все возможности произвольной настройки как UI так и внутренних процессов платформы.
Коммитоцентричность. Крайне удобная особенность работы с GitLab CI. Легко понять общий статус коммита. В отличии от TeamCity, где тебе нужно в каждую конфигу залезть, посмотреть, а точно ли это запуск с нужного коммита.
Спасибо, что дочитали до конца! Оставляйте комментарии и делитесь идеями.