По репозиториям на 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 шаблон

Реальность — это корневой .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 у каждого уровня свой. В логах любого уровня видно одно и то же значение «Корневой пайплайн» — по нему легко собрать весь стек снов одного запуска.

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

Благодаря шаблону описания задач в файлах уровней почти пусты. В конкретных зачах подключаются только общие 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}"

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

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

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!