Красивый GitLab CI: extends, якоря, include, trigger

от автора

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 💻


ссылка на оригинал статьи https://habr.com/ru/articles/922244/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *