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» показывает итог.