Как мы перестали копировать .gitlab-ci.yml и спасли пайплайны от хаоса с помощью модулей

от автора

Как избежать десятков MR при обновлении пайплайнов и стандартизировать развертывание новых сервисов в GitLab CI? Решение — вынести части пайплайнов в отдельные изолированные модули.

Для кого эта статья: DevOps и SRE инженеры, которые устали поддерживать зоопарк шаблонных проектов или микросервисов и хотят навести порядок в CI/CD.

В статье мы разберем:

  • единый источник правды (SSOT) для логики пайплайна;

  • стратегию версионирования и безопасный контроль обновлений;

  • четкое разделение ответственности между разработкой и инфраструктурой.

  • сколько времени это реально сэкономило — с цифрами и метриками. И, конечно, покажем, как обходить грабли, когда проекту всё равно нужно «чуть-чуть иначе».

Чего не будет в статье: Базовых туториалов по GitLab CI, сравнений с другими CI/CD-системами и глубокого погружения в кэширование или секреты. Статья фокусируется именно на архитектуре модульных пайплайнов и процессах их поддержки в продакшене.


TL;DR Копипаст .gitlab-ci.yml по десяткам сервисов привел к config drift: пайплайны расходились, а правка одной строчки требовала 10+ MR. Вынесли логику CI в отдельный репозиторий модулей, подключаемых через include с версионированием по SemVer — в проекте остается только короткий файл с переменными. В итоге миграция с Kaniko на BuildKit заняла 5 дней вместо 2-3 недель, а типовой багфикс на 20 сервисах — 1 час вместо 11.


Как было раньше и почему мы ушли от этого

Поначалу копипаст .gitlab-ci.yml был нормой. На 5-7 сервисах поддерживать их в таком виде было удобно и быстро.

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

И тут начались проблемы:

  • Обновления. Нужно поправить одну строчку в нескольких проектах — открываешь 10 MR. Пытаешься cherry-pick — ловишь конфликты.

  • Отсутствие SSOT. Делаешь новый сервис и думаешь: «Где вообще актуальный деплой под ArgoCD?». В одном репо один фикс, в другом — другой, в третьем — вообще своя реализация.

  • Потеря времени. Мы тратили часы не на новые задачи, а на чтение чужого копипаста, чтобы понять, как это вообще должно работать.

А потом понадобилось мигрировать с Kaniko на BuildKit и переехать на свой registry. DockerHub начал отдавать ошибки в 80-90% случаев, разработка встала. Дедлайн — неделя.

Мы оценили объём и поняли, что править десятки файлов руками — это работа на десятки часов. В неделю не влезем. Так стало понятно, что с копипастом надо завязывать.

Почему решили перейти на модули

Когда дошли руки до рефакторинга, мы сразу поняли, чего ждем от нового подхода. Требования простые:

  • SSOT и версионирование. Пишем логику в одном месте. Проекты тянут конкретную версию, которая им подходит. Обновляться должно быть легко. Поддерживать несложно.

  • Скорость. Сроки горят, плюс параллельно надо переезжать с Kaniko на BuildKit.

  • Совместимость. У нас не один инстанс GitLab, есть довольно старые self-hosted версии.

Стали смотреть варианты.

Генераторы

Идея в том, чтобы собирать .gitlab-ci.yml на лету. Но быстро поняли, что для нас это overkill. Это не экономит время на поддержку, а добавляет ее: теперь нужно поддерживать еще и сам генератор. А по срокам мы точно не успевали. Отсекли.

GitLab Components

Вещь нативная и современная. Но тут вылезли две проблемы. Первая: у нас в команде не было экспертизы, а времени на то, чтобы вкатиться и потестить в бою, не было. Вторая: компоненты не работают в старых версиях GitLab. Обновлять все инстансы прямо сейчас — долго. А держать два разных подхода (компоненты для новых и инклюды для старых) не хотелось.

Классические include

Выносим логику в отдельный репозиторий, делим на файлы и подключаем к проектам через include с фиксацией ref на нужную версию. Остановились на нем. Он работает везде, даже в старых GitLab. Внедряется быстро, читать новую документацию не надо, минимум сюрпризов.

Критерий

Includes

Components

Генераторы

SSOT

+ Один репо шаблонов

+ Один репо на компонент

+ Библиотека и описания сервисов

Версионирование

± Через ref (тег/ветка/коммит). Часто забывают фиксировать

+ SemVer через Git tags (@1.2.0)

+ Версия библиотеки генератора

Обновления

± Надо bump’ать ref в каждом проекте. Нужен скрипт-автоматизатор

± Надо bump’ить версию компонента. Нужен MR-бот

+ Перегенерировал — получил свежие YAML

Поддержка

± YAML и extends → через 20 шаблонов начинаешь тонуть

+ Есть spec с контрактом и README

+ Декларативно, но порог входа в Jsonnet/CUE

Скорость внедрения

+ 1-2 дня

— 1-2 недели до первого рабочего пайплайна

— 2-3 недели на настройку

Миграция Kaniko→BuildKit

+ Поменял шаблон → обновил ref в проектах

+ То же, но с bump’ом версии

+ Поменял шаблон → перегенерировал

Совместимость со старыми GitLab

+ GitLab 11.x+ (2018)

— Только GitLab 16.0+ / SaaS

+ Работает везде — на выходе обычный YAML

Сейчас include для нас просто фундамент. Логические блоки, которые мы сейчас выделили, в будущем легко лягут на архитектуру Components.

Как только внедрим модули, поставим задачи на постепенный апгрейд GitLab. Когда инстансы обновятся, сможем спокойно перевести include на Components. Границы модулей и логика уже будут готовы, останется только поменять синтаксис подключения.

Пошаговая реализация

Перед тем как начинать рефакторинг, мы составили план. Хотелось не остановить релизы на месяц, пока мы все переделываем.

План был такой:

  1. Создать репозиторий для модулей и продумать структуру.

  2. Продумать стратегию версионирования и процесс релиза, чтобы баги не улетали сразу в прод.

  3. Разбить текущую логику CI на независимые части.

  4. Завернуть каждую часть в отдельный модуль.

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

  6. Собрать базовый пайплайн, протестировать и зафиксировать как пример для остальных.

  7. Описать стандарт документации для модулей.

  8. Постепенно внедрить в проекты.

Разберем ключевые моменты.

Структура репозитория

Сразу закладывались на то, что потом будем мигрировать на GitLab CI Components. Изучили документацию GitLab и адаптировали структуру Components под текущие include. Логика простая: если сразу сделать папки как для компонентов, при миграции не придется все переделывать.

Получилось так:

ci-modules-repo/├── templates/                # модули│   ├── build/                # модуль сборки│   │   ├── template.yml      # код модуля│   │   └── README.md         # документация│   ├── deploy/               # модуль деплоя│   │   ├── template.yml│   │   └── README.md│   └── ...                   # другие модули├── migrations/               # инструкции по мажорным обновлениям│   ├── v1.x.x_to_v2.x.x.md   # что изменилось и как мигрировать│   └── ...├── CHANGELOG.md              # история версий└── README.md                 # общий обзор и быстрый старт

Папка migrations важна. Когда один инструмент используют десятки команд, мажорные обновления неизбежны. Гайды по миграции прямо в репозитории экономят время и снижают количество вопросов.

Стратегия версионирования

Подключать модули по ветке main — плохая идея:

include:  - project: 'devops/ci-templates'    file: '/templates/build/template.yml'    ref: main

include всегда тянет свежий код. Если кто-то зальет баг в main, он сразу сломает CI во всех проектах.

Чтобы этого избежать, мы используем SemVer. Каждый релиз отмечается тегом vMAJOR.MINOR.PATCH:

include:  - project: 'devops/ci-templates'    file: '/templates/build/template.yml'    ref: 'v1.1.1'

Но просто навесить теги недостаточно. Чтобы в main попадал только рабочий код, мы берем контрольный проект. Это любой сервис, который можно использовать для тестов. Перед мержем в main мы прогоняем фича-ветку через этот проект.

Эволюция процессов релизов

С релизами мы набивали шишки.

Как думали, что будет:

Что получили:

Как делаем сейчас: Добавили тестирование до мержа.

Результат: Релизы стали стабильнее. Если тег выпущен — он рабочий.

Разбивка CI на логические части

Теперь разбираем монолитный .gitlab-ci.yml.

Главная идея: .gitlab-ci.yml в проекте превращается из набора скриптов и сложной логики в короткий манифест — «я хочу эти модули с такими параметрами». Вся императивная логика — как собирать, как деплоить — уходит в отдельные модули в другом репозитории.

Как найти баланс при декомпозиции

Главное, когда разбиваем на модули — не уйти в крайности. Либо получаются модули по одной микрокоманде, либо огромные файлы, в которых снова все свалено. Держим баланс по трем принципам:

  • Single Responsibility. Модуль закрывает одну конкретную задачу и делает это хорошо.

  • Low Coupling. Модуль знает минимум о других. Взаимодействие — только через needs + артефакты и variables.

  • Reusability. Модуль можно подключить в любом проекте без правки его внутренностей.

Взяли для примера самый сложный пайплайн — монолитный сервис. Если архитектура выдержит его, выдержит и остальные.

Какие модули получились

Модуль

Что делает

check-conflicts

Проверка конфликтов на этапе мерджа

build

Сборка образов на BuildKit, отправка в реестр

test

Юнит-тесты и линтеры.

check-dependencies

Проверка зависимостей в Python-проекте через safety, результат — артефактом

check-image-vulnerabilities

Проверка образа на уязвимости через Trivy

sonarqube

SAST-анализ кода через SonarQube

deploy

Деплой через коммит тега в инфраструктурный репозиторий

sync

Синхронизация ArgoCD — чтобы видеть, когда код реально дошел до среды

auto-test

Автотесты через Newman на уже раскатанной среде

Отдельно про test: переиспользуемость логики между языками вынесли в Taskfile, чтобы не привязываться к конкретной тест-библиотеке. Привязка к Python осталась только на уровне кеша зависимостей — и это не проблема.

Передача контекста между модулями

Контекст передаем двумя способами:

  • Контракт переменных. У каждого модуля есть свой контракт — описание, как им управлять через переменные. Это основной способ коммуникации.

  • Артефакты. Передаются через needs с явно прописанным artifacts: true. Сюда попадает то, что нельзя выразить переменной: dotenv-файлы, отчеты, промежуточные бинари.

Большинство зависимостей видно прямо в .gitlab-ci.yml проекта через блок variables. Артефакты не тащатся по умолчанию — их нужно явно запросить через needs. Так модули остаются слабосвязанными.

Реализация модулей

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

Что внутри модуля

  • Локальные переменные — задают поведение по умолчанию. Джоба должна запускаться с минимальной настройкой и работать в большинстве проектов без дополнительных вопросов. Каждая переменная — это контракт: дефолтное значение задокументировано, и если ее не трогаешь, модуль ведет себя ровно так, как написано в документации.

  • Основная template-джоба — одна на модуль, подключается через extends. Имя строго по единому правилу .${название_модуля}_template. Например, .build_template. Все знают это имя и подключают модуль одинаково. Если имя модуля состоит из нескольких слов через дефис (например, check-conflicts), в имени job дефис превращается в подчёркивание: .check_conflicts_template.

  • Вспомогательные джобы (если нужны) — например, отдельные джобы для разных стендов. Тоже hidden (начинаются с .), но без строгого нейминга. Если кто-то их сломает — не критично. Те, кому они нужны, разберутся.

  • Rules — условия запуска. Это часть контракта, снаружи их трогать нельзя. Потребитель управляет поведением через переменные, а не переписывает правила.

  • Script — сама бизнес-логика, что джоба делает.

  • Все остальное (tags, image, services) — опционально.

Как управлять модулем

Два рычага:

  1. variables — основной способ. Через переменные настраивается 95% логики: включение фич, выбор веток, ручные запуски, параметры. Это осознанный выбор — когда перейдем на GitLab Components, переменные легко превратятся в inputs.

  2. needs — что бы строить DAG и задавать порядок запуска. Закрывает оставшиеся случаи, когда нужно явно указать зависимости.

Проблема с инкапсуляцией

У include + extends слабая изоляция. Потребитель теоретически может переопределить внутренние переменные или сломать rules. Полной защиты от этого нет, а попытки ее реализовать усложнят код. Мы решили не париться — миграция на GitLab Components решит эту проблему. Пока допускаем, что модуль можно сломать, если использовать его неправильно. Это компромисс ради скорости.

Пример модуля

Пример типового модуля .build_template (контракт, переменные, правила, шаблон)
# Переменные модуля (контракт):# - DEV_BRANCH, STAGE_BRANCH, PROD_BRANCH, RELEASE_BRANCH  (обязательно снаружи)# - FEATURE_FLAG           (по умолчанию: 'false')# - DISABLE_MR             (по умолчанию: 'false') - не запускается в MR# - MANUAL_MODE            (по умолчанию: 'false') - ручная джоба на целевых ветках# - MIRROR_BASE            (по умолчанию: '') - префикс зеркала для image/services# - MODULE_OPTION_A        (по умолчанию: 'default-value')# - MODULE_OPTION_B        (опционально).build_template:  stage: build  tags:    - k8s  interruptible: true  image: ${MIRROR_BASE}alpine:3.20  services: []  before_script: []  variables:    # Дефолты контракта — переопределяются снаружи через variables    MIRROR_BASE: ''    FEATURE_FLAG: 'false'    DISABLE_MR: 'false'    MANUAL_MODE: 'false'    MODULE_OPTION_A: 'default-value'    MODULE_OPTION_B: ''  rules:    # Условия запуска — не трогать снаружи    - if: '$DISABLE_MR == "true" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME'      when: never    - if: '$MANUAL_MODE == "true" && ($CI_COMMIT_BRANCH == $DEV_BRANCH || $CI_COMMIT_BRANCH == $STAGE_BRANCH || $CI_COMMIT_BRANCH == $PROD_BRANCH || $CI_COMMIT_BRANCH =~ $RELEASE_BRANCH || $CI_COMMIT_BRANCH =~ /^test.*/)'      when: manual    - if: '$MANUAL_MODE == "true" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $DEV_BRANCH || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $STAGE_BRANCH || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $PROD_BRANCH || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ $RELEASE_BRANCH || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^test.*/)'      when: manual    - if: '$MANUAL_MODE != "true" && ($CI_COMMIT_BRANCH == $DEV_BRANCH || $CI_COMMIT_BRANCH == $STAGE_BRANCH || $CI_COMMIT_BRANCH == $PROD_BRANCH || $CI_COMMIT_BRANCH =~ $RELEASE_BRANCH || $CI_COMMIT_BRANCH =~ /^test.*/)'      when: on_success    - if: '$MANUAL_MODE != "true" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $DEV_BRANCH || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $STAGE_BRANCH || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $PROD_BRANCH || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ $RELEASE_BRANCH || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^test.*/)'      when: on_success  script:    - echo "module skeleton — подставить бизнес-логику"

