Не переписывая пайплайны: переезд с include на GitLab CI Components
Сборка зеленая, тесты зеленые, деплой зеленый. А в реестр уехал образ с пустым именем: cr.yandex/REGISTRY_ID/:abc123. Никто не ошибся. Проект просто не задал IMAGE_NAME, а модуль не умеет потребовать эту переменную — ему нечем.
Чем настраивать модуль на include, написано в комментарии в шапке файла. Больше нигде: этот комментарий GitLab не читает и ничего по нему не проверяет. GitLab CI Components это чинят: у модуля появляется объявленный список inputs, у каждого — тип и дефолт, а список передается списком, а не строкой через запятую.
Ниже — как отрефакторить существующие модули и перевести на них команды, что делать с переменными, которые протекают между модулями, и на какие грабли при этом наступили.
TL;DR
Настроить модуль на
includeможно только переменными, а какие переменные он понимает — знает один его автор. Перевели модули на CI/CD Components. У каждого теперьspec: inputsс типами и дефолтами, списки передаются списками, а опечатка в имениinputроняет пайплайн до старта. Заодно проекты получили частичные версии:@2подтягивает патчи сама, обходить репозитории и подниматьrefбольше не нужно. Переписывать пайплайны заново не пришлось — старые теги сincludeживут рядом с компонентами, поэтому переезжать можно по одному модулю. За первую рабочую неделю после релиза переехало около 80% модулей, подключенных в проектах, а кода в пайплайнах стало почти вдвое меньше. В конце — четыре вещи, которые мы собрали лбом, одну из них — дважды.
Кому это читать. Если ваш CI уже собран из модулей на include — статья ваша целиком, мы прошли этот путь и описали его по шагам. Если вы пока копируете .gitlab-ci.yml между проектами, промежуточный шаг с модулями разобран в прошлой статье, а отсюда берите разделы про контракт, типы и грабли — они работают и без него.
Чего здесь не будет. Устройства деплой-инфраструктуры: ArgoCD, инфраструктурный репозиторий и токены для пуша в него тянут на отдельный разговор. Сравнения с GitHub Actions и Jenkins: речь только про GitLab. И функций Premium и Ultimate — весь опыт собран на self-managed Free.
Где мы остановились: модули на include
В прошлой статье мы ушли от копипасты .gitlab-ci.yml по десяткам сервисов. Что имеем на входе.
Условимся о словах сразу: модуль — это единица логики, build или deploy, независимо от версии. До переезда он подключался как шаблон через include, после — подключается как компонент.
Логика CI лежит в отдельном репозитории модулей. Проект подключает их через include, фиксирует ref на тег по SemVer и держит у себя короткий файл: include, variables, джобы через extends. Настраивается модуль только переменными — это контракт. Общие дефолты в variables-default.yml, у каждого модуля README с таблицей переменных, git-хуки не дают поменять шаблон и забыть про документацию.
В файле проекта это выглядело так:
include:
- project: 'dellavrite-terraform-modules/dellavrite-ci-modules'
ref: 'v1.9.0'
file: '/standard/build.yml'
variables:
CACHE_REPO_SUFFIX: 'cache'
MANUAL_BUILD: 'true'
build:
extends: .build_template
Три обязательных части: подключить файл, задать переменные, объявить джобу через extends от скрытого шаблона.
Структуру репозитория делали сразу под Components — templates/<модуль>/template.yml рядом с README.md. Логика простая: когда дойдут руки до компонентов, границы модулей уже нарезаны, останется поменять синтаксис подключения.
Сами компоненты тогда не взяли. Старые self-hosted инстансы их не поддерживали, экспертизы в команде не было, а параллельно горел переезд с Kaniko на BuildKit. Плюс не хотелось держать два подхода сразу: компоненты для новых инстансов, include для старых.
Инстансы обновили. Пора проверять, что там с обещанием «останется поменять синтаксис».
Три вещи, которые include так и не дал
Модули на include закрыли то, ради чего затевались: единый источник правды, версии, короткий файл в проекте. Претензий к ним нет. Но трех вещей мы от них не дождались. У всех трех общее свойство: они не мешают, пока модулей мало и вся команда держит их в голове.
Контракт, который держится на честном слове
Вот как модуль сообщал, чем его настраивать:
# Модуль сборки Docker образов
# Переменные:
# - DEV_BRANCH, STAGE_BRANCH, PROD_BRANCH, RELEASE_BRANCH
# - METADATA_URL (по умолчанию: адрес метаданных облака)
# - YANDEX_REGISTRY_ID (обязательно)
# - IMAGE_NAME (по умолчанию: 'app')
# - CACHE_REPO_SUFFIX (по умолчанию: 'cache')
# - BUILDX_EXTRA_ARGS (опционально)
# - DOCKERFILE_NAME (по умолчанию: 'Dockerfile')
# - MANUAL_BUILD (по умолчанию: 'false')
# - DISABLE_MR (по умолчанию: 'false')
# - MIRROR_BASE (по умолчанию: '')
.build_template:
variables:
CACHE_REPO_SUFFIX: 'cache'
METADATA_URL: http://169.254.169.254/computeMetadata/v1/...
BUILDX_EXTRA_ARGS: ''
DOCKERFILE_NAME: 'Dockerfile'
DISABLE_MR: 'false'
Тринадцать имен в шапке, дефолты в коде — у пяти. IMAGE_NAME в комментарии обещает значение app, а в variables: его нет. YANDEX_REGISTRY_ID помечен обязательным — но забудете его задать, и никто не остановит. А за тем, чтобы шапка не разошлась с кодом, следит только тот, кто правит модуль и сам про нее помнит.
Забытая переменная не роняет пайплайн. Она подставляется пустой строкой, и об этом узнаешь, когда деплой не находит образ.
Невидимые глобальные переменные
Все переменные пайплайна лежат в одном плоском пространстве имен. Приоритеты у нас выстроены так: variables-default → дефолты модуля → переменные проекта → переменные джобы. Пока помнишь все четыре уровня — предсказуемо.
Дальше хуже. Модуль видит все переменные пайплайна, а не только свои. И наоборот — снаружи можно переписать любую его внутреннюю переменную, это же просто еще одна строка в variables:. Изоляции у include + extends нет, и приделать ее не выйдет.
Поэтому на вопрос «откуда здесь это значение» быстро не ответишь. Идешь по всем четырем уровням руками и надеешься, что никто не занял то же имя в соседнем модуле.
Выигрывает всегда нижний уровень. И это пространство у модулей общее: каждый видит чужие переменные, и каждого можно переписать снаружи.
Все — строка
Переменная в GitLab CI — это строка. Не число, не булево, не список.
Булев флаг пишем как 'true', а проверяем сравнением строк: $MANUAL_BUILD == "true". Опечатался, написал 'ture' — ошибки не будет. Условие просто не сработает.
В rules это выглядит так, и так в каждом условии:
rules:
- if: '$MANUAL_BUILD == "true" && $CI_COMMIT_BRANCH == $DEV_BRANCH'
when: manual
- if: '$MANUAL_BUILD != "true" && $CI_COMMIT_BRANCH == $DEV_BRANCH'
when: on_success
- if: '$CI_COMMIT_BRANCH =~ $RELEASE_BRANCH'
when: manual
Один такой дефолт открыл нам кнопку деплоя на прод в MR-пайплайне любой ветки. RELEASE_BRANCH был задан как 'NON_EXISTENT_BRANCH' и выглядел безобидно, пока не попадал в последнее условие. Справа от =~ GitLab ждет regex в слэшах, а приходит голая строка; $CI_COMMIT_BRANCH в MR-пайплайне при этом пуст — и на пустом значении такое сравнение оказывается истинным. В проектах, где переменную не переопределили, в MR сами собой включались build, deploy_stage и deploy_prod. У переменной нет типа, и поймать это заранее было нечем.
Списка в переменной тоже не будет. Любой перечень — пути, теги, окружения — приходится класть строкой через запятую, а потом разбирать обратно в script. Хочется ровно обратного: передал список — получил список, без склейки на входе и парсинга на выходе.
Что дают Components: spec: inputs и частичные версии
Тот же модуль после переезда начинается с объявления inputs:
spec:
inputs:
job_name:
default: 'build'
description: "Имя создаваемой джобы"
needs:
type: array
default: []
allow_failure:
type: boolean
default: false
---
"$[[ inputs.job_name ]]":
needs: $[[ inputs.needs ]]
allow_failure: $[[ inputs.allow_failure ]]
Первое, ради чего стоило ехать, — объявленный интерфейс. Все, чем модуль настраивается, теперь лежит в самом файле: имена, типы, дефолты, описания. Имя джобы тоже стало input — дальше увидим, что из этого выросло.
Второе, ради чего стоило ехать, — версии. Компонент подключается частичной версией: @2 — это последний релиз второго мажора, @2.1 — последний патч в пределах минора.
include:
- component: $CI_SERVER_FQDN/dellavrite-terraform-modules/dellavrite-ci-modules/build@2
Проект получает багфиксы сам. Владельцу модулей больше не нужно обходить репозитории и просить обновить ref, а девопсам — объяснять, почему фикс вышел неделю назад, а в проекте его до сих пор нет. Новые проекты обычно берут мажор целиком и тянут заодно минорные улучшения, осторожные фиксируются на миноре.
Плата — дисциплина релизов.
Частичная версия резолвится только в то, что опубликовано в каталоге. Для @2 тега без GitLab Release просто не существует: подключение ответит content not found.
Настраивается это один раз. Владелец репозитория модулей включает в настройках проекта флаг CI/CD Catalog project, а публикацию мы повесили на пайплайн самого репозитория — чтобы никто не выпускал релизы руками:
create-release:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
script:
- echo "Публикую релиз $CI_COMMIT_TAG"
release:
tag_name: $CI_COMMIT_TAG
name: $CI_COMMIT_TAG
description: 'Изменения версии - см. CHANGELOG.md'
rules:
- if: '$CI_COMMIT_TAG =~ /^vd+.d+.d+$/'
Сделайте эту джобу единственной точкой публикации и не двигайте теги руками. Иначе @2 однажды приведет проекты не туда, и разбираться будет тяжело: в пайплайне потребителя не видно, какая версия подтянулась.
Тот же сценарий после переезда: те же четыре шага, но на втором есть объявленный input, а на третьем — проверка.
Рефакторинг модуля: что переписать, а что оставить как есть
Хорошая новость: тело джобы не трогается вообще. script, image, services, rules переезжают как есть, строка в строку. Меняется только то, как модуль получает настройки снаружи, — и здесь придется принять три решения: что объявить input, что оставить переменной и что делать с переменными, которые протекают между модулями. По дороге — как меняется файл проекта и что становится с variables-default.
Что становится input, а что остается переменной
Граница прошла по владельцу значения. Все, чем модуль настраивают снаружи, стало input. Секреты остались переменными: токены и ключи как лежали в CI/CD Variables, так и лежат, inputs их не заменяют.
Отдельный случай — сквозные переменные: одни на весь пайплайн и нужные сразу нескольким модулям. Их держит variables-default, а inputs модулей на них ссылаются:
image_name:
default: '$IMAGE_NAME'
description: "Имя собираемого образа. По умолчанию - сквозное значение
из variables-default; задавайте явно, только если репозиторий
собирает несколько разных образов"
Пример: build до и после
Было:
include:
- project: 'dellavrite-terraform-modules/dellavrite-ci-modules'
ref: 'v1.9.0'
file: '/standard/build.yml'
variables:
CACHE_REPO_SUFFIX: 'cache'
MANUAL_BUILD: 'true'
build:
extends: .build_template
Стало:
include:
- component: $CI_SERVER_FQDN/dellavrite-terraform-modules/dellavrite-ci-modules/build@2
inputs:
cache_repo_suffix: 'cache'
manual_build: 'true'
Джоба-обертка исчезла: модуль отдает джобу сам. Переменные проекта стали inputs один в один, только в нижнем регистре:
|
v1.x переменная |
v2.0.0 input |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Имена джоб не изменились, поэтому needs: у соседних джоб и protected environments переезда не заметили.
Что делать с variables-default.yml
Он никуда не делся, но сменил роль. Раньше это был плоский список значений:
variables:
DEV_BRANCH: 'NON_EXISTENT_BRANCH'
STAGE_BRANCH: 'NON_EXISTENT_BRANCH'
ENABLE_TESTS: 'true'
ENABLE_LINT: 'true'
MIRROR_BASE: ''
Теперь это объявленные inputs, из которых собираются те же переменные:
spec:
inputs:
dev_branch:
default: 'NON_EXISTENT_BRANCH'
description: "Имя dev-ветки"
image_tag:
default: '${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}-${CI_PIPELINE_ID}'
---
variables:
DEV_BRANCH: $[[ inputs.dev_branch ]]
IMAGE_TAG: $[[ inputs.image_tag ]]
Переменные остались, потому что их читают джобы соседних модулей. Но задать их теперь можно только через объявленный input, а не дописав строку в variables: наугад. Гейты ENABLE_TESTS и ENABLE_LINT уехали в модуль тестов — переключателем стал сам факт подключения компонента.
Что делать с протекающими переменными
Полной изоляции у компонентов нет: значение input в итоге все равно может стать переменной, а переменные лежат в одном пространстве на весь пайплайн. Зато появились два рычага, которых у include не было.
Первый: $[[ inputs.x ]] — это подстановка в YAML на этапе сборки конфигурации, еще до того, как пайплайн появится. Значение вообще может не становиться переменной.
Второй: у variables: два уровня — пайплайна и джобы. Все, что объявлено внутри джобы, соседние джобы не видят.
Из этих двух рычагов выросли четыре правила.
Сквозные переменные объявляет только variables-default. Имя образа, реестр, ветки, зеркало — все, что нужно нескольким модулям сразу, приходит из одной точки. Остальные модули не заводят под это свои inputs, а читают готовую переменную: иначе порядок include решал бы, чье значение победит.
Все остальное объявляется в variables: джобы. Раньше модули клали свои настройки в общий variables: пайплайна, и они были видны всем. Теперь значение, нужное одному модулю, физически существует только внутри его джобы — протекать нечему.
То, что нужно только в rules, переменной не становится вовсе. input подставляется прямо в условие, и в рантайме от него не остается следа:
rules:
- if: '"$[[ inputs.manual_build ]]" == "true"'
when: manual
Внутренние переменные получают префикс модуля. TARGET_IMAGE_NAME вместо IMAGE_NAME, SONAR_JQ_SOURCE вместо JQ_SOURCE. Переменная джобы приоритетнее сквозной, поэтому совпадение имен молча перекрыло бы общее значение. Заодно так уходим от ссылки на саму себя: IMAGE_NAME: $IMAGE_NAME написать нельзя.
Итог: в общем пространстве остаются только сквозные переменные — те, которым там и место. Все остальное либо живет внутри своей джобы, либо не доживает до рантайма.
Списки и структуры вместо строк через запятую
Список стадий пайплайна — это список, а не строка через запятую:
spec:
inputs:
stages:
type: array
default: ['check_conflicts', 'build', 'test', 'deploy', 'sync', 'autotest']
---
stages: $[[ inputs.stages ]]
Та же история с needs. Пустой список здесь значит «стартуй сразу, никого не жди»:
needs:
type: array
default: []
---
"$[[ inputs.job_name ]]":
needs: $[[ inputs.needs ]]
Ни склейки на входе, ни разбора в script. Раньше такой перечень передавали строкой и разбирали руками, а ошибку в разделителе ловили только на запуске.
Структуры приезжают там же, внутри массива. Дефолтный needs модуля деплоя выглядит так:
needs:
type: array
default: [{job: 'build', optional: true, artifacts: false}]
Строкой такое не передать — пришлось бы придумывать свой формат и разбирать его руками.
Отдельно стоит options: input принимает только значение из списка, все остальное отвергается до старта пайплайна.
env:
options: ['dev', 'stage', 'prod', 'test']
deployment_tier:
options: ['development', 'staging', 'testing', 'production', 'other']
У типов есть предел, и мы в него уперлись. Выразить через input «ключа нет вовсе» невозможно. Пустой needs: [] значит «стартуй немедленно» — это не то же самое, что отсутствие needs, при котором порядок определяют стадии. Поэтому там, где безопасного дефолта нет, мы needs просто не объявляем: пусть лучше ключа не будет, чем он появится со значением, ломающим порядок джоб.
Переезд по частям: include и component в одном пайплайне
Ни один проект не переписывался с нуля, и дело не в везении. Сработали четыре приема, каждый из которых полезен сам по себе:
-
Двойной режим. Старые теги с
includeживут рядом с компонентами, поэтому проект едет частями. -
Общее тело в партиале. Тело джобы осталось в общем куске конфигурации, компонент добавил к нему только интерфейс.
-
Несколько джоб из одного компонента. Имя джобы стало
input, поэтому компонент подключается столько раз, сколько нужно. -
Обратимость. Откат — один коммит в проекте, без согласования с кем-либо.
Двойной режим: include и components из одного репозитория
Каталог standard/ мы удалили — но только в main. В тегах v1.x он остался на месте, а тег никуда не денется. Значит, старое подключение продолжает работать ровно так же, как работало.
Важно другое: подключения в одном файле независимы друг от друга. Проект может держать часть модулей на v1, а часть уже на компонентах — и это валидная конфигурация, жить с ней можно сколько угодно:
include:
- component: $CI_SERVER_FQDN/dellavrite-terraform-modules/dellavrite-ci-modules/variables-default@2
inputs:
image_name: 'app'
dev_branch: 'dev'
- component: $CI_SERVER_FQDN/dellavrite-terraform-modules/dellavrite-ci-modules/build@2
- project: 'dellavrite-terraform-modules/dellavrite-ci-modules'
ref: 'v1.11.2'
file: '/standard/sync.yml'
sync_dev:
extends: .sync_dev
Сборка уже на компоненте, синхронизация еще на старом шаблоне со своей джобой-оберткой. Пайплайн собирается, обе джобы работают.
Одно правило: variables-default переводите первым. Он задает сквозные переменные, стадии и workflow — то, на что опираются и старые модули, и новые. Все остальное переносится по одному модулю, хоть по одной джобе за раз. Остановиться можно в любой момент и на сколько угодно.
Общее тело в партиале, интерфейс в компоненте
Партиалы появились не от любви к красивой структуре, а из конкретной поломки.
Модуль deploy-sync пушит тег в инфраструктурный репозиторий и тут же синхронизирует ArgoCD — одной джобой, иначе возникает гонка: тег еще не закоммичен, а синхронизация уже пошла. В v1 он просто подключал deploy и sync, а их тела склеивал через !reference. Скрытые шаблоны это позволяли: подключил файл — в пайплайне ничего не появилось.
С явными джобами прием сломался. Теперь include компонента deploy добавляет джобу deploy_dev, и deploy-sync, подключив два компонента ради их скриптов, впрыснул бы потребителю две лишние джобы — deploy_dev и sync_dev.
Поэтому тела вынесли в partials/ — за пределы каталога компонентов:
include:
- local: '/partials/deploy-core.yml'
- local: '/partials/sync-core.yml'
"$[[ inputs.job_name ]]":
before_script:
- !reference [.deploy_core, before_script]
- !reference [.sync_core, before_script]
script:
- !reference [.deploy_core, script]
- !reference [.sync_core, script]
Партиал — это скрытая джоба без своего spec:, живущая вне templates/. Компонентом она не является, отдельно не подключается, конфигурацию получает переменными от того модуля, который ее включил.
Партиалов ровно два — по числу тел, которые нужны больше чем одному компоненту. У остальных потребитель один, и выносить нечего.
Три компонента, два тела. deploy-sync не подключает соседние компоненты, а собирает свою джобу из тех же партиалов — поэтому чужие джобы в пайплайн потребителя не попадают, а логика не расходится.
Одно подключение — одна джоба
Раз имя джобы стало input, один и тот же компонент подключается столько раз, сколько нужно. Деплой на четыре окружения — это четыре подключения одного компонента, а не четыре копии шаблона. Вот два из них — показаны только те inputs, что различаются:
include:
- component: $CI_SERVER_FQDN/.../deploy@2
inputs:
job_name: 'deploy_dev'
env: 'dev'
environment_name: 'development'
deployment_tier: 'development'
- component: $CI_SERVER_FQDN/.../deploy@2
inputs:
job_name: 'deploy_prod'
env: 'prod'
environment_name: 'production'
deployment_tier: 'production'
На extends то же самое требовало отдельного скрытого шаблона под каждое окружение.
Обратимость: откат в один коммит
Проект откатывается одним коммитом, ни с кем не согласовывая.
Сейчас:
include:
- component: $CI_SERVER_FQDN/dellavrite-terraform-modules/dellavrite-ci-modules/build@2
inputs:
cache_repo_suffix: 'cache'
manual_build: 'true'
После отката:
include:
- project: 'dellavrite-terraform-modules/dellavrite-ci-modules'
ref: 'v1.11.2'
file: '/standard/build.yml'
variables:
CACHE_REPO_SUFFIX: 'cache'
MANUAL_BUILD: 'true'
build:
extends: .build_template
Тот же переезд, только в обратную сторону: inputs разворачиваются обратно в переменные, джоба возвращается через extends. Плюс вернуть в CI/CD Variables то, что модуль v1 ждет от проекта.
Механика v1 не зависит от инфраструктуры v2, поэтому откатившийся проект работает как раньше, а соседние продолжают жить на компонентах.
С чего начать у себя
Чтобы не начинать с белого листа, мы держим в репозитории модулей стартовый файл. В нем подключено то, что нужно почти всем, остальное лежит закомментированным — раскомментировать и заполнить.
Стартовый .gitlab-ci.yml на компонентах, целиком
default:
retry: 2
include:
- component: $CI_SERVER_FQDN/dellavrite-terraform-modules/dellavrite-ci-modules/variables-default@2
inputs:
image_name: 'my-service'
dev_branch: 'dev'
stage_branch: 'stage'
prod_branch: 'prod'
# yandex_registry_id: 'b1g...'
- component: $CI_SERVER_FQDN/dellavrite-terraform-modules/dellavrite-ci-modules/check-conflicts@2
- component: $CI_SERVER_FQDN/dellavrite-terraform-modules/dellavrite-ci-modules/build@2
# - component: $CI_SERVER_FQDN/dellavrite-terraform-modules/dellavrite-ci-modules/test-python@2
# - component: $CI_SERVER_FQDN/dellavrite-terraform-modules/dellavrite-ci-modules/deploy@2
# inputs:
# job_name: 'deploy_dev'
# env: 'dev'
# chart_path: 'cluster-dev/charts/my-service'
# environment_name: 'development'
# environment_url: 'https://dev.example.com'
# deployment_tier: 'development'
# infrastructure_project: 'group/infrastructure'
# - component: $CI_SERVER_FQDN/dellavrite-terraform-modules/dellavrite-ci-modules/sync@2
# inputs:
# job_name: 'sync_dev'
# env: 'dev'
# argocd_server_url: 'argocd.example.com'
# argocd_app_name: 'my-service-dev'
# argocd_token_variable: 'ARGOCD_AUTH_TOKEN_DEV'
Весь пайплайн проекта — это include, inputs и пара глобальных настроек. Ни одной джобы, ни одного extends, ни одной строки script.
Миграция команд: почему в этот раз было проще
Переезд на компоненты дался заметно легче, чем в свое время переезд на модули. Тогда каждый второй сервис преподносил сюрприз, и мы сознательно шли медленно. Здесь границы модулей уже были нарезаны — менялся способ подключения, а не логика внутри.
Начали с контрольного проекта. Один сервис перевели целиком, дождались, пока он отработает на всех окружениях, и только после этого пошли дальше. Берите на эту роль не самый простой проект, а самый полный — с деплоем на все стенды, автотестами и сканерами. Простой переедет и так, а все вопросы вы соберете разом на сложном.
Дальше шли по одному модулю: сборка, потом тесты, потом деплой. Поэтому переезд и не выглядел как миграция: в любой момент часть пайплайна уже на компонентах, часть еще на include, и все работает.
Новые проекты сразу заводились на компонентах — им переезжать было неоткуда.
Легаси не трогали. Правило то же, что и в прошлый раз: не все сразу. У таких проектов свой график, и определяется он не нами.
Часть команд переехала вообще без нашего участия. В v2 выходило обновление, которое им было нужно, — и они переносили тот модуль, ради которого пришли, оставляя остальные на месте.
Четыре дорожки после релиза. Проекты в работе едут через контрольный и дальше по одному модулю, новые сразу рождаются на компонентах, легаси живет своей жизнью, а часть команд переезжает без нас.
Результаты: что изменилось в цифрах
Больше всего времени съела переделка самих модулей: выделенного ресурса не было, срочности тоже — старая схема работала, а часть проблем закрывалась костылями и верой, что и так нормально. Перебирали модули по одному, между делом, и растянулось это на две-три недели.
Миграция команд после этого прошла мягко. За первую рабочую неделю после релиза переехало около 80% подключенных в проектах модулей — считаем именно модули, кто-то перевел сборку и тесты, а деплой оставил на потом. Остальные переезжают до сих пор, по мере необходимости.
Сведем в таблицу:
|
|
На include |
На компонентах |
|---|---|---|
|
Кода в пайплайне проекта |
~110 строк |
~56 строк |
|
Настроек у модуля |
16 переменных |
7 |
|
Выкатка патча на все проекты |
обойти репозитории и поднять |
ничего не делать |
Почти половина кода ушла вместе с джобами-обертками, блоками extends и частью переменных. Дело не в строках, а в том, сколько нужно прочитать, чтобы понять пайплайн: файл на полсотни строк осваивается за один заход, файл на сто с лишним — уже нет.
Третья строчка таблицы стоит первых двух. За месяц после релиза мы выпустили четыре версии, включая два патча для раннеров без интернета, и ни один проект не поправил у себя ни строчки — все сидят на @2.
Четыре релиза за месяц уехали в проекты сами. На
includeэто были бы четыре обхода всех репозиториев с правкойrefв каждом.
Чего в таблице нет: опечатка в имени input, забытая обязательная настройка или недопустимое значение теперь роняют пайплайн до старта, а не портят выкатку молча.
Грабли переезда: content not found, unknown input и приоритеты переменных
Переезд прошел спокойнее, чем мы боялись: пайплайны не легли ни разу. Но четыре вещи мы собрали лбом, одну из них — дважды. У нее же обнаружилось второе дно, о котором дальше.
-
@v2— это не версия. Частичная версия парсится как semver, аvживет только в именах реальных тегов.@2работает,@v2даетcontent not found, при этом@v2.1.0— валидное подключение точной версии. Разница в один символ, а ошибка ни на что не указывает. -
Переменная джобы перебивает сквозную. Поймали дважды. Модуль
sonarqubeобъявилJQ_SOURCEсвоей переменной уровня джобы — и сквозное значение изvariables-defaultдо джобы не доехало, поэтому на раннерах без интернета анализ падал, пытаясь скачать jq с github.com. Позже то же самое случилось сIMAGE_TAGв модулях деплоя. Лечитсяinputс дефолтом-ссылкой на сквозную переменную, а внутренняя переменная называется иначе:
variables:
SONAR_JQ_SOURCE: $[[ inputs.jq_source ]]
Назвать ее тем же именем нельзя — получится ссылка на саму себя.
-
Свой
stages:перебивает список изvariables-default. Список стадий теперь объявляетvariables-default, но оставшийся у проектаstages:молча его переписывает, и джобы модулей не находят свою стадию. Лечится удалением — свой список больше не нужен. -
Гейты
enable_*больше не существуют. Раньше модуль включался переменной, теперь — самим фактом подключения. Оставшийся ключ роняет пайплайн с «unknown input». Раздражает ровно один раз, и это лучшее, что случилось с гейтами: раньше опечатка в такой переменной означала тихо пропущенный деплой.
Вернемся ко второму пункту: его второе дно — не опечатка в имени, а границы самих компонентов. Изолирован интерфейс, рантайм остался общим — и правила из раздела про рефакторинг нужны именно поэтому. Компоненты наводят порядок снаружи модуля, а дисциплину именования внутри пайплайна по-прежнему держите вы.
Итоги
Главное, что дал переезд, — строгий контракт. Раньше интерфейс модуля был обещанием в комментарии: тринадцать переменных списком, дефолты частью в коде, частью на словах. Никто ничего не проверял. Теперь интерфейс объявлен рядом с логикой, имеет типы и дефолты, а несоответствие ловится до старта пайплайна.
Следом — гибкие версии. Багфиксы разъезжаются по проектам сами, никого не нужно обходить и просить обновить ref. Это единственное, что чувствуется сразу, без привыкания.
Такого разительного эффекта, как при переходе на модули, здесь нет — и не могло быть.
Копипаста и config drift — боль первого порядка, она меряется в часах. Отсутствие контракта — боль второго порядка: она стоит не времени, а доверия к зеленому пайплайну.
Что мы из этого вынесли:
-
Контракт пишется в первую очередь для себя.
Половина находок в этой статье всплыла не оттого, что компоненты умные, а оттого, что пришлось вслух перечислитьinputsкаждого модуля. Дефолт, живущий только в комментарии, обнаруживается ровно в этот момент. -
Ехать надо попутно.
У нас переезд занял две-три недели фоновой работы и окупился. Если под него нужен выделенный квартал — отложите до момента, когда все равно будете трогать модули. -
Это гигиена, а не ускорение.
Компоненты не выкатят вам релиз быстрее. Они уберут класс ошибок, которые раньше проходили молча, и это чувствуется не в первый день.
Если вы все еще копируете .gitlab-ci.yml между проектами — начните с первой статьи: там выигрыш в разы больше и виден сразу.
Давайте обсудим
-
Как у вас устроен контракт модуля — уже
spec: inputsили все еще список переменных в README и надежда на дисциплину? И кто у вас ловит рассинхрон документации с кодом: линтер, ревьюер или пользователь модуля? -
Вы сидите на частичной версии и получаете патчи автоматически — или фиксируете точный тег, потому что автообновление в CI страшнее ручного обхода репозиториев? Если фиксируете, то как узнаете, что вышел нужный фикс?
-
Кто уже переезжал на компоненты — на чем споткнулись? У нас самое обидное пряталось не в синтаксисе, а в приоритетах переменных: интерфейс изолирован, а рантайм все тот же общий.
-
И вопрос к тем, у кого модули живут годами: как вы решаете, когда пора выпускать мажор с breaking changes, а когда тянуть совместимость дальше?
-
И главное, с чем можно спорить: мы утверждаем, что компоненты — это гигиена, а не ускорение, и что ради них не стоит выделять отдельный квартал. Если у вас переезд дал измеримый выигрыш во времени — расскажите, где именно, нам это правда интересно.
Ресурсы
-
GitLab CI/CD Components — та самая документация, на которую мы поглядывали еще в первой статье и по которой в итоге переехали.
-
dellavrite-ci-modules — репозиторий модулей из статьи. Все примеры лежат там целиком, вместе с партиалами и линтерами компонентов, которые в текст не поместились.
-
Гайды миграции v1.x → v2.0.0 — то, что мы писали для своих команд: по файлу на модуль, с таблицами «переменная →
input». Если поедете, начните с них. -
Первая статья: как мы ушли от копипасты к модулям — с чего все начиналось, и куда идти, если у вас пока копипаста.
Автор: dellavrite

