Копипаста в GitLab CI — это проблема, которую знают все DevOps-инженеры. Вы меняете один параметр в docker login, забываете обновить его в трёх других местах и вот уже час ищете, почему упал пайплайн. А если в компании 10 проектов с одинаковым CI? Тогда каждое изменение превращается в кошмар.

DevOps-инженер Ефрем Менгеша на конференции TechLeadConf 2025 рассказал, как избежать этих ошибок и сократить код CI на 56 %. Мы взяли его доклад и развернули в полный гайд.

В статье разбираем:

  • как избавиться от дублирования через шаблоны и применить принцип DRY (Don't Repeat Yourself);

  • метод масштабирования с помощью матриц (один код вместо трёх);

  • как создать общий CI для группы проектов и управлять им из одного места;

  • реальные примеры: от типового пайплайна к оптимизированному.

В итоге вы получите готовый набор инструментов, чтобы сделать свои пайплайны структурированными, надёжными и удобными в поддержке.

Содержание:

CI и его UI

Типовой CI

Типовой CI (Continuous Integration) — это автоматизированный процесс (пайплан), включающий стадии сборки (build), тестирования (test) и развёртывания (deploy) кода. В GitLab CI этот процесс описывается в файле gitlab-ci.yml, который находится в корне репозитория, и запускается при каждом изменении кода.

Предположим, что у нас есть абстрактное приложение на Python. Посмотрим на его CI:

Здесь мы видим те самые три стадии и переменные, которые рассмотрим позже. 

Теперь посмотрим на нашу первую задачу (Job):

Мы видим название задачи (Build) и стадию (stage: build), к которой она относится. B скрипте (script) описана сама задача: нужно собрать метаинформацию и вывести её через echo, а затем с помощью Docker собрать контейнер с приложением и запушить в container registry. В конце видим секцию tags, которая указывает на runner, на котором следует выполнить задачу. 

За выполнение задачи отвечает GitLab Runner — демон, который можно запустить на своём сервере, и он будет выполнять задачи, описанные в пайплайнах. Когда вы создаёте новый runner, вы регистрируете его в GitLab и при этом назначаете ему определённые теги. Посредством этих тегов вы и выбираете, какая задача и на каком runner'е будет запущена.

Здесь мы предполагаем, что у нас есть runner с тегом dev, на котором и должна выполняться задача. 

Следующая задача test с тем же синтаксисом:

Здесь снова собираем метаинформацию, выводим её через echo, а потом тестируем приложение.

Дальше — стадия deploy:

Задача называется Deploy dev и относится к стадии deploy. Также выполняется определённый скрипт: деплоим и запускаем приложение с помощью docker-compose

Из интересного — у нас появляется environment. Это инструмент, который позволяет удобно отслеживать деплои в разные окружения, настраивать review окружения и их автоматическое удаление, а также много чего ещё. Он добавляет много гибкости, но в рамках этого материала мы не будем обсуждать его подробнее.

Также из нового у нас появилась настройка when: manual, которая заставляет задачи запускаться не автоматически, а по нажатию кнопки.

Есть аналогичная задача Deploy stage:

Она практически идентична предыдущему Deploy dev — отличается только именем окружения, что я выделил. 

Также есть аналогичная задача Deploy prod:

Весь этот процесс из трёх стадий и есть наш типовой CI.

UI

А так процессы CI будут выглядеть в UI: 

Мы видим три стадии: build, test, deploy. Также видим задачи, которые относятся к каждой из них.

На иллюстрации выше можно заметить, что задачи в первых двух стадиях отмечены как выполненные, а задачи по деплою не запустились. Напомню, мы добавили настройку when: manual. Если бы мы этого не сделали, сработало бы поведение пайплайна по умолчанию, и все три задачи запустились бы параллельно и самостоятельно.

Вообще стоит вспомнить основные стандартные правила выполнения пайплайна:

  • Стадии выполняются строго друг за другом. 

  • Задача из следующей стадии выполнится только после того, как успешно завершатся все задачи из предыдущей. 

  • Все задачи в рамках одной стадии выполняются параллельно.

Однако поведение пайплайна можно менять как угодно, так как это очень гибкая система. Один из мощнейших инструментов для этого — need. Подробно эту тему разбирать не будем, но интересно будет добавить, что относительно недавно в GitLab появилась возможность для мануальных задач добавлять баннер. Например, для кнопки deploy to prod можно добавить баннер, чтобы при нажатии на неё появлялось предупреждение или инструкция.

Итак, мы посмотрели, как CI выглядит в коде и в UI. Теперь обсудим его оптимизацию.

Оптимизация CI: применение шаблонов для упрощения

Наш CI выглядит красиво и структурированно, но если присмотреться, можно заметить, что код страдает от избыточности:

Во-первых, мы по несколько раз собираем метаинформацию и выводим её через echo. Во-вторых, docker login буквально повторяется в каждой задаче. Повторяющийся код — это плохо, потому что мы нарушаем принцип DRY. Это усложняет поддержку, увеличивает риск ошибок, и если мы хотим что-то поменять, то нам приходится дублировать наши усилия. Это не очень хорошо. 

Представим пример, где нам нужно поменять одну из повторяющихся частей. Как следствие, мы должны поменять её в каждой задаче. Например, мы меняем некую переменную в docker login. Может возникнуть ситуация, где мы либо забываем внести изменения, либо опечатываемся. Это приводит к тому, что пайплайн падает:

Нужно расследовать, что пошло не так: либо кто-то закоммитил в приложение что-то не то, либо на runner'е что-то случилось, либо мы что-то не досмотрели в CI. Это увеличивает время, затрачиваемое на код-ревью. В общем, возникает много неприятностей, которых можно избежать. 

Чтобы избавиться от повторяющихся кусков кода, мы можем применить шаблоны (templates). То есть положить повторяющуюся часть кода в одно место, а дальше там, где он повторяется, ссылаться на него. Таким образом мы избегаем дублирования.

Сокращение повторений с помощью extends + reference и default + before_script

Для решения этой задачи GitLab предлагает два подхода, которые хорошо описаны в документации. Первый — это YAML-якоря, или YAML anchors. Его особенности:

  • Работает на уровне YAML-синтаксиса, то есть не имеет отношения к GitLab, а относится к любому YAML-файлу.

  • Это не очень удобно читается, когда конфигурация слишком большая.

  • YAML-якоря плохо работают с keyword include в GitLab, у них плохая синергия, хотя и include нужен не везде

Но в этой статье подробно рассмотрим второй подход, которой предложил сам GitLab — ключи extends и reference

Начать стоит с того, что для использования template его нужно объявить. Для этого GitLab предлагает использовать Hidden Jobs — скрытые задачи, имена которых начинаются с точки. В нашем случае создаёи скрытую задачу.common_init_script, где описываем повторяющиеся части скрипта, которые хотим переиспользовать:

 

Так мы объявляем template, а вызвать его можно как раз таки с помощью ключа reference.

В рамках задачи build убираем повторяющиеся строчки кода и на их место ставим reference, который их и вызывает. Поскольку вставленные в template строчки кода повторяются не только в build, а ещё в четырёх задачах (одно — test, три — deploy), нам нужно вызвать этот же reference подобным образом ещё в каждой из этих задач.

Мы серьёзно сократили количество кода, но можем сделать ещё меньше повторений. Для этого вместо reference нужно воспользоваться связкой default и before_script:

 

default — это keyword GitLab, который позволяет задать настройки по умолчанию для всех задач.

Важно

  • В default можно указать не все ключи (список ограничен, но его, как правило, достаточно). В данном примере я использую ключ before_script.

  • Можно отменить наследование для конкретной задачи с помощью ключа inherit.

before_script — это по сути тот же keyword, что и script, только выполняется раньше основного скрипта. Под капотом на GitLab Runner before_script и script объединяются в один shell.

Если в случае с reference мы убрали повторяющиеся строчки кода и заменили их вызовом reference, то с помощью связки default + before_script мы полностью избавляемся от повторений.

То есть описываем повторяющуюся часть в default и before_script, а из самих задач убираем эти повторения и больше ничего не прописываем и не вызываем reference, так как настройки по умолчанию применяются ко всем задачам.

Гибкость через переменные

Мы оптимизировали CI. Но у нас есть ещё одна повторяющаяся команда — echo, которую не забрали в before_script, так как она (echo) немного отличается для каждой задачи. Например, в build пишем, что выполняем сборку, в test — что выполняем тестирование. То есть по сути повторяем название задачи или стадии на русском языке и хардкодим это. А для деплоя мы ещё и в скобках добавляем название окружения:

Но и здесь можно уменьшить повторяемость. Такие структуры можно свернуть до подобных template:

Для build и test хардкодим действие: для test — «тестирование», для build — «сборка». А для всех деплоев (dev, stage и prod) мы также выводим действие и в скобках добавляем окружение. Получается, первый шаблон можно использовать для build и test, а второй — для трёх деплоев. Но мы не можем это так оставить: «действие», «окружение» — это нужно чем-то заменить. Тут нас выручат предопределённые переменные GitLab:

Предопределённые переменные GitLab автоматически доступны в любом пайплайне. Это огромное количество переменных с метаинформацией, которые позволяют строить сложный CI, например: кто выполнил коммит, какой коммит-месседж, что является источником пайплайна — шедулер, мердж-реквест или его триггернули через API.

В данном случае мы используем две переменные:

  • CI_JOB_STAGE — хранит название стадии (build, test или deploy);

  • CI_ENVIRONMENT_NAME — хранит название окружения.

В итоге можем объявить эти два темплейта и с помощью знакомого нам reference вызывать один для build и test, а второй — для трёх деплоев. 

Однако в своём желании оптимизировать код сборки можно пойти ещё дальше:

Вместо двух темплейтов у нас теперь один. По сути, мы убрали темплейт «для build и test» и оставили только вариант «для deploy», но немного изменив его: после CI_ENVIRONMENT_NAME добавляются двоеточие и дефис. Это трюк из Bash: если переменной CI_ENVIRONMENT_NAME нет, будет использоваться значение по умолчанию, в данном случае — дефис.

Получается, что для build и test, где окружение не задано, этот echo сработает так, что в скобках будет дефис — значение по умолчанию. Что значит, что build и test не относятся ни к какому окружению, то есть универсальны для всех окружений. А для деплоев вместо дефиса будет подставляться содержимое переменной CI_ENVIRONMENT_NAME. И мы можем донести это в наш существующий before_script.

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

Use Case: сборка приложения

Представим, что мы собираем APK для Android через Gradle. Мы видим, что у нас уже объявлен темплейт .common_build и также есть две задачи по сборке: Build Dev и Build Prod

Эти задачи ссылаются на темплейт .common_build и с помощью этого скрипта собирают приложение:

Но самое интересное связано с переменными:

  • KEYSTORE_FILE. У задачи Build Dev указан свой файл (devFile), аналогично у Build Prod — prodFile.

  • BUILD_DIR. Переменная объявлена на второй строке в секции variables, в которой объявляются переменные по умолчанию, то есть она общая и для Build Dev, и для Build Prod. Таким образом, BUILD_DIR не зависит от окружения и является общим для всех.

  • GRADLEW_BUILD_CMD. Это параметр командной строки, с которым собирается наше приложение. У Build Dev он один (makeDevRelease), у Build Prod — другой (makeProdRelease).

Таким образом, идентичный скрипт для сборки мы вынесли в темплейт, а разницу — в переменные, которые мы только что посмотрели. Тем самым мы ещё больше сократили количество строк кода, повысили читаемость и добавили прозрачности.

Достаточно представить, что вместо вызова темплейта этот скрипт вызывается дважды и вместо переменных там хардкод. Как следствие, будет сложно сравнить скрипты для dev и для prod. А с помощью трюка с переменными мы сразу подсвечиваем разницу. Это полезно как для новых людей в команде, которым будет проще зареверсинжинирить происходящие во время первого знакомства с проектом, так и для постоянных мейнтейнеров, которые смогут прозрачнее и удобнее вносить изменения в CI.

В итоге с помощью темплейтов мы сократили количество повторений и строк кода в gitlab-ci.yml на 22 %. При этом поработали с нашим CI, но задачи по деплою (а их у нас три) практически не трогали. А ведь там много чего повторяется. Затронем это в следующей главе.

Использование матрицы для идентичных задач

GitLab CI предоставляет механизм matrix, с помощью которого можно превратить три идентичные задачи в одну:

С помощью parallel:matrix мы добавляем переменную ENV, в которой три элемента: dev, stage и prod. Дальше мы ссылаемся на эту ENV в самой задаче, в секциях environment и tags.

При этом в названии задачи не указываем ENV. Пока не очень понятно, но давайте посмотрим, как это будет выглядеть в UI:

Появится задача Deploy, у которой будут три дочерние задачи: Deploy dev, prod и stage — элементы массива. Так без единой строчки дублирования мы сгенерировали три независимые задачи.

Получается, мы объявили матрицу с одним параметром ENV и без дублирования кода получили три задачи. При этом особенность матриц не только в том, что мы отказываемся от дублирования кода, ещё мы легко масштабируемся.

Например, можно добавить ещё один параметр:

Здесь мы добавили параметр REGION, куда поместили сокращённые названия городов: msk, spb, nsk. А для dev просто написали local.

С добавлением нового параметра мы получили уже шесть независимых задач. И опять же, мы не продублировали ни одной строчки кода.

Можно придумать много разных user-кейсов. Например, если вы деплоитесь в облако, здесь можно указать названия регионов — зоны abc.

С помощью матриц мы ещё сильнее сократили количество строк кода и повторений — уже на 35 %, не суммируя с теми 22 %. Теперь у нас 57 %.

На этом можно и закончить с оптимизацией нашего воображаемого репозитория app. Но часто бывает так, что в GitLab больше одного репозитория — целые группы, которые тоже используют идентичный CI. Можем ли мы хранить CI для группы репозиториев в одном месте? Хороший вопрос. Постараемся найти на него ответ.

Унификация процессов: Common CI для группы проектов

Давайте превратим оптимизированный CI в Common CI.

Что такое Common CI? Это единый, переиспользуемый CI-конфиг, который подходит сразу для группы репозиториев. 

Как уже говорилось, у нас в Git обычно не один, а несколько репозиториев. Мы рассматривали воображаемый репозиторий app — но таких может быть десятки. Часто репозитории в одной группе пишутся по единым паттернам и стандартам, часто — одними и теми же командами. В них могут храниться микросервисы, входящие в состав одного большого продукта. Как следствие, все они деплоятся одинаково, например в один Kubernetes-кластер или на одни и те же серверы. То есть они могут использовать одинаковый CI.

В нашем примере, как и в большинстве реальных кейсов, используется абстракция: приложение собираем с помощью Docker, а развёртываем через docker-compose. При этом неважно, на каком языке оно написано, главное — его можно упаковать в единую схему сборки и выката. Это и есть причина, почему CI можно переиспользовать.

Предположим, у нас есть следующая группа репозиториев.

Где:

  • my_group/app — основное приложение на Python, с которым мы работали ранее;

  • my_group/api — микросервис на Python;

  • my_group/admin — админка на Python;

  • my_group/consumer — consumer RabbitMQ на Go.

Допустим, мы хотим использовать Common CI для Python-приложений. То есть превратить CI, который использовали для app, в Common CI и задействовать для всех трёх проектов.

Перед тем как это сделать, нужно проверить, нет ли в CI хардкода. И если присмотреться, то в deploy в секции script, когда мы деплоим приложение с помощью docker-compose, мы хардкодим имя приложения app:

Мы можем красиво избавиться от этого с помощью переменной CI_PROJECT_NAME — предопределённая переменная GitLab, которая хранит название репозитория:

Так мы сделали CI универсальным. Остаётся его только переложить. Для этого создаём новый репозиторий common-ci, в котором будет только один файл — ci.yml. И мы весь наш CI туда и перекладываем.

Таким образом, в каждом репозитории с Python в файле .gitlab-ci.yml указываем подобный include (смотрите по ссылке). Эта конструкция указывает, в каком проекте, ветке и файле находится CI.

Include: ссылаемся на наш Common CI

Может показаться, что если нужно поменять что-то конкретное для одного репозитория, то мы не сможем это сделать в рамках Common CI. Но это не так.

Для примера возьмём проект my_group/admin и предположим, что мы хотим переименовать приложение из admin в app-admin. При этом сам репозиторий в GitLab переименовывать не нужно — достаточно, чтобы в docker-compose создавалась соответствующая директория, а образы тегировались как my_group/app-admin, а не my_group/admin. Вот такую гипотетическую задачу для конкретного репозитория постараемся решить в рамках Common CI.

Для этого предлагаем в CI my_group/admin добавить две переменные: 

  • APP_NAME с желаемым именем app-admin;

  • CUSTOM_IMAGE_NAME с тремя переменными — две предопределённые переменные GitLab, которые отвечают за правильный адрес до нашего container registry, и уже знакомая нам APP_NAME.

А в самом Common CI мы применяем знакомый нам способ, который использовали с echo, — переменные по умолчанию:

Получается, если переменные CUSTOM_IMAGE_NAME и APP_NAME заданы, будут использоваться их значения (сценарий для admin), а если этих переменных нет, то будут использоваться значения по умолчанию (сценарий для всех остальных приложений). Так с помощью переменных можно сделать CI более универсальным.

Частичное использование Common CI

Теперь вспомним my_group/consumer, который мы не интегрировали в Common CI. До этого мы уже обсудили, что репозитории с приложениями, написанными на разных языках программирования, могут использовать один и тот же CI благодаря абстракциям, в данном случае — Docker. В связи с этим предположим, что для my_group/consumer мы хотим использовать Common CI, но нужны только стадии build и deploy, а стадию test наследовать мы не хотим, поскольку это другой язык программирования — не Python, мы хотим свои тесты.

Мы можем в CI my_group/consumer использовать такую конструкцию:

В левой части можно увидеть CI нашего consumer, а в правой — тесты из Common CI. Давайте подробнее разберёмся, что тут происходит.

Сначала с помощью уже знакомой нам схемы мы выполняем include для consumer. Так же, как мы это делали для Python-приложений. Но после этого с седьмой строки мы начинаем описывать тесты.

В GitLab при использовании конфигурации Common CI происходит слияние, а именно deep merge, нескольких .gitlab-ci.yml-файлов (в данном случае репозиториев consumer и common-ci). При этом важно учитывать, что если в приложении определена задача с тем же именем, что и в common-ci, то она полностью переопределяет задачу из common-ci.

В нашем примере в common-ci есть задача Test, где запускаются проверки образа (pull, trivy, pytest). Но в приложении тоже определена задача Test, и при слиянии она заменит общее задачу. В результате останется только кастомный скрипт приложения (echo «Мои кастомные тесты»), а все проверки из common-ci будут потеряны.

Такое переопределение — bad practice, потому что сложно следить за такой конфигурацией и предсказывать её поведение.

Чтобы избежать подобных ошибок, в GitLab есть удобный Pipeline Editor. Он визуализирует финальную версию пайплайна с учётом всех include и merge и позволяет быстро увидеть, какие задачи реально выполнятся. Пользоваться им действительно стоит — это экономит время и помогает держать процесс под контролем.

Структуризация Common CI

Рассмотрим более надёжный способ наследования Common CI для нашего consumer. Сейчас в репозитории Common CI находится один файл ci.yaml, где спрятаны все задачи. Но можно привести его к варианту с двумя директориями:

Изначально в ci.yaml хранились все задачи, а теперь для каждой из них мы выделили свой файл в отдельной директории stages. А в директории ci находится файл .gitlab-ci-python.yaml, который содержит стадии, переменные и include. Это уже локальный include. То есть мы включаем локально default, build, test и deploy.

Получается, поместили в директорию ci файл, который можно использовать как готовый CI. И теперь include Python-приложений можно поменять таким образом:

При этом для самих приложений ничего не поменяется. Просто появится дополнительный уровень вложенности.

А CI Go-приложения теперь будет выглядеть подобным образом:

Здесь мы объявляем стадии, переменные и делаем include, но уже не готового CI, а каждой стадии по отдельности. Мы включаем default, build и deploy, а test закомментировали — мы их не включаем (вообще они были закомментированы для наглядности, этот блок кода можно было вообще не добавлять). И ниже описываем свои test.

Поскольку мы не включаем тесты, а описываем их сами, никаких слияний (merge) не будет. Не будет конфликтов, никто не будет ни над кем доминировать и никого переопределять. Это более грамотный и прозрачный подход.

Нестабильность include

Ранее мы include вызывали через ветку main (ref: main). Но ссылаться на неё не очень хорошо, потому что она относительна — в неё прилетают новые коммиты, и мы не знаем её состояние, которое нестабильно. Поэтому здесь стоит разобрать ещё несколько правил работы include

Предположим, у нас есть репозиторий app с CI, который включается из ветки main:

В момент создания пайплайна с таким include версия CI, на которую он ссылается, будет «загружена» в этот пайплайн. И если вернуться к этому пайплайну через время и перезапустить любую задачу, он не пойдёт заново смотреть, что находится в include. Он будет использовать ту версию CI, которую «загрузил» в момент создания пайплайна.

Теперь предположим, что репозиторий app идеален и уже год в него не было ни одного коммита. Вдруг нам понадобилось перекатиться, и мы создаём новый пайплайн. Если мы ссылаемся на ветку main, при создании нового пайплайна он сходит в include, найдёт там CI и загрузит его. И если этот CI отличается от того, что был год назад, когда мы последний раз выкатывались, мы получим другое поведение.

Чтобы бороться с этой проблемой, GitLab предлагает следующие решения

  • ссылаться на protected tags:

  • ссылаться на коммит-SHA нашего Common CI:

Ещё можно использовать include:remote, где указывается ссылка на raw-файл, как в GitHub. В общем, практик для предосторожности очень много.

Также можно вынести проект, ветку и файл в переменные. Например, в примере выше была группа с четырьмя репозиториями, которые использовали Common CI. Мы можем создать подобные групповые переменные, назвать их как угодно и поместить в них нужные значения. И CI будет их использовать.

Когда мы хотим обновить CI (например, с тега 1.1 на 1.2), то вместо того, чтобы коммитить новый тег в четыре репозитория, можно обновить его в переменной, и тогда все следующие пайплайны будут работать на новой версии.

Но это совет из рубрики вредных, так как эта переменная нигде не версионируется (она не лежит под Git).

Недостатки Common CI

Common CI хорош тем, что можно управлять CI в одном месте, можно меньше дублировать себя, уменьшать количество код-ревью, а CI актуален для всех репозиториев. Но у него есть и минусы.

Обновление версии CI нужно контролировать. В данном случае мы храним все яйца в одной корзине, и если что-то сделать не так, например использовать групповые переменные, можно разом всё сломать. Нужно быть осторожным, выкатывать новый CI для приложений постепенно и держать многое в голове, потому что его используют разные репозитории.

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

Результат

В итоге с помощью Common CI нам удалось сократить количество строк кода больше чем вполовину:

А если убрать из оптимизированного шаблоны и матрицы, то можно было сократить количество строк на 78 %. Но в рамках одного материала невозможно рассказать всё. 

Например, в GitLab можно использовать run — экспериментальное нововведение, которое позволяет реализовывать что-то наподобие include отдельных функций. Насколько знаю, даже появилась поддержка GitLab Actions. Кроме того, теперь можно использовать include для CI-каталогов — это готовые шаблоны для ускорения работы. Есть также downstream-пайплайны: при большом желании и некоторой «изобретательности» можно генерировать пайплайны на лету, которые будут запускаться в downstream-пайплайне — и там вообще всё формируется динамически.

Тем не менее с такими возможностями нужно быть осторожным. В GitLab CI действительно много отличных инструментов. Надеюсь, мне удалось вдохновить вас открыть документацию, посмотреть, что появилось нового, что обновилось, и, возможно, сделать свои пайплайны ещё более надёжными и стабильными.

Оптимизация GitLab CI — это не только сокращение кода, но и повышение надёжности пайплайнов. Используя шаблоны, матрицы и Common CI, можно избавиться от дублирования, ускорить процессы и упростить поддержку. Главное — соблюдать баланс между универсальностью и гибкостью, чтобы не усложнить систему. Внедряйте лучшие практики постепенно, тестируйте изменения — и ваши пайплайны станут быстрее, стабильнее и удобнее в работе.

Если вы ищете альтернативу GitLab с возможностями CI/CD на уровне GitLab EE, обратите внимание на Deckhouse Code. Он полностью совместим с GitLab CE и значительно расширяет его функциональность — многие фичи для организации и масштабирования пайплайнов, о которых мы рассказали в этой статье, работают ещё удобнее. А ещё он подойдёт тем, кому важна запись в реестре российского ПО.

P. S.

Читайте также в нашем блоге: