По репозиториям на Git часто видно: вложенные пайплайны в GitLab CI остаются редкой экзотикой, а в проде в основном живут линейные сценарии. Тем не менее я убежден, что грамотные downstream прекрасно связывают процессы в единый поток, снимают ручной труд с команды и добавляют архитектуре гибкости. Конечно, запутаться в многоуровневой логике может даже опытный DevOps-инженер или тимлид, но чтобы вы не блуждали в сложной структуре, я подготовил для вас практическую шпаргалку по работе с ними.

Последние несколько лет я занимался сопровождением вендорской разработки, строил CI/CD преимущественно на базе Gitlab CI и мне есть, что рассказать. Помните фильм «Начало»? Сегодня он станет нашим наглядным гидом по архитектуре наследуемых пайплайнов и, надеюсь, поможет вам взглянуть на многослойный CI‑конвейер под другим углом.

Привет, Хабр! Я Даниэль Челноков, архитектор миграции в команде Cloud.ru, и идея написать статью в концепции фильма «Начало» (Inception) зрела во мне давно. Как мне кажется, он лучше всего иллюстрирует метафору вложенных структур на GitLab CI. Можете достать попкорн и включить бессмертный шедевр Нолана, чтобы освежить воспоминания, потому что сейчас мы разберем следующие паттерны:

  • Как корневой пайплайн может управлять последовательным и параллельным запуском дочерних пайплайнов, используя директивы needs и rules.

  • Как управлять вертикальным и параллельным ветвлением пайплайна, используя директивы trigger и parallels.

  • Как, используя шаблоны, эффективно обслуживать задачи с максимальным переиспользованием кода на примере директив includes и extends. Пока без директивы !reference, так как ее возможности тянут на отдельную статью.

  • Как обеспечить сквозную передачу переменных между окружениями.

Для кого эта статья

Эта статья подойдет для инженеров и разработчиков уровня middle и выше. Предполагается, что вы уже свободно читаете .gitlab-ci.yml и понимаете базовые директивы: stages, jobs, needs, rules, extend, includes и т.д. Если вы писали линейные пайплайны, но еще не собирали оркестрацию из дочерних пайплайнов через trigger, parallel:matrix и ветвление по статусу — поздравляю, это точно для вас.

В фильме команда спускается по уровням сна, и чем глубже уровень, тем сильнее замедляется время и изменяется окружение. Я переношу это в CI так:

  • Реальность (корневой пайплайн) — подготавливает и управляет спуском. Запускает дочерние пайплайны по очереди, погружая команду все глубже.

  • Город (билды), Отель (тесты) и Крепость (стейдж) — каждый следующий или параллельный уровень (пайплайн) выполняет определенную задачу.

  • Лимб — место, где связь с реальностью теряется и время практически останавливается. Идеальная аналогия с security-gate или manual-approve перед деплоем в продакшен-среду.

  • Выброс (деплой) — на самом дне срабатывает «толчок»: он либо выбрасывает команду обратно в реальность с успешной доставкой, либо схлопывает сон откатом.

  • Тотем — набор сервисной информации и уведомлений для связи с реальностью. Сопровождает выполнение задач в пайплайне на протяжении всего цикла выполнения.

В первую очередь необходимо показать структуру проекта:

├── .gitlab-ci.yml            # Реальность: входная точка пайплайна
├── pipelines/
│   ├── city.yml              # Город: сборка образов
│   ├── hotel.yml             # Отель: тесты
│   ├── fortress.yml          # Крепость: шлюз с проверкой качества и деплой на стейдж
│   ├── limbo.yml             # Крепость: шлюз с проверкой ИБ и деплой в продакшен 
│   └── deploy.yml            # Выброс: успешный выброс в реальность
├── common/
│   └── stages.yml            # Общий stages для включения во все пайплайны
└── templates/
    ├── dream.yml             # .dream и .fall-into-dream шаблоны
    └── kick.yml              # .kick шаблон с общим stage kick
    └── parallels.yml         # spec заголовок и .parallels-spec шаблон
На скриншоте отражена корневая часть пайплайна и триггеры на дочерние пайплайны. Dream — стейдж с подготовительными задачами falling — стейдж с триггерами дочерних пайплайнов. Downstream — сервисный неуправляемый стейдж, отображающий ссылки на дочерние пайплайны
На скриншоте отражена корневая часть пайплайна и триггеры на дочерние пайплайны. Dream — стейдж с подготовительными задачами falling — стейдж с триггерами дочерних пайплайнов. Downstream — сервисный неуправляемый стейдж, отображающий ссылки на дочерние пайплайны

Реальность — это корневой .gitlab-ci.yml. Этап перед погружением в вызовы дочерних пайплайнов подходит для выполнения сервисных задач или, например, прогона линтеров и анализа кода. Затем выполняется оркеструющий этап с триггерами (погружение). Это место — простор для творчества.

Вот пара примеров организации триггеров:

  • Триггеры-окружения. В этом случае каждый триггер вызывает шаблонный пайплайн (например, классический пайплайн — билд, пуш, деплой) с переопределением переменных под конкретное окружение. Подсказка: переменные можно использовать как переключатели в rules. То есть включать-отключать часть задач в пайплайне.

  • Триггеры-этапы. В этом случае каждый триггер вызывает отдельные пайплайны, каждый под свою задачу.

В моем опыте триггеры выполняли роль «клиентов», то есть для каждого клиента мог отличаться набор поставляемых сервисов, переменные в окружении развернутых контейнеров и сценарии запускаемых тестов. А также флоу ветвления был выстроен так, что для определенных префиксов веток-тегов триггерами запускались разные пайплайны. Например, для автотестов по расписанию запускался один пайплайн, для разработчиков — другой. А для сборки и отгрузки артефактов клиенту запускался тот же, что и для разработки, но уже без деплоя и с триггером в конце, который в свою очередь запускал еще один CCD-пайплайн (Client Continuous Delivery).

В итоге получались цепочки:

  • Автотесты: корневой пайплайн > пайплайн автотестов.

  • Разработка: корневой пайплайн > пайплайн сборки и деплоя.

  • Релиз: корневой пайплайн > пайплайн сборки > пайплайн CCD.

В нашем примере триггерами в первом уровне вложенности запускаются четыре дочерних пайплайна (Город, Отель, Крепость, Лимб) и сцепляются в строгую последовательность через needs. Каждый уровень — отдельный файл pipelines/[city, hotel, fortress, limbo].yml, вся логика спуска вынесена в один шаблон, переиспользуемый код вызывается из других источников, поэтому файлы уровней получаются почти пустыми. Остается только маппинг и переопределение или дополнение шаблонов.

Важное уточнение: уровни не вложены друг в друга буквально, а оркеструются из Реальности как последовательная цепочка. В первую очередь это обусловлено тем, что в CE версии Gitlab ограничена глубина вызова дочерних пайплайнов до трех уровней вложенности. Во вторую очередь я решил вас пожалеть. Потому что, конечно, визуально проще и приятнее воспринимать скриншоты. Но если бы я дословно следовал арифметике фильма, предавая порядок спуска и коэффициент TIME_DILATION_FACTOR (1x > 20x > 400x > 8000x > 160000x), боюсь, чтобы разглядеть это, вам понадобилась бы даже не батарея мониторов, а целый медиафасад. Настоящая вложенность остается на дне — там, где из Крепости и Лимба запускается kick-пайплайн pipelines/deploy.yml.

В качестве Тотема кажется уместным использовать директивы before_script и after_script как сущности для связи с реальностью. Диагностика окружения – before_script. Уведомление об успехе или падении – after_script. Очень часто добавление сервисной информации в эти директивы экономило мне огромное количество времени. К тому же с их помощью можно определить общую логику для задач и переиспользовать, а scriptпереопределять для конкретных задач. Не пренебрегайте ими.

Выброс в реальность — это непосредственно деплой в целевое окружение и откат в случае неудачи.

Ядро: один шаблон на все уровни

Яркий пример переиспользования — шаблон .dream. Здесь живет почти вся общая логика. Шаблон описывает поведение любой задачи внутри сна (тотем), а .fall-into-dream — погружение на следующий уровень.

.dream:
  stage: dream
  # Диагностическая информация об окружении, которая всегда выполняется независимо от статуса выполнения script
  before_script:
    - |
      echo "──────── ТОТЕМ ────────"
      echo "Уровень сна : ${DREAM_LEVEL}"                # уровень сна
      echo "Корневой пайплайн : ${TOTEM}"                # неизменен на всех уровнях
      echo "Текущий пайплайн : ${CI_PIPELINE_ID}"        # свой на каждом уровне
      echo "Искажение времени : ${TIME_DILATION_FACTOR}" # искажение времени
      echo "Задача : ${CI_JOB_NAME}"
  # Можно вебхуком отправлять уведомление в телеграм (можно и при успехе, и при неудаче). Тоже Тотем.
  after_script:
    - |
      if [[ "${CI_JOB_STATUS}" == "failed" ]]; then
        echo "Искажение времени: ${TIME_DILATION_FACTOR}"
      fi


.fall-into-dream:
  stage: falling
  trigger:
    include: pipelines/${DREAM_LEVEL}.yml # Управляем вызовом следующего сна через переменную в текущем
    strategy: depend                      # Родитель ждёт дочерний пайплайн и наследует его статус
    forward:
      yaml_variables: true                # Важно пробрасывать объявленные переменные в джобах
      pipeline_variables: true            # И важно пробрасывать переменные, TOTEM, пришедший сверху trigger-переменной, едет дальше

Вынося тотем в before_script, мы можем всегда получить информацию об окружении и спокойно переопределять директиву script при переиспользовании шаблона. В текущем примере любая задача или шаблон — это наследование через extends: .dream и определение поведения через script, variables и другие директивы.

Одна переменная — и маршрут, и подпись. DREAM_LEVEL одновременно задает имя файла для триггера (include: pipelines/${DREAM_LEVEL}.yml) и читается в тотеме как название уровня. Меньше переменных — меньше сложность, меньше ошибок, проще восприятие. Это важная конструкция, при грамотном использовании позволяющая добавлять новые downstream условно на лету.

Почему форвард включен полностью? Потому что yaml_variables: true уносит вниз переменные, объявленные в триггере (DREAM_LEVEL, TIME_DILATION_FACTOR), а pipeline_variables: true — тотем, пришедший сверху. Поскольку каждый уровень в этой схеме — прямой потомок Реальности, проброс одношаговый, и оба канала работают без подводных камней с перезаписью. Эта конструкция позволяет определить общие переменные для всех дочерних пайплайнов и ситуативно их переопределять в конкретных задачах, если это требуется.

Реальность: управление спуском

Корневой файл одновременно делает свою работу (статический анализ) и выступает архитектором сна — определяет уровни и порядок погружения.

Получаем такое содержимое .gitlab-ci.yml:

workflow:
  rules:
    - if: $CI_COMMIT_REF_SLUG
      when: always
    - when: never

include:
  - common/stages.yml
  - templates/dream.yml
  - templates/parallels.yml

variables:
  TOTEM: ${CI_PIPELINE_ID}.  # тотем рождается в реальности 
  TIME_DILATION_FACTOR: "1x" # Время течет с нормальной скоростью 1:1"


lint:
  extends:
    - .dream
    - .parallel-spec
  script:
    - echo "npm run lint $SERVICE"

sonarqube:
  extends: 
    - .dream
    - .parallel-spec
  script:
    - echo "sonnar-scanner $SERVICE"


# Уровни сна
fall-into-city:
  extends: .fall-into-dream
  variables:
    DREAM_LEVEL: city
    TIME_DILATION_FACTOR: "20x"


fall-into-hotel:
  extends: .fall-into-dream
  variables:
    DREAM_LEVEL: hotel
    TIME_DILATION_FACTOR: "400x"
  needs: [ fall-into-city ]


fall-into-fortress:
  extends: .fall-into-dream
  variables:
    DREAM_LEVEL: fortress
    TIME_DILATION_FACTOR: "8000x"
  needs: [ fall-into-hotel ]


fall-into-limbo:
  extends: .fall-into-dream
  variables:
    DREAM_LEVEL: limbo
    TIME_DILATION_FACTOR: "160000x"
  needs: [ fall-into-fortress ]

Спуск строго последователен благодаря определению зависимостей needs в .gitlab-ci.yml: Город > Отель > Крепость > Лимб.

Это повторяет логику фильма, где на следующий уровень спускаются только после того, как закрепились на предыдущем. Если вам нужно параллельное выполнение — достаточно убрать needs.

Директива workflow.rules — простой предохранитель: родительский пайплайн стартует только при наличии CI_COMMIT_REF_SLUG (то есть для веток и тегов) и не запускается в остальных случаях. Правила workflow не наследуются дочерними пайплайнами.

Тотем: как не потерять реальность

В фильме тотем — предмет, по которому понимаешь, спишь ты или нет; его свойство — оставаться неизменным на всех уровнях сна. В нашем CI роль тотема играет ID корневого пайплайна. Он объявлен глобально в Реальности:

variables:
  TOTEM: ${CI_PIPELINE_ID} # тотем рождается в реальности

Дальше тотем форвардится вниз вместе с остальными переменными. Так как каждый уровень — прямой потомок Реальности, тотем доезжает за один шаг и остается неизменным, тогда как собственный CI_PIPELINE_ID у каждого уровня свой. В логах любого уровня видно одно и то же значение «Корневой пайплайн» — по нему легко собрать весь стек снов одного запуска.

Принты из before_script демонстрирующие наследование и изменение переменных на разных уровнях вложенности
Принты из before_script демонстрирующие наследование и изменение переменных на разных уровнях вложенности

Уровни погружения

Благодаря шаблону описания задач в файлах уровней почти пусты. В конкретных зачах подключаются только общие extends и задается логика через script. Это значительно упрощает чтение и восприятие кода. Значения глубины и подпись задания получают из проброшенных DREAM_LEVEL и TIME_DILATION_FACTOR.

Уровень

Файл

DREAM_LEVEL

Время

Что делает

Реальность

.gitlab-ci.yml

1x

lint, sonarqube + оркестрация спуска

Город

city.yml

city

20x

сборка образов, matrix frontend/backend

Отель

hotel.yml

hotel

400x

smoke- и auto-тесты, matrix

Крепость

fortress.yml

fortress

8000x

quality-gate + kick:stage (deploy >rollback)

Лимб

limbo.yml

libmo

160000x

security-gate + kick:prod (deploy >rollback)

Город и Отель используют parallel:matrix, чтобы развернуть работу сразу по нескольким сервисам. При управлении CI вокруг набора сервисов моей реальной болью была невозможность управлять набором сервисов из одного источника. В процессе написания статьи я заглянул в Gitlab-тикет на эту тему. Тикет мне был знаком, но я давно его не открывал и сейчас увидел решение. В последних комментариях к тикету есть пример использования директив spec.inputs в parallels для решения подобного рода задачи.

В итоге в templates добавился еще один файл parallel.yml, в котором описан spec в заголовке и шаблон ниже. У темплейта есть две важных особенности:

  • Директива работает только в заголовке, отделяемым тремя дефисами ---.

  • Шаблон должен находиться в том же файле, в противном случае inputs не отрендерится и вывод в логе задачи будет выглядеть вот так [$[[ inputs.services ]]].

Содержимое templates/parallels.yml:

spec:
  inputs:
    services:
      default: ["backend", "frontend"]
      type: array

---

.parallel-spec:
  parallel:
    matrix:
      - SERVICE: $[[ inputs.services ]]

Использование такого шаблона позволяет централизованно управлять набором сервисов для всех уровней вложенности, а также остается возможность переопределить спек в конкретном шаблоне задачи. Для демонстрации включил этот шаблон в корневом пайплайне и в пайплайнах Города и Отеля.

Конструкция везде выглядит плюс-минус одинаково, экстенды, переопределение и скрипт:

build:
  extends:
    - .dream
    - .parallel-spec
  variables:
    IMAGE_TAG: ${CI_REGISTRY}/${SERVICE}:${CI_COMMIT_REF_SLUG}
  script:
    - echo "docker build -t ${IMAGE_TAG}"
Результат параллельного запуска джоб с применением parallels.matrix и набора сервисов из спека
Результат параллельного запуска джоб с применением parallels.matrix и набора сервисов из спека

Лимб: время почти остановилось

