
Сборка зеленая, тесты зеленые, деплой зеленый. А в реестр уехал образ с пустым именем: 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 =~ /^v\d+\.\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». Если поедете, начните с них.Первая статья: как мы ушли от копипасты к модулям - с чего все начиналось, и куда идти, если у вас пока копипаста.

