
В последнее время мне довелось столкнуться с огромным количеством 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 ?
Комментарии (12)

ashkraba
01.07.2025 15:42И после всего этого кто ещё будет утверждать, что Jenkins это сложно и не понятно. Смотрю вот на все это и радуюсь, что не пользуюсь gitlab ci. Имхо

Chelyuk
01.07.2025 15:42Крутая подборка фич.
У меня только вопрос - есть ли какой-то вменяемый способ дебажить пайплайн в принципе. Ну и особенно со всеми вложениями вроде variables/include/etc? Или я правильно понял, что ничего особенно хорошего для этой проблемы нет?
dr_clinker
01.07.2025 15:42На странице проекта, в котором запускается пайплайн можно получить полностью отрендеренный файл, со всеми вложениями в одном месте

luxter
01.07.2025 15:42Спасибо за материал. Многими штуками пользовался и пользуюсь. Считаю, что чем проще сделано - тем лучше. Отлаживать эти пайпы то ещё удовольствие, особенно когда они тиражируются на кучу проектов.
Когда я сам делал типовой пайп для приложений, старался сделать стандартную и понятную всем коллегам историю с минимум сложностей. Но даже в этом случае, спустя пару лет, когда заходишь посмотреть и думаешь: блин, а как оно там было и зачем... Без документации ещё не всегда вспомнишь почему так или иначе в каком-то месте реализовано. Впрочем, как и везде)

boenu
01.07.2025 15:42так и не понял, нафига нужны дочерние пайплайны. Разве нельзя просто запустить их последовательно?

SimSonic
01.07.2025 15:42Дочерний может быть пайплайном другого проекта, например.
В репо с кодом -- собрал проект, потом джобой дернул другой проект с его деплоем.
Или ещё пример. Репо с contract-first спекой и репо с зависимым от неё проектом :)

Femistoklov
01.07.2025 15:42Поставил плюс, чтоб немного компенсировать страдания.
С CI гитлаба самая веселуха - это не синтаксис, а семантика. С тем, чтобы скрипты скомпоновать, проблем нет, а вот как заставить гитлаб делать всё так, как тебе требуется - это надо постараться.

SimSonic
01.07.2025 15:42Просто надо понимать, что с одной стороны гитлаб уже старый проект сам по себе, в нём много легаси, новые фичи иногда реализуются с оглядкой на старые ограничения, и часто являются затычками, чтобы обойти их. Например, extends вводили как раз чтобы решить проблемы якорей. А во-вторых, им всё-таки приходится развиваться с оглядкой на весь опыт его использования комьюнити, чтобы за раз ничего слишком много не поломать :)
olku
Спасибо за статью, некоторые штуки не знал. Однако модульность и гибкость могут привести к противоположному эффекту - никто кроме GitLab и Ops экспертов не понимает эффективный конфиг. Мы в конторе запретили метапрограммирование пайплайнов - пусть будет на сотню строк больше но просто и декларативно.