С развитием информационной безопасности в пайплайнах я начал встречать определенные паттерны-«шлюзы». Перед деплоем на продакшен ставится одна или несколько задач, требующих ручного нажатия запуска конкретного человека-шлюза. Есть несколько способов реализации подобной логики, чтобы только определенные пользователи могли прожимать эту кнопку (например, через защищенные окружения), но это тоже тянет на отдельную статью. Поэтому в моем примере просто when: manual.

Наглядный пример when: manual задачи security‑approve в пайплайне limbo. Ну разве это не чистилище в его чистом виде? ?
Наглядный пример when: manual задачи security‑approve в пайплайне limbo. Ну разве это не чистилище в его чистом виде? ?

The Kick: выброс в реальность

Самая красивая часть метафоры. В фильме толчок синхронно выбрасывает спящих наверх. На дне нашего сна, в Крепости и Лимбе (если из него удается выбраться), срабатывает выброс — deploy. В концепции фильма итогом всех усилий является прижившаяся идея или провал миссии. В случае с CI выброс — это еще один триггер с выбросом итогов в конечное окружение, все пайплайны выполнены, прижившаяся идея — это итог выполнения всех пайплайнов и успешный деплой, а провал — это ошибка деплоя и его откат.

include:
  - common/stages.yml
  - templates/dream.yml
  - templates/kick.yml

quality-gate:
  extends: .dream
  script:
    - echo "check quality"

deploy:stage:
  extends: .kick
  trigger:
    include: pipelines/deploy.yml
    strategy: depend
  forward:
    yaml_variables: true
    pipeline_variables: true

Директива strategy со значением mirror на каждом триггере поднимает статус выбранной ветки наверх, к Крепости или Лимбу и дальше к Реальности, так что корневой пайплайн честно отражает итог всего спуска. Получаем зависимость: если глубоко вложенный пайплайн деплоя упал, то и корневой завершит выполнение с ошибкой.

Скрины для понимания общего вида самых длинных пайплайнов из примера. На практике подобная конструкция могла растягиваться на два экрана, но зато весь процесс был доступен для просмотра в одном месте
Скрины для понимания общего вида самых длинных пайплайнов из примера. На практике подобная конструкция могла растягиваться на два экрана, но зато весь процесс был доступен для просмотра в одном месте

Финал

Надеюсь, метафора Inception оказалась весьма показательной рабочей моделью. Стоит увидеть в дочерних пайплайнах уровни, и конструкция перестает пугать: понятно, кто кого запускает, куда уходит управление и как возвращается статус.

Если убрать киношные отсылки, остается несколько инженерных принципов:

  • Корневой пайплайн оркеструет дочерние пайплайны и через needs выстраивает строгую последовательность погружения. Также можно управлять запуском уровней параллельно или через rules с переменными-переключателями.

  • Общие шаблоны .dream и .fall-into-dream обслуживают все таски, то есть глобальные директивы и логика собраны в одном месте. Удобно, чтобы не плодить около 15 строк одного и того же кода в разных местах, при этом увеличивая сложность сопровождения.

  • Директива strategy: mirror склеивает статус выполнения корневого и дочерних пайплайнов в общий. Итог самого глубокого уровня поднимается до корня.

  • Глобальный CI_PIPELINE_ID, проброшенный вниз, иллюстрирует наследование переменной на всех уровнях и дополняется другой информацией об окружении конкретного пайплайна. Незаменимый способ отражения сервисной информации при отладке.

  • Директива forward позволяет управлять наследованием переменных окружения или их изоляцией. Добавляет гибкости при связывании пайплайнов.

  • Лимб — места боли и страданий с долгим ожиданием выполнения или ручного запуска. Как правило, это ручные аппрувы или тяжелые тесты. Такие места необходимо избегать или минимизировать.

Подход одинаково хорошо ложится и на монорепозиторий с несколькими окружениями, и на микросервисную доставку, где каждый уровень — отдельный этап. Главное не плодить вложенность ради вложенности: каждый уровень сна должен решать реальную конкретную задачу, иначе рискуете застрять в собственном лимбе из пайплайнов.

Представленный CI успешно запускался на следующих версиях Gitlab:

  • локально — Community Edition v18.10.8;

  • публично — Enterprise Edition 19.2.0-pre.

Полный рабочий пример выложен в репозиториях на gitverse.ru и gitlab.com.

Счастливых вам путешествий по лабиринтам .dream!

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