
В последнее время мне довелось столкнуться с огромным количеством CI в GitLab. Я каждый день писал свои и читал чужие конфиги. Мой день буквально выглядел как:
<code class="language-yaml"> --- day: tasks: - activity: "Поесть" priority: medium - activity: "Душ" priority: low - activity: "Читать документации GitLab" priority: high - activity: "Писать GitLab CI" priority: high - activity: "Спорить с chatgpt" priority: high </code>
Поэтому сейчас, когда я снова научился писать обычный текст не в формате YAML, я решил собрать лучшие практики и механизмы, с которыми мне довелось работать, и поделиться с Хабром, как сделать наши пайплайны более красивыми и лаконичными.
Механизмы, описанные в данной статье, актуальны для версии v17.11.3-ee. Для других версий советую проверить наличие инструмента.
Extends и anchors — повторное использование job
Начать хочется с механизмов, которые позволяют нам избежать дублирования кода. Самые простые — это якоря (anchors) и правила extends: с их помощью мы можем не писать одинаковый код в разных заданиях. И хотя на первый взгляд принцип их работы очень похож, на деле это совершенно разные инструменты, поэтому давайте подробнее разберёмся с каждым.
▍ YAML anchors
Является встроенным функционалом YAML, а не фичей GitLab. Позволяет помечать якорем
&имя
блок и использовать его далее через <<: *имя
.<code class="language-yaml"> --- .common-config: &common_config image: alpine:3.22 before_script: - echo "absolute" > whoIam job: <<: *common_config script: - cat whoIam </code>
Здесь мы сделали базовую конфигурацию
.common-config
, название начинается с точки, поэтому это задание не будет исполняться в пайплайне. Далее job
включает в себя якорь, таким образом все поля из базовой конфигурации будут включены и в job
. Эквивалент:
<code class="language-yaml"> --- job: image: alpine:3.22 before_script: - echo "absolute" > whoIam script: - cat whoIam </code>
На первый взгляд всё выглядит очень радужно, можно переиспользовать код и сократить похожие задания. Однако, как уже упоминалось выше, это фишка самого yaml, поэтому работают якоря только в рамках одного файла. Также уровень интеграции не очень высок, так как мы просто вставляем кусок конфига в какое-то место.
▍ Extends
А вот
extends
— это уже директива самого GitLab, это значит, что отрабатывает она на этапе парсинга, что уже звучит как что-то более гибкое. Собственно говоря, так и есть. extends
умеет работать с разными файлами, мы можем обращаться к заданиям, полученным из include
(об этом подробнее дальше). Также extends
обладает глубоким слиянием для словарей (variables, environment, rules): одинаковые поля не заменяется, а объединяются. При этом списки (script, tags) заменяются полностью.Пример 1:
<code class="language-yaml"> --- .common_config: image: alpine:3.22 before_script: - echo "amsolute" > whoIam job: extends: .common_config script: - cat whoIam </code>
Эквивалент:
<code class="language-yaml"> --- job: image: alpine:3.22 before_script: - echo "absolute" > whoIam script: - cat whoIam </code>
Пример 2:
<code class="language-yaml"> --- .base_job: image: alpine:3.22 variables: BASE: "yes" LEVEL: "base" tags: - shared artifacts: paths: - logs/ expire_in: 1 hour .override_job: extends: .base_job variables: LEVEL: "override" EXTRA: "true" tags: - linux artifacts: when: always final_job: extends: .override_job script: - echo "BASE=$BASE" - echo "LEVEL=$LEVEL" - echo "EXTRA=$EXTRA" </code>
Эквивалент:
<code class="language-yaml"> --- final_job: image: alpine:3.22 variables: BASE: "yes" LEVEL: "override" EXTRA: "true" tags: - linux artifacts: paths: - logs/ expire_in: 1 hour when: always script: - echo "BASE=$BASE" - echo "LEVEL=$LEVEL" - echo "EXTRA=$EXTRA" </code>
При этом мы можем совмещать
anchor
и extends
, чтобы объединять и словари, и списки.Финальный пример:
<code class="language-yaml"> --- .default_scripts: &default_scripts - echo "start" - echo "done" .base_job: image: alpine:3.22 tags: - shared variables: VAR1: "from_base" VAR2: "base_value" artifacts: paths: - base.log expire_in: 2h final_job: extends: .base_job variables: VAR2: "override_value" VAR3: "new_value" artifacts: when: always script: <<: *default_scripts - echo $VAR1 - echo $VAR2 - echo $VAR3 </code>
Эквивалент:
<code class="language-yaml"> --- final_job: image: alpine:3.22 tags: - shared variables: VAR1: "from_base" VAR2: "override_value" VAR3: "new_value" artifacts: paths: - base.log expire_in: 2h when: always script: - echo "start" - echo "done" - echo $VAR1 - echo $VAR2 - echo $VAR3 </code>
Отлично! Надеюсь, примеры привнесли ясность в разницу между этими механизмами. Если подытожить, сам GitLab рекомендует использовать именно
extends
из-за их гибкости и более ясного поведения. Однако якоря также имеют место быть в некоторых сценариях, особенно при работе со скриптами, когда мы не хотим дублировать какой-то кусок. ▍ Бонус! Директива !reference
Также является фичей GitLab. Позволяет копировать конкретные куски заданий. Как и extends, не ограничена одним файлом.
<code class="language-yaml"> --- job: script: - !reference [.common, script] </code>
Здесь мы берём задание
.common
и копируем из него script
в script
задания job
. Таким образом мы может спускаться до любого уровня yaml. Например, можно скопировать значение конкретной переменной:<code class="language-yaml"> --- VAR1: !reference [.vars, variables, BEST_VAR] </code>
Однако не стоит злоупотреблять этой директивой, она сильно снижает читаемость. И когда её слишком много, чтение CI превращается в прыжки по 5 конфигам в поисках нужной строчки.
include — 3 файла по 100 строк лучше одного на 300
Механизм, который позволяет подключать внешние yaml-файлы. Если наш конфиг становится слишком большим, работа с ним усложняется, а чтение превращается в бесконечные прыжки. Для решения этой проблемы мы можем поделить наш CI на логические части и разнести по разным файлам. Также
include
позволяет создавать шаблоны для конфигов, что может быть отличным решением в проектах, где у нас есть схожие задания. Поддерживает множество различных форматов подключения: локальные файлы, файлы из другого проекта, файлы по URL, встроенные шаблоны GitLab (об этом подробнее дальше).Пример:
<code class="language-yaml"> --- include: - local: 'ci-templates/example.yml' - project: 'group/common-ci', ref: main, file: 'templates/example.yml' - remote: 'https://example.com/ci/common.yml' </code>
include
обрабатывается yaml-парсером GitLab. По сути мы просто сливаем несколько конфигов в один. При этом слияние глубокое, т. е. структуры словарей будут объединяться аналогично тому, как это работает в extends
. Также include
даёт нам возможность переопределять значения, последнее определение всегда будет побеждать. Пример:
<code class="language-yaml"> #-------------ci-templates/base.yml------------# --- default: image: alpine:3.22 before_script: - echo "[base] preparing" .build_template: script: - echo "[template] build step" variables: LEVEL: "template" #---------------------.gitlab-ci.yml--------------------# --- include: - local: 'ci-templates/base.yml' default: before_script: - echo "[project] extra prep" variables: PROJECT_VAR: "42" build: extends: .build_template variables: LEVEL: "override" script: - echo "[project] custom build" - echo "LEVEL=$LEVEL" - echo "PROJECT_VAR=$PROJECT_VAR" </code>
Эквивалент:
<code class="language-yaml"> --- default: image: alpine:3.22 before_script: - echo "[project] extra prep" variables: LEVEL: "template" PROJECT_VAR: "42" build: variables: LEVEL: "override" script: - echo "[project] custom build" - echo "LEVEL=$LEVEL" - echo "PROJECT_VAR=$PROJECT_VAR" </code>
Переменные inputs
Как упоминалось выше,
include
можно использовать для создания шаблонов. В примере выше мы использовали переопределение для работы с шаблоном, но GitLab предлагает для этих целей более лаконичное решение -inputs
. inputs
поддерживает типы и валидацию, что является отличным способом стандартизировать работу с шаблонами. Помимо возможности задать тип, у них есть ещё ряд отличий от обычных переменных:- задаются один раз при запуске пайплайна и не меняются;
- могут иметь опции выбора;
- могут иметь значение по умолчанию;
- должны быть обязательно определены.
inputs
можно начинать писать на верхнем уровне yaml, но этот подход не очень рекомендуется самим GitLab. Предпочтительнее использовать spec
на верхнем уровне и внутри уже inputs
.Пример:
<code class="language-yaml"> --- spec: inputs: # обычный string APP_NAME: description: "Имя приложения (используется в тегах/репортах)" type: string default: "demo-app" # string c выбором ENVIRONMENT: description: "Куда деплоим" type: string required: true options: ["dev", "staging", "prod"] # string с валидацией по regex RELEASE_TAG: description: "Тег в формате vMAJOR.MINOR.PATCH" type: string regex: pattern: "^v\\d+\\.\\d+\\.\\d+$" message: "Ожидается SemVer вида v1.2.3" # boolean RUN_MIGRATIONS: description: "Запускать ли миграции схемы БД" type: boolean default: false # integer RETRY_COUNT: description: "Сколько раз повторять flaky-тесты" type: integer default: 3 # number (double) THRESHOLD: description: "Минимальный процент покрытия тестами" type: number default: 0.95 # array EXTRA_ARGS: description: "Дополнительные флаги CLI (массив строк)" type: array default: - "--verbose" - "--color" # map DEPLOY_TARGETS: description: "Карты окружение → URL хоста" type: map default: dev: "dev.example.com" staging: "staging.example.com" prod: "example.com" # file с опцией необязательной передачи CONFIG_FILE: description: "Пользовательский конфиг (JSON)" type: file required: false --- variables: APP_NAME: $[[ inputs.APP_NAME ]] ENVIRONMENT: $[[ inputs.ENVIRONMENT ]] RELEASE_TAG: $[[ inputs.RELEASE_TAG ]] RUN_MIGRATIONS: $[[ inputs.RUN_MIGRATIONS ]] RETRY_COUNT: $[[ inputs.RETRY_COUNT ]] THRESHOLD: $[[ inputs.THRESHOLD ]] # массив и map приходят JSON-строкой EXTRA_ARGS_JSON: $[[ inputs.EXTRA_ARGS ]] DEPLOY_TARGETS: $[[ inputs.DEPLOY_TARGETS ]] CONFIG_FILE: $[[ inputs.CONFIG_FILE ]] </code>
Пример использования:
<code class="language-yaml"> --- include: - local: 'ci-templates/base.yml' inputs: APP_NAME: "awesome-api" ENVIRONMENT: "staging" RELEASE_TAG: "v2.1.0" RUN_MIGRATIONS: true RETRY_COUNT: 2 THRESHOLD: 0.9 EXTRA_ARGS: - "--workers=4" - "--timeout=60" DEPLOY_TARGETS: dev: "dev.awesome.local" staging: "staging.awesome.local" prod: "awesome.local" CONFIG_FILE: ".deploy/config.staging.json" </code>
Для удобства предпочтительнее выносить
inputs
потом в variables
, это сделает Ваш конфиг более читаемым и менее перегруженным визуально.Важно отметить, что
inputs
не поддерживают передачу секретов, они не скрывают переменные.Дочерние пайплайны — ещё больше гибкости
В GitLab существует замечательный механизм
trigger
, который позволяет запускать отдельный конвейер, создавая вложенные пайпланы. Это отличное решение, когда в CI нужна гибкая разделённая логика. Дочерние пайплайны бывают двух видов: статические и динамические.
▍ Статический
Мы заранее подготавливаем yaml-файл и далее, используя
trigger
, создаём на его основе новый конвейер. Добавление происходит либо через include
, либо через project
. Также мы можем указать:-
strategy: depend
— дожидаемся окончания дочернего и зеркалируем его статус в job; -
variables:
— способ передать YAML-переменные; -
forward:
— настройка, какие именно переменные мы хотим передавать (переменные пайплайна, задания, секреты); -
environment:
— помечаем как деплой в определённую среду.
Пример:
<code class="language-yaml"> --- stages: [prepare, child] run_child_pipeline: stage: child rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH trigger: include: - local: .gitlab/child/base.yml strategy: depend forward: pipeline_variables: true yaml_variables: true job_variables: false secret_variables: true variables: DEPLOY_ENV: $CI_COMMIT_REF_NAME THRESHOLD: "85" inputs: APP_NAME: "backend-api" RUN_MIGRATIONS: true EXTRA_ARGS: ["--concurrency=4"] environment: review/$CI_COMMIT_SHORT_SHA </code>
Статические дочерние пайпланы — отличное решение, когда логика уже поделена на несколько конфигов, и мы хотим их запускать из
.gitlab-ci.yml
. ▍ Динамический
Сами механизмы никак не меняются, но подход к реализации становится более гибким. Мы будем сами генерировать yaml-кофиг прямо в CI. Работает по принципу
генератор + триггер
. Давайте сразу посмотрим на пример:<code class="language-yaml"> --- stages: [generate, child] generate_child_config: stage: generate image: alpine:3 script: - | cat > dynamic-child.yml <<'EOF' stages: - test - deploy test_job: stage: test script: - echo "Тесты внутри динамического пайплайна" deploy_job: stage: deploy script: - echo "Деплой из child-pipeline" when: manual EOF artifacts: paths: [dynamic-child.yml] run_child_pipeline: stage: child needs: [generate_child_config] trigger: include: - artifact: dynamic-child.yml job: generate_child_config strategy: depend </code>
В job
generate_child_config
скрипт выводит yaml-конфиг в файлdynamic-child.yml
, GitLab сохраняет его как артефакт. Далее в jobrun_child_pipeline
:- в директиве
include
указываем, что включаем артефакт; - GitLab разворачивает этот yaml как дочерний конвейер;
- благодаря
strategy: depend
родительский job завершится только после child-pipeline и примет его итоговый статус.
Пока что всё звучит очень классно и красиво, но, к сожалению, всё-таки есть ряд ограничений:
- В динамически сгенерированном yaml нельзя использовать переменные в секциях
include
внутри него. Т.е. если сгенерированный файл сам используетinclude: $VAR/file.yml
— это не сработает. - Ограничений на количество вложенных
include
— 150. -
include: astifact
не поддерживает передачу CI/CD переменных.
Выводы
Надеюсь, эта статья поможет сделать ваш GitLab CI более понятным, модульным и лаконичным. Используйте
extends
для переиспользования, include
— для структурирования, inputs
— для стандартизации, а trigger
— для гибкости.© 2025 ООО «МТ ФИНАНС»
Telegram-канал со скидками, розыгрышами призов и новостями IT ?

olku
Спасибо за статью, некоторые штуки не знал. Однако модульность и гибкость могут привести к противоположному эффекту - никто кроме GitLab и Ops экспертов не понимает эффективный конфиг. Мы в конторе запретили метапрограммирование пайплайнов - пусть будет на сотню строк больше но просто и декларативно.