Комментарий в начале — это документация. Потребитель сразу видит все переменные и их поведение. Локальные variables задают дефолты, rules используют эти переменные, но сами не переопределяются. Имя .build_template следует этому правилу, подключение предсказуемое.

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


Вынесение дефолтных переменных в отдельный файл

Когда модулей становится много, в каждом проекте приходится дублировать одни и те же переменные: названия веток, адрес registry, пути к утилитам, флаги. Это неудобно поддерживать.

Мы вынесли общие переменные в файл variables-default.yml в репозитории с модулями.


Приоритет переменных

Переменные из variables-default.yml подключаются через include и имеют самый низкий приоритет. Их можно переопределить в блоке variables: — на уровне проекта или конкретной job.

Цепочка приоритетов:

variables-default → модульные дефолты → project variables → job variables


Convention и Configuration

Раньше DevOps отвечал и за логику пайплайна, и за ее настройку в каждом проекте. После вынесения модулей разделили ответственность:

  • Convention — стандартное поведение модуля, дефолтные значения. Находится в репозитории CI-модулей. Отвечает DevOps-команда.

  • Configuration — конкретные значения для проекта. Находится в репозитории проекта. Отвечает команда сервиса.

В проекте остаются только отличия от шаблона. Все остальное берется из модулей.


Пример

Convention (CI-репозиторий)
# templates/variables-default/template.ymlvariables:  DEV_BRANCH:            'NON_EXISTENT_BRANCH'  STAGE_BRANCH:          'NON_EXISTENT_BRANCH'  PROD_BRANCH:           'NON_EXISTENT_BRANCH'  RELEASE_BRANCH:        'NON_EXISTENT_BRANCH'  ENABLE_TESTS:          'true'  ENABLE_LINT:           'true'  JQ_SOURCE:             https://github.com/stedolan/jq/releases/download/jq-1.5/jq-linux64  FF_TIMESTAMPS:         "true"  MIRROR_BASE:           ''
Configuration (репозиторий проекта)
stages:  - build  - deployinclude:  - project: 'dellavrite-terraform-modules/dellavrite-ci-modules'    ref: 'v1.7.2'    file: '/standard/variables-default.yml'  # 1. самый низкий приоритет  - project: 'dellavrite-terraform-modules/dellavrite-ci-modules'    ref: 'v1.7.2'    file: '/standard/build.yml'  - project: 'dellavrite-terraform-modules/dellavrite-ci-modules'    ref: 'v1.7.2'    file: '/standard/deploy.yml'variables:                                    # 2. переопределяет variables-default  DEV_BRANCH:           'dev'  STAGE_BRANCH:         'stage'  PROD_BRANCH:          'prod'  RELEASE_BRANCH:       '/^release-.*/'  ENABLE_DEPLOY:        'true'  MIRROR_BASE:          'registry.gitlab.com/your-registry/'  DEV_CHART_PATH:       'infra/cluster-dev/charts/my-app'  IMAGE_NAME:           'my-app'build:  extends: .build_templatebuild-manual:  extends: .build_template  variables:                                 # 3. переопределяет project + defaults    IMAGE_NAME:       'my-app'    MANUAL_MODE:     'true'                 # модуль дает 'false'deploy_dev:  extends: .deploy_dev  needs: [build]

Схема


Что это дает:

  • Общие переменные в одном месте

  • Новые проекты работают с минимальной настройкой

  • DevOps меняет дефолты для всех проектов сразу

  • В .gitlab-ci.yml проекта видны только отличия от шаблона

Базовый пример: как мы решили проблему «белого листа»

Когда модули и переменные готовы, мы столкнулись со следующей проблемой: разработчик создает новый сервис, открывает пустой .gitlab-ci.yml… и не знает, с чего начать. Даже если документация есть, сразу возникает десяток вопросов: какие модули подключать, в каком порядке, какие переменные обязательны, как это вообще должно выглядеть?

Есть два типичных пути:

  • Спросить у DevOps — но это нагружает команду и не масштабируется.

  • Скопировать у соседей — но это возвращает к исходной проблеме копипасты. Часто вместе с нужными строками подтягивается чужая бизнес-логика и устаревшие костыли.

Нам нужен один рабочий пример, который закрывает 80% типовых сценариев на старте и дает точку входа без вопросов.

Что такое базовый пример?

Это не генератор вроде cookiecutter. Это готовый .gitlab-ci.yml, который живет в репозитории с модулями и всегда готов к копированию.
Главное правило: он всегда прогоняется через контрольный проект, когда выходит новая версия CI. Поэтому, копируя его, разработчик получает рабочий пайплайн на момент релиза.

Этот файл показывает:

  • минимальный набор модулей для типового сервиса;

  • обязательный минимум переменных;

  • правильную фиксацию ref на конкретную версию — чтобы разработчик сразу видел, как надо, а не тянул main.

Как это работает: открыл файл, скопировал в свой проект, выставил минимальные переменные — пайплайн работает.

В базовом примере мы оставили три стадии: build, test и deploy. Но test и deploy по умолчанию закомментированы. На старте многим сервисам деплой и тесты еще не нужны, чтобы не перегружать разработчика. Когда потребность появится — достаточно просто снять комментарии. CI сразу готов к расширению.

