В последнее время мне довелось столкнуться с огромным количеством 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 сохраняет его как артефакт. Далее в job
run_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 💻
