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

Сборка зеленая, тесты зеленые, деплой зеленый. А в реестр уехал образ с пустым именем: 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 помечен обязательным - но забудете его задать, и никто не остановит. А за тем, чтобы шапка не разошлась с кодом, следит только тот, кто правит модуль и сам про нее помнит.

Забытая переменная на include: четыре шага от подключения модуля до образа без имени в реестре
Забытая переменная на include: четыре шага от подключения модуля до образа без имени в реестре

Забытая переменная не роняет пайплайн. Она подставляется пустой строкой, и об этом узнаешь, когда деплой не находит образ.

Невидимые глобальные переменные

Все переменные пайплайна лежат в одном плоском пространстве имен. Приоритеты у нас выстроены так: 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 однажды приведет проекты не туда, и разбираться будет тяжело: в пайплайне потребителя не видно, какая версия подтянулась.

Тот же сценарий на компонентах: spec:inputs объявляет image_name с дефолтом, и в реестр уезжает образ с именем
Тот же сценарий на компонентах: spec:inputs объявляет image_name с дефолтом, и в реестр уезжает образ с именем

Тот же сценарий после переезда: те же четыре шага, но на втором есть объявленный 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

CACHE_REPO_SUFFIX

cache_repo_suffix

BUILDX_EXTRA_ARGS

buildx_extra_args

DOCKERFILE_NAME

dockerfile_name

MANUAL_BUILD

manual_build

DISABLE_MR

disable_mr

Имена джоб не изменились, поэтому 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 собирает свою джобу из тех же тел, что deploy и sync
Три компонента и два партиала: deploy-sync собирает свою джобу из тех же тел, что deploy и sync

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

Четыре дорожки после релиза v2: контрольный проект, остальные по одному модулю, новые проекты, легаси и самостоятельный переезд
Четыре дорожки после релиза v2: контрольный проект, остальные по одному модулю, новые проекты, легаси и самостоятельный переезд

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


Результаты: что изменилось в цифрах

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

Миграция команд после этого прошла мягко. За первую рабочую неделю после релиза переехало около 80% подключенных в проектах модулей - считаем именно модули, кто-то перевел сборку и тесты, а деплой оставил на потом. Остальные переезжают до сих пор, по мере необходимости.

Сведем в таблицу:

На include

На компонентах

Кода в пайплайне проекта

~110 строк

~56 строк

Настроек у модуля deploy-sync

16 переменных

7 input

Выкатка патча на все проекты

обойти репозитории и поднять ref

ничего не делать

Почти половина кода ушла вместе с джобами-обертками, блоками extends и частью переменных. Дело не в строках, а в том, сколько нужно прочитать, чтобы понять пайплайн: файл на полсотни строк осваивается за один заход, файл на сто с лишним - уже нет.

Третья строчка таблицы стоит первых двух. За месяц после релиза мы выпустили четыре версии, включая два патча для раннеров без интернета, и ни один проект не поправил у себя ни строчки - все сидят на @2.

Четыре релиза за месяц уехали в проекты сами. На include это были бы четыре обхода всех репозиториев с правкой ref в каждом.

Чего в таблице нет: опечатка в имени input, забытая обязательная настройка или недопустимое значение теперь роняют пайплайн до старта, а не портят выкатку молча.


Грабли переезда: content not found, unknown input и приоритеты переменных

Переезд прошел спокойнее, чем мы боялись: пайплайны не легли ни разу. Но четыре вещи мы собрали лбом, одну из них - дважды. У нее же обнаружилось второе дно, о котором дальше.

  1. @v2 - это не версия. Частичная версия парсится как semver, а v живет только в именах реальных тегов. @2 работает, @v2 дает content not found, при этом @v2.1.0 - валидное подключение точной версии. Разница в один символ, а ошибка ни на что не указывает.

  2. Переменная джобы перебивает сквозную. Поймали дважды. Модуль sonarqube объявил JQ_SOURCE своей переменной уровня джобы - и сквозное значение из variables-default до джобы не доехало, поэтому на раннерах без интернета анализ падал, пытаясь скачать jq с github.com. Позже то же самое случилось с IMAGE_TAG в модулях деплоя. Лечится input с дефолтом-ссылкой на сквозную переменную, а внутренняя переменная называется иначе:

  variables:
    SONAR_JQ_SOURCE: $[[ inputs.jq_source ]]

