include, extends и !reference: DRY-пайплайны

Учимся не копипастить YAML: подключать шаблоны и наследовать настройки джоб.

extends — механизм наследования: джоба перенимает поля другой джобы или скрытого шаблона, переопределяя нужное.

Враг номер один — копипаста

Когда пайплайн растёт, в нём появляются почти одинаковые джобы: десять деплоев в разные окружения, отличающиеся одной строкой. Копипаста делает файл хрупким: правку придётся вносить в каждую копию. GitLab даёт три инструмента переиспользования: include, extends и !reference.

Стоит проговорить, почему DRY в CI болезненнее, чем DRY в обычном коде. В коде дублирование ловят тесты и компилятор; в YAML-пайплайне нет ни того, ни другого — забытая в одной из десяти копий правка не вызовет ошибку, она просто тихо приведёт к тому, что один деплой ведёт себя иначе, и вы узнаете об этом в худший момент, на проде. Конфигурация пайплайна — это код, который запускает весь остальной код, поэтому цена незаметного расхождения тут особенно высока. Хорошее эмпирическое правило: как только два блока YAML совпадают на 80% и больше, пора выносить общее в шаблон. Это не эстетика, а защита от класса багов «починили в одном месте, забыли в девяти».

Полезно сразу разложить три инструмента по назначению, чтобы не путать их: include отвечает на вопрос «откуда взять конфигурацию» (другой файл, другой проект, шаблон GitLab); extends — «как унаследовать целую джобу и что в ней переопределить»; !reference — «как вставить один конкретный кусок чужой джобы в нужное место своей». Они не конкурируют, а складываются: типичный зрелый пайплайн через include подтягивает общий файл с базовыми джобами, реальные джобы наследуют их через extends, а отдельные фрагменты дотачивают через !reference.

ИнструментУровеньОтвечает на вопрос
includeфайл целикомОткуда взять конфигурацию?
extendsджобаКак унаследовать джобу и что переопределить?
!referenceотдельное полеКак вставить кусок чужой джобы в свою?

include: подключаем внешние файлы

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

include:
  - local: '/ci/build.yml'
  - project: 'my-group/ci-templates'
    file: '/deploy.yml'
  - template: 'Jobs/SAST.gitlab-ci.yml'

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

Каждый вид include закрывает свой сценарий. local просто разбивает большой .gitlab-ci.yml одного репозитория на читаемые куски — полезно, когда файл разросся. project — самое мощное: вы держите CI-шаблоны в одном репозитории организации и подключаете их во все остальные проекты, фиксируя версию через ref (тег или ветку). Так платформенная команда правит общий деплой в одном месте, а десятки сервисов получают обновление, лишь сдвинув ref. template подтягивает готовые шаблоны самого GitLab (SAST, Dependency Scanning, Code Quality), а remote — файл по произвольному URL. Эта централизация — то, ради чего include вообще существует: единый источник правды вместо синхронизации YAML вручную по репозиториям.

extends и скрытые джобы

Джоба, имя которой начинается с точки (.deploy-base), — скрытая: она не выполняется сама, а служит шаблоном. Реальные джобы наследуют её через extends:

.deploy-base:
  stage: deploy
  image: bitnami/kubectl:latest
  script:
    - kubectl apply -f k8s/

deploy-staging:
  extends: .deploy-base
  environment:
    name: staging

deploy-production:
  extends: .deploy-base
  environment:
    name: production
  when: manual

Обе джобы берут общий script и image из .deploy-base, добавляя своё окружение. Правку базовой логики делаешь в одном месте.

Как именно сливаются поля

Здесь скрыта ловушка, на которой спотыкаются почти все новички, поэтому разберём механику слияния подробно. При extends GitLab сливает поля родителя и потомка по типу значения. Словари (map) сливаются по ключам: если у родителя в variables есть A и B, а потомок добавляет B и C, итог — A, переопределённый B и новый C. А вот списки (array) заменяются целиком: если потомок задаёт свой script, он полностью вытесняет родительский, а не дописывается к нему. Это контринтуитивно — кажется, что свои строки добавятся к базовым, но нет. Поэтому «унаследовать базовый script и добавить пару своих шагов» через один лишь extends не выйдет; для этого как раз и нужен !reference, о котором ниже. Запомните разницу: map-поля (variables, environment) дружелюбно сливаются, list-поля (script, before_script, rules) перезаписываются.

!reference: переиспользуем кусочки

Иногда нужно взять не всю джобу, а конкретный кусок — например, общий before_script. Для этого есть !reference: он подставляет значение по пути из другой джобы.

.setup:
  before_script:
    - echo "общая подготовка"
    - apk add --no-cache curl

test:
  before_script:
    - !reference [.setup, before_script]
    - echo "плюс шаги именно для теста"
  script:
    - run-tests

!reference точечнее, чем extends: он позволяет смешивать переиспользованные части со своими в нужном порядке.

Заметьте синтаксис: !reference — это YAML-тег (восклицательный знак перед именем), а путь [.setup, before_script] читается как «возьми из скрытой джобы .setup поле before_script». Именно так решается проблема замены списков из предыдущего раздела: вы вставляете ссылку на родительский список как элемент своего списка и дописываете рядом собственные шаги — порядок полностью под вашим контролем. Практический критерий выбора: нужна целая джоба-шаблон с общими image, stage, rules — берите extends; нужно вплести один-два общих шага в свой script или before_script — берите !reference. Они отлично сочетаются в одной джобе.

Как работает под капотом

На этапе загрузки конфигурации GitLab сначала собирает финальный YAML: подтягивает все include, разворачивает extends (рекурсивно сливая поля родителя и потомка с приоритетом потомка) и подставляет !reference. Только потом этот итоговый, «плоский» конфиг превращается в джобы. Поэтому в редакторе CI есть кнопка «View merged YAML» — посмотреть, что получилось после всех слияний.

Ключевая мысль: всё переиспользование разрешается до запуска пайплайна, на этапе компиляции конфигурации сервером — никакой магии во время выполнения нет. Раннер получает уже плоскую, развёрнутую джобу без следов extends и !reference. Это даёт удобный приём отладки: вместо того чтобы в уме «складывать» три уровня наследования и десять include, откройте «View merged YAML» (или эндпоинт CI Lint) и посмотрите финальный результат глазами. Если джоба ведёт себя странно — почти всегда ответ виден в смерженном YAML: какое-то поле перезаписалось не так, как вы ожидали.

Если сравнивать с GitHub Actions, модель переиспользования там устроена иначе. У Actions нет extends/!reference на уровне полей; зато есть reusable workflows (вызов целого workflow через uses: с входами inputs) и composite actions (упаковка нескольких шагов в один переиспользуемый action). Грубое соответствие: GitLab include: project ≈ reusable workflow из другого репозитория, а composite action ≈ вынесенный набор шагов, который вставляешь как один. Философское отличие: GitLab переиспользует декларативно сливая YAML-поля до запуска, тогда как Actions переиспользует вызывая другой workflow/action как функцию с параметрами. Подход GitLab гибче в мелочах (можно дотянуться до любого поля через !reference), подход GitHub — строже и явнее в границах (чёткие входы/выходы). Понимая обе модели, легче переносить пайплайны между платформами.

Частые ошибки

  • Забыть точку в имени базовой джобы — тогда она перестанет быть скрытой и попытается выполниться.
  • Полагаться на глубокое слияние массивов в extends: списки (например, script) заменяются целиком, а не дописываются.
  • Подключать слишком много include без проверки итогового YAML — легко получить неожиданное переопределение.
  • Подключать include: project без фиксации ref — однажды апстрим-шаблон поменяется и молча сломает ваш пайплайн.
  • Городить три-четыре уровня extends подряд — наследование становится нечитаемым, отладка через «merged YAML» обязательна.

Итоги

  • include подключает внешние файлы/шаблоны — единый источник правды для команды.
  • extends наследует скрытые джобы-шаблоны (имя с точкой), переопределяя нужное.
  • Map-поля сливаются по ключам, list-поля (script) заменяются целиком — частый источник сюрпризов.
  • !reference переиспользует отдельные части и решает проблему замены списков; «View merged YAML» показывает итог.
Проверьте себя
1. Что обозначает точка в начале имени джобы (например, .deploy-base)?
AДжоба выполняется первой
BЭто скрытая джоба-шаблон: сама не выполняется, служит для наследования через extends
CДжоба отключена навсегда
DДжоба запускается только вручную
2. Чем !reference отличается от extends?
AНичем
B!reference подставляет конкретный кусок (например, before_script) и позволяет смешивать со своими шагами, а extends наследует джобу целиком
C!reference работает только с include
Dextends переиспользует части, а !reference — всю джобу