Привет, меня зовут Панов Михаил, я 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. Мне же хотелось в этой статье раскрыть вопрос модульности и гибкости, а также результаты, которые можно получить, используя такой подход. Буду рад Вашим вопросам и комментариям.

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


  1. AkshinM
    14.07.2023 20:26

    Допустим есть asp.net приложение, где логика приложения описана в visual studio проекте, и загружена в репозиторий MyApp. Также есть база данных MyAppDb в котором много таблиц, хранимых процедур за разработку которого отвечают другие люди в лице sql разработчиков и хранится в своем репозитории.

    Приложение MyApp использует базу данных MyAppDb для своей работы.

    Кроме того есть много других приложений MyApp2(3...), баз данных типа MyAppDb2 (3...).

    Иногда хранимые процедуры в одной базе данных могут тянуть данные таблиц из других баз данных.

    Каким образом можно шаблонами организовать деплоймент приложений и баз данных? Возможны случаи:

    1) Если есть небольшие изменения, то собирается и развертывается только проект приложения. С этим все просто

    2) если есть значительные изменения которые затрагивают и приложение и базу данных с которым она работает. 2 разных репозитория должны одновременно пройти процесс build и deploy так как они зависимы. Будет плохо если приложение обновилось, а новую хранимую процедуру которая она должна использовать нет в базе данных

    3) если нет изменений в приложении, а есть только доработки в самой бд

    4) как организовать revert изменений если таски могут быть разными. Пример: разработчик А добавил фичу в приложение и соответственно чтобы она работала добавил новый column в таблицу бд. Вроде протестировали, все хорошо , это попало в прод. SQL Разработчик Б ,в ту же БД добавил новую хранимую процедуру для другого таска. Все также протестировали и это попало в прод. Потом обнаружилось в коде разработчика А что-то не то и изменения откатили назад. При откате назад вернулась и БД. А значит работа разработчика Б тоже пропала.


    1. Stok1y Автор
      14.07.2023 20:26

      Задача нетривиальная и решать ее можно разными способами, не совсем понял как вопрос относится к шаблонам gitlab-ci?

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


      1. AkshinM
        14.07.2023 20:26

        К шаблонам непосредственно она конечно не относится. Интересно, как можно было бы логику реализовать с использованием шаблонов для минимизации работы настройки в каждом проекте (asp.net и БД) и автоматизации процессов. При чем чтобы процесс работал с шабонами (особенно со вторым случаем).


    1. remzalp
      14.07.2023 20:26

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

      А миграции это код, так что им вполне место в репозитории, да и версионировать уже можно.


      1. AkshinM
        14.07.2023 20:26
        -1

        можете по подробнее по поводу миграций? где я могу посмотреть\прочитать об этом?