Пример базового шаблона для быстрого старта
# =============================================================================# БАЗОВЫЙ ШАБЛОН GITLAB CI/CD# =============================================================================# 1. Скопируйте файл в корень проекта как .gitlab-ci.yml# 2. Заполните переменные ниже# 3. Секреты добавьте через GitLab: Settings → CI/CD → Variables# 4. Раскомментируйте блоки test / deploy при необходимости# =============================================================================stages:  - check_conflicts  - build  # - test    # ← раскомментируйте вместе с блоком "ТЕСТИРОВАНИЕ" ниже  # - deploy  # ← раскомментируйте вместе с блоком "ДЕПЛОЙ" нижеdefault:  retry: 2# =============================================================================# ПЕРЕМЕННЫЕ# =============================================================================variables:  # [ОБЯЗАТЕЛЬНО] Заполнить в этом файле  IMAGE_NAME: 'my-service'  DEV_BRANCH: 'dev'  STAGE_BRANCH: 'stage'  PROD_BRANCH: 'prod'  # [ОБЯЗАТЕЛЬНО] Добавить в GitLab Variables (Settings → CI/CD → Variables)  # YANDEX_REGISTRY_ID - ID реестра Yandex Container Registry  # [ДЛЯ DEPLOY] Заполнить в этом файле (когда раскомментируете deploy)  # INFRASTRUCTURE_REPO: 'infrastructure'  # DEV_CHART_PATH: 'cluster-dev/charts/my-service'  # STAGE_CHART_PATH: 'cluster-stage/charts/my-service'  # PROD_CHART_PATH: 'cluster-prod/charts/my-service'  DEV_ENVIRONMENT_URL: 'https://dev.example.com'  STAGE_ENVIRONMENT_URL: 'https://stage.example.com'  PROD_ENVIRONMENT_URL: 'https://prod.example.com'  # [ДЛЯ DEPLOY] Добавить в GitLab Variables  # GITLAB_PRIVATE_KEY - SSH-ключ для клонирования репозитория инфраструктуры  # GITLAB_HOST        - hostname GitLab-сервера (например: gitlab.example.com)# =============================================================================# МОДУЛИ# =============================================================================include:  - project: 'dellavrite-terraform-modules/dellavrite-ci-modules'    ref: 'v1.7.2'    file: '/standard/variables-default.yml'  - project: 'dellavrite-terraform-modules/dellavrite-ci-modules'    ref: 'v1.7.2'    file: '/standard/check-conflicts.yml'  - project: 'dellavrite-terraform-modules/dellavrite-ci-modules'    ref: 'v1.7.2'    file: '/standard/build.yml'  # - project: 'dellavrite-terraform-modules/dellavrite-ci-modules'  #   ref: 'v1.7.2'  #   file: '/standard/python/test.yml'  # - project: 'dellavrite-terraform-modules/dellavrite-ci-modules'  #   ref: 'v1.7.2'  #   file: '/standard/deploy.yml'# =============================================================================# ДЖОБЫ: BUILD# =============================================================================check_conflicts:  extends: .check_conflicts_templatebuild:  extends: .build_template  needs: []# =============================================================================# ДЖОБЫ: TEST# =============================================================================# test:#   extends: .test_template#   needs:#     - job: build#       artifacts: false# =============================================================================# ДЖОБЫ: DEPLOY# =============================================================================# Требует переменные: INFRASTRUCTURE_REPO, *_CHART_PATH, GITLAB_PRIVATE_KEY, GITLAB_HOST# deploy_dev:#   extends: .deploy_dev#   needs:#     - job: build#       artifacts: false# deploy_stage:#   extends: .deploy_stage#   needs:#     - job: build#       artifacts: false# deploy_prod:#   extends: .deploy_prod#   needs:#     - job: build#       artifacts: false

Что мы получили в итоге:

  • Проще начать. Разработчику не нужно читать всю документацию, чтобы запустить первый пайплайн.

  • Готовый пример. Наглядно показано, насколько коротким и понятным получается .gitlab-ci.yml при модульном подходе.

  • Мотивация использовать модули. Разработчики начинают пользоваться модулями не потому, что DevOps «спустили директиву», а потому что так проще, чем разбираться в чужом копипасте из соседнего проекта.

Этот базовый пример подходит для старта 80% типовых проектов. Но он не объясняет, что делать с оставшимися 20%, и не дает глубокого понимания всех доступных модулей. Для специфичных сценариев, тонкой настройки и понимания логики каждой переменной у нас есть отдельная документация в README каждого модуля. О ее формате поговорим далее.

Создание документации

Модули без документации — это те же копипаст-шаблоны, только в другом репозитории. Если README устаревает быстрее кода, вся идея SSOT не работает.

У документации в CI обычно две проблемы.

  • Первая — она протухает. Живет в Confluence или на wiki-странице, про которую забывают. На стадии поддержки проекта она устаревает почти сразу.

  • Вторая — ее тяжело читать. Чтобы интегрировать один модуль, разработчик вынужден читать длинный документ про весь CI-репозиторий и искать нужное.

Мы решили это через Docs-as-Code и децентрализацию.

Docs-as-Code — документация не живет в отдельной базе знаний. Она лежит рядом с кодом модуля — в той же папке, в том же MR, под теми же правилами ревью. Изменился модуль — в том же коммите меняется README. Не получилось — ревью не пройдет.

Самодостаточность — мы хотели, чтобы разработчику не приходилось читать всю документацию по репозиторию. Он открывает нужный модуль (например, deploy), читает один файл и получает все для интеграции. Никаких «а описание этой переменной смотрите в разделе X на wiki».

Подготовка к миграции
Этот подход готовит нас к переходу на GitLab CI Components. То, что мы сейчас описываем как таблицу переменных в README, в будущем станет блоком spec: inputs: в компоненте. Документация уже пишется в формате, который после переезда превратится в карточку CI/CD Catalog.

Структура README модуля

Мы сделали стандарт, обязательный для каждого модуля:

  1. Назначение. Один абзац: что делает модуль и какую задачу решает.

  2. Зависимости (опционально). Без каких модулей этот не заведется.

  3. Таблица переменных. Все локальные и групповые переменные + секреты:

Имя переменной

Тип

Дефолт

Обязательность

Описание

IMAGE_NAME

string

Yes

Имя собираемого образа

MANUAL_MODE

bool

false

No

Ручной запуск на таргетных ветках

MIRROR_BASE

string

‘’

No

Префикс зеркала для image/services

Это прообраз inputs. Разработчик сразу видит весь интерфейс взаимодействия с модулем, не открывая его код.

  1. Артефакты (опционально). Что модуль оставит после себя — dotenv, отчеты, бинари. Что можно подхватить через needs.

  2. Требования к инфраструктуре. Как должны быть настроены раннеры, какие теги нужны, что должно быть в самом репозитории-потребителе, какие сервисы развернуты в инфраструктуре (ArgoCD, SonarQube и т.д.).

  3. Примеры использования. Быстрый старт — готовые куски YAML (include + extends + variables), которые можно скопировать в свой .gitlab-ci.yml.

Борьба с протуханием: линтер документации

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

Сделали через два git-хука — pre-commit и pre-push.

  • pre-commit на каждый коммит проверяет две вещи:

    • Если в каком-то модуле появились или исчезли переменные — в том же коммите эти переменные должны отразиться в соответствующем README.md. Иначе коммит не пройдет.

    • Если изменился шаблон модуля (template.yml) — в README должно быть хоть какое-то изменение. Нет изменений — коммит отклонен.

  • pre-push проверяет одну вещь:

    • если в пуше ты навешиваешь тег, то в CHANGELOG.md этот тег обязан быть описан. Иначе пуш блокируется.

Плюс простой скрипт-установщик, который одной командой подключает хуки у разработчика.

Это снимает нагрузку с ревьюверов — им не нужно глазами ловить «а переменная-то не задокументирована».
И документация не устаревает: хук просто не даст закоммитить изменения без обновления README.

Главный README репозитория

Помимо документации по каждому модулю, в корне репозитория есть общий README — это входная точка, и у нее структура из пяти блоков:

  1. Архитектура и структура. Какие модули есть в репозитории, за что отвечает каждая папка и файл.

  2. Quick Start. Ссылка на готовый к старту файл — скопировал, и пайплайн пошел.

  3. Версионирование и подключение. Как работать с тегами, как читать CHANGELOG и понимать, что появилось в новой версии.

  4. Документация модулей. Описание структуры документации внутри каждого модуля — чтобы человек сразу понимал, где что искать.

  5. Разработка: git-хуки. Как быстро подключить хуки при локальной разработке.

Как работает линтер