Назвать ее тем же именем нельзя - получится ссылка на саму себя.

  1. Свой stages: перебивает список из variables-default. Список стадий теперь объявляет variables-default, но оставшийся у проекта stages: молча его переписывает, и джобы модулей не находят свою стадию. Лечится удалением - свой список больше не нужен.

  2. Гейты enable_* больше не существуют. Раньше модуль включался переменной, теперь - самим фактом подключения. Оставшийся ключ роняет пайплайн с «unknown input». Раздражает ровно один раз, и это лучшее, что случилось с гейтами: раньше опечатка в такой переменной означала тихо пропущенный деплой.

Вернемся ко второму пункту: его второе дно - не опечатка в имени, а границы самих компонентов. Изолирован интерфейс, рантайм остался общим - и правила из раздела про рефакторинг нужны именно поэтому. Компоненты наводят порядок снаружи модуля, а дисциплину именования внутри пайплайна по-прежнему держите вы.


Итоги

Главное, что дал переезд, - строгий контракт. Раньше интерфейс модуля был обещанием в комментарии: тринадцать переменных списком, дефолты частью в коде, частью на словах. Никто ничего не проверял. Теперь интерфейс объявлен рядом с логикой, имеет типы и дефолты, а несоответствие ловится до старта пайплайна.

Следом - гибкие версии. Багфиксы разъезжаются по проектам сами, никого не нужно обходить и просить обновить ref. Это единственное, что чувствуется сразу, без привыкания.

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

Копипаста и config drift - боль первого порядка, она меряется в часах. Отсутствие контракта - боль второго порядка: она стоит не времени, а доверия к зеленому пайплайну.

Что мы из этого вынесли:

  1. Контракт пишется в первую очередь для себя.
    Половина находок в этой статье всплыла не оттого, что компоненты умные, а оттого, что пришлось вслух перечислить inputs каждого модуля. Дефолт, живущий только в комментарии, обнаруживается ровно в этот момент.

  2. Ехать надо попутно.
    У нас переезд занял две-три недели фоновой работы и окупился. Если под него нужен выделенный квартал - отложите до момента, когда все равно будете трогать модули.

  3. Это гигиена, а не ускорение.
    Компоненты не выкатят вам релиз быстрее. Они уберут класс ошибок, которые раньше проходили молча, и это чувствуется не в первый день.

Если вы все еще копируете .gitlab-ci.yml между проектами - начните с первой статьи: там выигрыш в разы больше и виден сразу.


Давайте обсудим

  • Как у вас устроен контракт модуля - уже spec: inputs или все еще список переменных в README и надежда на дисциплину? И кто у вас ловит рассинхрон документации с кодом: линтер, ревьюер или пользователь модуля?

  • Вы сидите на частичной версии и получаете патчи автоматически - или фиксируете точный тег, потому что автообновление в CI страшнее ручного обхода репозиториев? Если фиксируете, то как узнаете, что вышел нужный фикс?

  • Кто уже переезжал на компоненты - на чем споткнулись? У нас самое обидное пряталось не в синтаксисе, а в приоритетах переменных: интерфейс изолирован, а рантайм все тот же общий.

  • И вопрос к тем, у кого модули живут годами: как вы решаете, когда пора выпускать мажор с breaking changes, а когда тянуть совместимость дальше?

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


Ресурсы

  • GitLab CI/CD Components - та самая документация, на которую мы поглядывали еще в первой статье и по которой в итоге переехали.

  • dellavrite-ci-modules - репозиторий модулей из статьи. Все примеры лежат там целиком, вместе с партиалами и линтерами компонентов, которые в текст не поместились.

  • Гайды миграции v1.x → v2.0.0 - то, что мы писали для своих команд: по файлу на модуль, с таблицами «переменная → input». Если поедете, начните с них.

  • Первая статья: как мы ушли от копипасты к модулям - с чего все начиналось, и куда идти, если у вас пока копипаста.