image

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

В jobgenerate_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 ?

Комментарии (4)


  1. olku
    01.07.2025 15:42

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


  1. ashkraba
    01.07.2025 15:42

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


  1. ma1uta
    01.07.2025 15:42

    И ни слова про gitlab ci components...


  1. Chelyuk
    01.07.2025 15:42

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