Линтер проходится по всем модулям в templates/*/, парсит template.yml, вытаскивает все упоминания переменных (ключи в variables: и ссылки $VAR / ${VAR}), фильтрует служебные через IGNORE_REGEX (всякие CI_*, стандартные Docker-переменные, алиасы) и сверяет с содержимым README.md. Любая нестыковка — exit 1, коммит отклонен.

Документация теперь живёт в том же MR, что и код модуля, и хук физически не пропустит рассинхрон — писать её «потом» стало невозможно технически, а не только организационно. И заодно подготовились к переходу на GitLab CI Components, когда дорастем до нужных версий GitLab.

Внедрение

Инструмент готов и задокументирован. Осталось самое сложное — чтобы команды начали им пользоваться.

Мы сразу решили не спускать директиву
«с понедельника все на новых модулях».
Это всегда заканчивается одинаково: бунт, сломанные пайплайны, недовольные разработчики.
Поэтому миграцию разбили на части.

Четыре волны миграции

  • Пилот.
    Нашли одну-две лояльные команды, которые согласились попробовать первыми.
    На них поймали баги, которые не увидели на контрольном проекте.
    Риск минимальный, обратная связь — быстрая.

  • Новички.
    Все новые репозитории — только через базовый шаблон.
    Копипаст из старых проектов на ревью больше не проходит.
    Новые сервисы сразу растут на модулях — это проще, чем разбираться в чужом .gitlab-ci.yml на 500 строк.

  • Легаси.
    Когда первые проекты переехали, начали постепенно переводить остальные.
    Главное правило — не все сразу.
    Каждый второй сервис при переезде преподносил сюрприз, и если бы мы выпустили для всех разом, просто утонули бы в багах.

  • Мертвые души.
    Принцип «не трогай, пока не сломается».
    Если сервис не трогали год — мигрировать его не надо.
    Но как только приходит любая задача по нему — рефакторинг CI становится первым шагом.
    Ресурсы на спящие репозитории не тратим, но и копипасту плодиться не даем.

Снижаем порог входа

Если для переезда разработчику нужно читать лонгрид и самому разбираться с каждой проблемой — он будет саботировать.
Переезд должен быть простым.

Сделали две вещи:

  1. Запись митапа с разбором миграции.
    Показали на реальном проекте с кучей особенностей, как он переезжает.
    Разработчик видит конкретный кейс, а не абстрактную документацию.

  2. Простое правило обновления.
    Если ловишь баг — скорее всего он уже пофикшен в патч-версии.
    Обновиться быстрее, чем ждать DevOps.

Страх «черного ящика»

Были возражения: как доверить прод тому, что лежит в непонятном внешнем репозитории?

Первое время модули действительно ломали прод — любое изменение в общем инструменте несет риски.
Поэтому на первый месяц договорились:
если сломался CI на новых модулях, DevOps чинит это прямо сейчас, а не «в следующем спринте».

Это сняло основное напряжение.
Команды поняли, что в сложной ситуации их не бросят, и перестали воспринимать модули как нечто чужое и неконтролируемое.

Результаты и метрики

Главный триггер рефакторинга — миграция с Kaniko на BuildKit. По нашей изначальной оценке, ручная правка всех .gitlab-ci.yml заняла бы 2-3 недели. С модулями вышло так:

  • 2 дня — написание модулей сборки

  • 2 дня — внедрение в проекты

  • 1 день — миграция с Kaniko на BuildKit во всех проектах

5 дней вместо 2-3 недель.


Три сценария «до и после»

1. Багфикс в CI для 20 проектов

До модулей

После модулей

Количество MR

20+ в каждый проект

1-2 в репозиторий с модулями

Время DevOps

11 часов

1 час

Время ответственных

3,3 часа (асинхронно, ~10 минут на сервис)

Забытые сервисы

10% забываются, при следующем обновлении их приходится внедрять заново

Для забытого сервиса достаточно просто обновить версию модуля, чтобы получить все изменения

Процесс багфикса ДО

Процесс багфикса ДО
Процесс багфикса ПОСЛЕ

Процесс багфикса ПОСЛЕ

2. Минорное обновление функционала на 6 сервисах

До модулей

После модулей

Количество MR

6+ в проекты

1-2 в репозиторий с модулями

Время DevOps

8 часов

2,25 часа

Время ответственных

3 часа (асинхронно, ~30 минут на сервис)

Процесс минорного обновления ДО
Процесс минорного обновления ПОСЛЕ

3. Создание CI в новом репозитории

  • Было: найти репозиторий, который выглядит «наиболее шаблонным», скопировать .gitlab-ci.yml, вычистить лишнее, дописать новое.

  • Стало: подключить нужные модули через include и прописать переменные.


Что изменилось в цифрах

  • Дрейф конфигураций. Раньше 10-15% сервисов со временем обрастали уникальными решениями — чем дольше живет пайплайн, тем сильнее отличается от остальных. Сейчас дрейф минимальный, в любом новом проекте видишь знакомую структуру.

  • Качество внедрений. Раньше 15-20% внедрений новых практик не срабатывали с первого раза из-за различий в пайплайнах. Сейчас 97% внедрений работают сразу.

  • Скорость внедрения новых практик. Линтеры, security-сканеры, новые проверки — добавляем в одном месте, и они сразу доступны всем проектам.

  • Разделение ответственности. Мы четко разделили CI-инфраструктурную логику (как собирать, как деплоить) и бизнес-логику (что именно собирать и куда).


Бонусы, которых не ждали

  • Документация CI. Когда логика собрана в одном репозитории, документировать ее проще. Покрытие выросло, разработчики начали ей пользоваться — перестали каждый раз спрашивать DevOps «а как у тебя тут это работает».

  • Bus factor. Знания о том, как устроен CI, теперь в репозитории, а не в голове у конкретного инженера. Кто-то ушел в отпуск или из компании — процессы не встают.

Та самая грабля, когда каждому проекту надо чутка по другому и как мы ее обошли

Базовые модули закрывают 80% типовых задач. Оставшиеся 20% — это проекты с экзотическим стеком, кастомным препроцессингом или специфичными средами. Например:

  • Супер-легаси, где перед деплоем нужно накатывать специфичные миграции.

  • Data Science проект, которому для тестов требуется GPU-раннер.

  • Сервис, который собирается не в Docker-образ, а в бинарник.

Когда команда сталкивается с таким проектом, обычно возникают две реакции. Обе ведут к проблемам.

  1. Форк шаблона. Кажется, что проще скопировать template.yml в проект и поправить под себя. Но это убивает единый источник правды (SSOT). Мы снова получаем config drift, от которого пытались избавиться.

  2. Переопределение внутренностей. Проект не форкает модуль, а пытается его обойти: переопределяет image в обход контракта, инъектит команды в before_script или переписывает rules. Пайплайн становится хрупким и падает при первом же обновлении базового модуля.


Как дать гибкость и не сломать SSOT

Мы используем четыре подхода:

  • Точки расширения. Модуль закрыт для прямого изменения script, но умеет безопасно выполнять внешние скрипты. Если проекту нужен кастомный шаг, он передает его через переменную (например, PRE_BUILD_SCRIPT), а модуль сам исполняет его в нужном месте.

  • Декомпозиция вместо if/else. Если проекту нужен принципиально другой процесс, мы не пишем в базовом модуле build ветвления на все случаи жизни. Мы делаем отдельный модуль build-bare-metal.

  • Sidecar-паттерн. Проект вообще не трогает базовый модуль. Вместо этого в .gitlab-ci.yml создается локальная джоба, которая по needs: забирает артефакты из модульной джобы и делает свою специфику. Модуль остается нетронутым.

  • Toggle-переменные. Включаем и выключаем опциональные шаги переменными. Например, ENABLE_LINT: false в модуле тестов легально отключает линтеры без правки кода самого модуля.


Организационные правила

Чтобы исключения не плодились бесконечно, мы добавили два правила:

  • Правило трех проектов. Если кастомизация нужна одному проекту — это локальный костыль (используем sidecar или хуки). Если это нужно трем и более проектам — значит, в ядре не хватает функционала. Создаем MR в центральный репозиторий и делаем фичу для всех.

  • Ревью локальных обходов. Любой override стандартного поведения проходит ревью. Главный вопрос ревьюера: «Почему это нельзя сделать фичей для всех?».


Модули-монолиты

Важный момент: не делайте модули-монолиты. Рано или поздно один универсальный build перестанет справляться, и появится матрица: build-python, build-go, build-nodejs. Это нормально. Лучше дробить модули на специализированные, но сохранять у них единый интерфейс (одинаковый набор переменных, нейминг, правила), чем городить один модуль с сотней условий if.

К тому же, это отличная подготовка к переходу на GitLab Components, где spec: inputs будет жестко задавать контракт.

Схема «Антипаттерн vs Паттерн»:

Модульность — это не запрет на кастомизацию. Это перевод кастомизации из плоскости «переписать чужой код» в плоскость «настроить интерфейсы и скомпоновать джобы».

Итоги

Переход на модули — это не просто перенесли YAML-код в отдельный репозиторий. Это изменило роль DevOps-команды. Для разработчиков CI-модули стали продуктом — с версиями, контрактом переменных и понятными ожиданиями по стабильности, а не общей папкой с YAML, которую страшно трогать.

Наша текущая архитектура на include и переменных — это не временный костыль, а правильная база.
Когда мы обновим GitLab до 16.x+, миграция на нативные CI/CD Components и публикация во внутренний каталог займет дни, а не недели. Границы модулей уже спроектированы так, что нам останется только переписать синтаксис подключения и заменить переменные на spec: inputs.

За время внедрения мы вынесли для себя три правила:

  1. Внедрение — это социальная задача.
    Модули могут быть технически безупречны, но если команды не понимают, зачем им это, и не чувствуют, что их слышат, вы получите саботаж.

  2. Начинайте с малого.
    Не пытайтесь перевести все проекты за один спринт, иначе утонете в edge-cases и легаси-костылях.

  3. Инвестиции в CI окупаются в кризис.
    В спокойное время рефакторинг пайплайнов кажется тратой времени. Но когда нужно за день внедрить критический фикс или новый сканер безопасности на сотни проектов, модульная архитектура спасает от ночных марафонов с cherry-pick.


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

Сейчас глобальные обновления перестали быть проблемой. Когда прилетает задача вроде «расставить новые Quality Gate на все проекты», это больше не вызывает паники и оценок в несколько спринтов. Мы просто правим один модуль, выпускаем минорный релиз, и команды спокойно обновляют ref в своих .gitlab-ci.yml.

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

  • Кто уже сидит на Components в проде на self-hosted — насколько было больно? И кто использует генераторы вместо include — что перевесило порог входа?

  • Вы полагаетесь только на хуки, или есть еще и серверная проверка в MR-пайплайне самого CI-репозитория? Если только хуки — как вы уверены, что все их установили?

  • Как вы страхуетесь от того, что PRE_BUILD_SCRIPT превратится в помойку с копипастой скриптов по всем проектам — тем самым конфиг-дрейфом, только на уровне shell вместо YAML?

Ресурсы

  • GitLab CI/CD Components — документация, на структуру которой мы ориентировались с самого начала, и на которую в перспективе переезжаем с include.

  • Semantic Versioning 2.0.0 — спецификация SemVer, по которой версионируем модули.

  • dellavrite-ci-modules — репозиторий с уже реализованными модулями, о которых шла речь в статье.

ссылка на оригинал статьи https://habr.com/ru/articles/1061280/