Привет, меня зовут Михаил Панов, я DevOps‑инженер МТС Digital. Хочу поделиться с вами опытом построения «модульных» pipelines на основе gitlab-ci. В этой статье я расскажу, что такое модульный CI/CD, из чего он состоит, для чего нужен и как поможет командам, поддерживающим большой список нетиповых репозиториев.
Часть 1: Проблема класcического gitlab-ci
Предположим, что вы участник команды, которая сопровождает крупный стрим, в котором есть несколько команд со своими сервисами:
Frontend;
Backend;
API.
Одна команда использует Golang, другая часть сервисов – на основе Gradle, а еще Frontend есть. Нужно не забывать и о том, что существуют нетиповые репозитории, например, для AWX или Terraform.
В таких условиях возникает вопрос: «А как лучше все это автоматизировать?». Варианты такие:
можно создать типовые для каждого языка gitlab-ci.yml и складывать их в репозитории. Но тут мы столкнемся с трудностями версионирования и обновления;
можно создать один инфраструктурный репозиторий и переложить все туда, затем с помощью include подключать в нужный репозиторий. Но и тут возникнут трудности. Файлы будут предназначены для конкретной задачи (test, build, deploy, etc).
Хотелось бы получить что-то настолько гибкое и быстрое, что-то, что даст возможность уменьшить время внедрения новых проектов в наши pipelines.
Посмотрим, что можно сделать и перейдем к примерам.
Часть 2: Типовые gitlab-ci – что можно сделать лучше?
Типовой gitlab-ci.yml, как правило, располагается в самом репозитории и содержит несколько stage и job. Например:
#Пример 1
stages:
- test
- build
- deploy
test:job:
stage: test
image:
name: $IMAGE
entrypoint: []
script:
- echo "test"
rules:
if: $CI_COMMIT_BRANCH == "master"
varuables:
ENABLE: "true"
tags:
- prod-runner
build:job:
stage: build
image:
name: $IMAGE
entrypoint: []
dependencies: []
needs:
- test-job
script:
- echo "build"
rules:
if: $CI_COMMIT_BRANCH == "master"
varuables:
ENABLE: "true"
tags:
- prod-runner
deploy:job:
stage: deploy
image:
name: $IMAGE
entrypoint: []
dependencies:
- build-job
needs:
- test-job
- build-job
script:
- echo "deploy"
rules:
if: $CI_COMMIT_BRANCH == "master"
varuables:
ENABLE: "true"
tags:
- prod-runner
В свою очередь, каждая job несет в себе информацию о том, что и как нужно сделать с кодом. Это рабочий вариант, но с каждой новой строчкой кода будет усложняться «читабельность» файла.
Пункт 2.1: Теория модульности
Чтобы достичь гибкости и читабельности кода, разобьем Пример 1 на логические части.
Gitlab предоставляет возможности для разделения кода с помощью:
include – добавление одного файла в другой;
extends – объединение схожих блоков кода между собой или расширение одного блока кода за счет другого;
anchors – якоря, которые позволяют переиспользовать блоки кода в разных сценариях;
reference tags – ссылки, которые дают возможность сослаться на блок кода, а также использовать и переиспользовать этот код.
Эти инструменты позволят собирать пайплайны (pipelines) как «конструктор».
Пункт 2.2: Структура и теория
Взглянув еще раз на Пример 1, выделим части, которые занимают много места и могут быть переиспользованы. Например:
блок правил (rules);
блок переменных (variables);
блок скриптов(before_script, script, after_script)
Вынесем эти блоки в отдельные файлы и определим структуру проекта:
templates-repo/
|--variables
| |--test-vars
| | └──test-vars.gitlab-ci.yml
| |--build-vars
| | └──build-vars.gitlab-ci.yml
| └──deploy-vars
| └──deploy-vars.gitlab-ci.yml
|--rules
| |--test-rules
| | └──test-rules.gitlab-ci.yml
| |--build-rules
| | └──build-rules.gitlab-ci.yml
| └──deploy-rules
| └──deploy-rules.gitlab-ci.yml
|--scripts
| |--test-scripts
| | └──test-scripts.gitlab-ci.yml
| |--build-scripts
| | └──build-scripts.gitlab-ci.yml
| └──deploy-scripts
| └──deploy-scripts.gitlab-ci.yml
|--templates
| |--build-bin
| | └──build-bin-template.gitlab-ci.yml
| |--build-image
| | └──build-image-template.gitlab-ci.yml
| └──deploy
| └──deploy-template.gitlab-ci.yml
|--entrypoints
| |--java
| | |--mvn.gitlab-ci.yml
| | └──gradle.gitlab-ci.yml
| |--golang
| | └──golang.gitlab-ci.yml
| └──deploy
| └──deploy-template.gitlab-ci.yml
Структура проекта определена, теперь нам нужно собрать первую job. Для примера соберем job kaniko, остальные jobs будут собираться по такому же принципу.
Пункт 2.3: Примеры
Чтобы получить полноценную job, необходимо объединить вынесенные элементы между собой. Ниже примеры объединяемых шаблонов:
build-rules.gitlab-ci.yml
#############
## Changes ##
#############
.changes-exclude-files-devops: &changes-exclude-files-devops
- ".gitlab-ci.yml"
- "Dockerfile"
###########
## Rules ##
###########
.if-enable-build-kaniko-env: &if-enable-build-kaniko-env
if: $ENABLE_KANIKO_BUILD == "false"
.if-enable-build-kaniko-tag-env: &if-enable-build-kaniko-tag-env
if: $ENABLE_KANIKO_BUILD_TAG == "false"
.if-ci-branch-and-env-exist: &if-ci-branch-and-env-exist
if: $CI_COMMIT_BRANCH =~ /master|feature\/.*/ && $ENABLE_KANIKO_BUILD == "true"
.if-ci-tag-and-env-exist: &if-ci-tag-and-env-exist
if: $CI_COMMIT_TAG =~ /^(v[0-9]\d*)\.([0-9]\d*)\.([0-9]\d*)(-([a-zA-Z-]*))?/ && $ENABLE_KANIKO_BUILD_TAG == "true"
.if-piplince-surce-push-or-merge-request: &if-piplince-surce-push-or-merge-request
if: $CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "merge_request_event"
.if-ci-commit-tag: &if-ci-commit-tag
if: $CI_COMMIT_TAG
#################
## Build rules ##
#################
.rules:build-image:job:
rules:
- <<: *if-enable-build-kaniko-env
when: never
- <<: *if-piplince-surce-push-or-merge-request
changes: *changes-exclude-files-devops
when: never
- <<: *if-ci-commit-tag
when: never
- <<: *if-ci-branch-and-env-exist
- when: never
build-vars.gitlab-ci.yml
.variables:k8s:deploy:kaniko:
variables:
ENVIRONMENT: prod
IMAGE: kaniko:latest
build-scripts.gitlab-ci.yml
.script:infra-build-script:
script:
- 'echo "Building docker image: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA"'
- /kaniko/executor
--context "$CI_PROJECT_DIR"
--dockerfile "$CI_PROJECT_DIR/.docker/Dockerfile"
--destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA"
--cache=true --cache-ttl 336h
--label ru.mts.git.project="$CI_PROJECT_URL"
--label ru.mts.git.ref="$CI_COMMIT_REF_NAME"
--label ru.mts.git.revision="$CI_COMMIT_SHORT_SHA"
--skip-tls-verify
--use-new-run
--registry-mirror=your.registry.com
--build-arg http_proxy=$http_proxy
--build-arg https_proxy=$https_proxy
--build-arg no_proxy=$no_proxy
Файл /templates/build-image/build-image-template.gitlab-ci.yml будет выглядеть следующим образом:
include:
- local: '/scripts/build-scripts/build-scripts.gitlab-ci.yml'
- local: '/rules/build-rules/build-rules.gitlab-ci.yml'
- local: '/variables/build-vars/build-vars.gitlab-ci.yml'
build:kaniko:job:
stage: build-image
image:
name: $IMAGE
entrypoint: []
needs:
- job: test-job
dependencies: []
extends:
- .script:infra-build-script
- .rules:build-image:job
- .variables:k8s:deploy:kaniko
tags:
- prod-runner
С помощью extends мы объединили в шаблон scripts, rules и variables. Это дает возможность собрать полноценный job.
Как видно из примера выше (Пункт 2.2 - структура), появилась еще одна директория entrypoints/, которая объединяет в себе шаблоны (templates) для формирования подключаемого файла для группы проектов.
Он будет выглядеть следующим образом:
include:
- local: '/templates/build-bin/build-bin-template.gitlab-ci.yml'
- local: '/templates/build-image/build-image-template.gitlab-ci.yml'
- local: '/templates/deploy/deploy-template.gitlab-ci.yml'
stages:
- test
- build
- deploy
# Global variables
variables:
ENABLE_LINT: "true"
ENABLE_BUILD: "true"
ENABLE_DEPLOY_PROD: "true"
ENABLE_DEPLOY_TEST: "true"
ENABLE_DEPLOY_DEV: "true"
Тут мы перечисляем stages, includes, variables. То есть те элементы которые могут находиться только в «верхнеуровневом» или подключаемом файле.
Пункт 2.4: Нюансы
Пройдемся по нюансам этой реализации. Так как Gitlab у всех может быть разной степени владения — «коммунальный» или полностью подконтрольный и закрытый, реализация шаблонов будет немного меняться в части include. Документация говорит, что мы можем воспользоваться четырьмя возможными «инпутами»:
Possible inputs: The include subkeys.
include:local – используется для подключения файлов, находящихся в репозитории локально;
include:project – вариант, подходящий в большинстве случаев, так как позволяет тонко настраивать подключение файла в репозиторий назначения;
include:remote – используется для подключения шаблонов из внешних источников, потребуется авторизация (подробнее в документации);
include:template – отметаем, так как он используется для подключения встроенных шаблонов самого gitlab.
Также хотелось бы обратить внимание на несколько моментов:
Если репозиторий у вас открыт и все хранится в main (master) ветке — можно воспользоваться include:remote, так как это будет ссылка на конкретный файл и она займет меньше места (хотя, казалось бы, куда еще меньше?). Если репозиторий закрытый(internal) — используйте include:project.
Include:project можно использовать двумя разными способами в зависимости от того, как вы управляете инфраструктурными файлами в своих репозиториях.
Вариант 1. Если изменения производятся с помощью ansible, terraform, etc — файл будет следующего вида, с явным указанием полей:
include:
- project: 'my-group/my-project'
ref: v1.0.0
file: '/templates/.gitlab-ci-template.yml'
variables:
ENABLE_LINT: "true"
ENABLE_BUILD: "true"
ENABLE_DEPLOY_PROD: "false"
ENABLE_DEPLOY_TEST: "true"
ENABLE_DEPLOY_DEV: "true"
Вариант 2. Если управление производится на основе переменных окружения:
include:
- project: '$CI_PROJECT_PATH'
ref: '$CI_PROJECT_REF'
file: '$CI_PROJECT_FILE'
variables:
ENABLE_LINT: "true"
ENABLE_BUILD: "true"
ENABLE_DEPLOY_PROD: "false"
ENABLE_DEPLOY_TEST: "true"
ENABLE_DEPLOY_DEV: "true"
У вас может быть наоборот.
Заключение
Такой способ объединения шаблонов позволяет гибко настроить CI/CD без заявки на ультиматизм. Вы можете комбинировать шаблоны(templates) любым удобным для вас способом, один из примеров – это репозиторий шаблонов самого gitlab. Мне же хотелось в этой статье раскрыть вопрос модульности и гибкости, а также результаты, которые можно получить, используя такой подход. Буду рад Вашим вопросам и комментариям.