Удобный CI/CD доступен каждому

Привет, Хабр! Недавно я выступал на Moscow Python Conf, где делился нашим опытом создания и использования CI/CD пайплайнов. В данной статье я расскажу об этих пайплайнах, раскрою их особенности и покажу, как они помогают нам быстро доставлять код и поддерживать высокий показатель Time To Market. Надеюсь, что наш опыт будет полезен и вам.

У себя мы разрабатываем на Python, поэтому возможно небольшое содержание python-related инструментов. Но не спешите уходить! Идеи, лежащие в основе нашего пайплайна, подходят для любого языка программирования.

CI/CD

Для начала освежим в памяти это понятие. CI/CD — это не набор галочек в гитлабе, это набор практик, с помощью которых можно доставлять код и ценность пользователям и бизнесу в автоматическом или полуавтоматическом режимах. Таких методов существует целых 4 штуки.

  • Continuous Integration — практика разработки, при которой разработчики интегрируются общий репозиторий как можно чаще. Это значит, что ваши pull-request’ы должны жить довольно мало. Такой подход приводит к значительному уменьшению проблем с интеграцией так как, merge-конфликтов становится значительно меньше. Это наш основной подход.

  • Continuous Isolation — направлена на изоляцию вашей фичи в ветке, чтобы гарантировать, что разработка, тестирование и развертывание различных функций или компонентов могут происходить независимо и параллельно. Ключевое здесь то, что мы не знаем, как долго фича может находиться в ветке. Из-за этого pull-request’ы могут копиться, а merge-конфликты появляться, что сильно замедляет разработку.

  • Continuous Delivery — позволяет быстро и удобно выпускать изменения кода при помощи нажатия кнопки. Ручное действие тут необходимо.

  • Continuous Deployment — идет на шаг дальше, чем Continuous Delivery. Любой код, который успешно прошел все этапы пайплайна, автоматически попадает на среды. Вмешательства человека не происходит.

Trunk Based Development

Или же TBD, та модель ветвления, которую мы используем в банке. Здесь я особо не буду вдаваться в подробности того, что это такое и как устроено, ведь про нее уже написана отличная статья. Но подсвечу изменения, которые мы в него внесли, для большего удобства и эффективности.

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

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

Стадии пайплайна

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

  • static-analyze

  • build

  • test

  • scan

  • image-release

  • deploy

Static analyze

Как вы поняли, это какой-то статический анализ. На этой стадии мы используем инструменты статического анализа для поддержания кодовой базы в чистоте. Давайте рассмотрим небольшой пример джобы из данной стадии.

.ruff-static-analyze:
 script:
   - pip install ruff$([[ "$VERSION" != "" ]] && echo "=="$VERSION)
   - ruff check . $ARGS --output-format gitlab --output-file $CODEQUALITY
 artifacts:
   reports:
     codequality: $CODEQUALITY

Здесь происходит довольно много интересного, поэтому по порядку:

  • Мы ставим инструменты статического анализа прямо во время выполнения джобы. Это позволяет нам использовать самые последние версии этих инструментов, так как мы не завязываемся на версию. Хотя версию все же можно зафиксировать.

  • При необходимости, внутрь команды можно прокинуть какие-то аргументы через переменную $ARGS

  • Здесь же формируется Code Quality отчет. Это такая функция гитлаба, о которой можно подробно прочитать здесь. Не каждый инструмент умеет генерировать такие отчеты, так что перед выбором инструмента — проверяйте наличие данной возможности.

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

  • ruff — нашумевшее решение, так как заменяет собой много других утилит. Написано на языке Rust, так что работает очень быстро. Является и линтером и форматтером одновременно. Можно сказать мастхев в современной Python разработке.

  • mypy — один из самых популярных тайп-чекеров. Если вы не знаете, что это такое, то у меня есть доклад, где я подробно о них рассказываю. Если кратко, то этот инструмент помогает следить за типами в Python коде.

  • trivy — довольно известное решение в мире информационной безопасности. Позволяет обнаруживать уязвимости в ваших пакетах.

  • hadolint — инструмент позволяет линтить докер-файлы. Содержит довольно много правил и помогает использовать best-practices при написании докер-файла.

  • kube-score — позволяет линтить k8s манифесты. Мы используем кубер, поэтому хотим поддерживат наши манифесты в чистоте и порядке.

Build

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

.kaniko-build:
 script:
   - >
     COMMAND="/kaniko/executor
     --context $CI_PROJECT_DIR
     --dockerfile $CI_PROJECT_DIR/$DOCKEFILE_DESTINATION
     --destination $DOCKER_TAG_FOR_CI_AMD
     --build-arg ...
   - eval $COMMAND

Здесь происходит сборка образа с прокидыванием внутрь всех необходимых аргументов.

Сборка происходит не только под amd архитектуру, но и под arm, чтобы можно было спулить себе образ на мак, и если что подебажить. После сборки мы также делаем манифест, объединяя два образа для удобства.

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

В качестве реджистри мы используем Artifactory. Это довольно странная вещь, но в целом его можно представить как файловую систему. У нас есть папки, а конечными файлами являются теги. Поэтому реджистри выглядит следующим образом.

.
├── group/
│   ├── project/
│   │   └── ci/
│   │       ├── 045a01e8
│   │       ├── 045a01e8-amd64
│   │       ├── 045a01e8-arm64
│   │       └── ...
│   └── ...
└── ...

Как вы можете видеть, сразу после сборки образы попадают в папку ci. На этом этапе просто запомните этот факт, ниже будет объяснено, почему и зачем мы так сделали.

Test

Одна из самых интересных стадий в этом пайплайне. Мы реализовали две джобы тестирования для двух разных случаев:

  • Простая джоба тестирования, при помощи которой можно гонять юнит-тесты. Хорошо подходит для пакетов.

  • Сложная джоба тестирования, в ней можно гонять интеграционные тесты. Она же подходит для сервисов.

Начнем с простой джобы.

.test-docker-ci:
  variables:
    COMMAND: pytest . --cov=. --cov-report xml:$COVERAGE -n 4 --junitxml=$JUNIT
  script:
    - docker run $DOCKER_TAG_FOR_CI $COMMAND $ARGS || [ $? -eq 5 ]
    - CONTAINER_ID=docker ps -lq
    - docker cp $CONTAINER_ID:$JUNIT $JUNIT
    - docker cp $CONTAINER_ID:$COVERAGE $COVERAGE

После выполнения стадии build — у нас имеется готовый докер образ.

Он же и будет использоваться для запуска тестов при помощи pytest. Все тесты запускаются параллельно при помощи плагина pytest-xdist. Тут также происходит сбор coverage и junit отчетов, чтобы результат тестирования красиво отображался в гитлабе.

Не смотря на то, что эта джоба довольно простая, у нее все же есть большой минус. С ее помощью не получится протестировать приложение, которое взаимодействует с какой-либо инфраструктурой, будь то базы данных, очереди или кэши.

Поэтому есть есть более сложная джоба, в основе которой лежит docker-compose.

.test-compose-ci:
 variables:
  COMPOSE_FILE: "docker-compose.yml"
  COMMAND: pytest . --cov=. --cov-report xml:$COVERAGE -n 4 --junitxml=$JUNIT
  APP: application
 script:
  - docker compose -f $COMPOSE_FILE config |
    docker run -i $YQ "(.services[] | select(has(\"build\")) | del(.build) |.image) |= \"${DOCKER_TAG_FOR_CI}\"" > ./tmp-compose.yml
  - mv ./tmp-compose.yml $COMPOSE_FILE
  - docker compose -f $COMPOSE_FILE run $APP $COMMAND $ARGS || [ $? -eq 5 ]
  - CONTAINER_ID=`docker ps -lq`
  - docker cp $CONTAINER_ID:$OUT_DIR/$JUNIT $JUNIT
  - docker cp $CONTAINER_ID:$OUT_DIR/$COVERAGE $COVERAGE
  - docker compose -f $COMPOSE_FILE_DESTINATION down -v

Основная сложность тестирования через композ в том, что нужно как-то подставить только что собранный образ внутрь композ файла. Но с этим отлично справляется утилита yq. С ее помощью можно подставить путь до нашего нового образа в секцию image внутрь композа.

Запуск тестов будет происходить на сервисе с названием application. Этим параметром можно управлять через переменные джобы. В остальном же — тесты точно так же паралеллизуются и происходит сбор coverage и junit отчетов.

Давайте посмотрим на обрезанный пример композа, взятый из наших сервисов.

version: '3.9'


services:
  application:
    build:
      context: .
    environment:
      ...
    depends:
      - postgres-database
      - migrations


  postgres-database:
    image: postgres
    environment:
      ...


  migrations:
    build:
      context: .
    command: alembic upgrade head
    environment:
      ...
    depends:
      - postgres-database

Как можно видеть, мы используем базу данных postgres и хотим накатывать миграции в автоматическом режиме прямо перед запуском тестов. Плюс тут в том, что мы можем поднимать абсолютно любую инфраструктуру.

Просуммируем, что дает подход к тестированию через композы:

  • Можем запускать любую необходимую для сервиса инфраструктуру.

  • Хорошо подходит не только для запуска в CI, но и для локальной разработки.

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

Но после прохождения тестирования образом, есть еще один очень важный момент.

Так как мы не хотим, чтобы образы, на которых не прошли тесты, попадали на среды — мы используем механизм property, который присутствует в Artifactory. Property — это механизм, который позволяет устанавливать и считывать мета-информацию об образах. Такой механизм есть не только у Artifactory, но и Nexus3.

Поэтому после успешного прохождения тестов мы и вешаем проперти tested на наш образ. Навешивание происходит при помощи cli для artifactory под названием jf. Позже мы воспользуемся проперти, которое повесили на этом шаге.

Сама же джоба выглядит так.

.tag-tested-image:
  script:
    - jf rt sp --include-dirs $ARTIFACT_PATH "tested=true”

А вот так будет выглядить страничка merge-request’а в гитлабе при использовании нашей стадии тестирования.

страничка merge-request'а

страничка merge-request’а

На ней можно видеть coverage, который вычисляется после прохождения тестов. В данном случае он равен 92%. А внизу картинки есть Test summary, там собраны результаты по всем запущенным тестам. Если какой-то из тестов упал, то в этой секции можно будет увидеть ошибку.

Scan

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

  • AppScreener

  • Checkmarx

  • Sonar

После того, как образ пройдет сканирование, мы точно также пометим его при помощи проперти. Прямо как после стадии сканирования, только проперти называется scanned.

.tag-scanned-image:
  script:
    - jf rt sp --include-dirs $ARTIFACT_PATH "scanned=true”

После того, как образ прошел тестирование и сканирование — его можно релизить.

Image release

Самая важная стадия, которая лежит в основе всего нашего пайплайна.

.image-release:
 stage: image-release
 script:
  - ARTIFACT_FROM=$RELEASE_FROM_TAG
  - ARTIFACT_TO=$RELEASE_TO_TAG
  - jf rt copy docker://$ARTIFACT_FROM docker://$ARTIFACT_TO

Но по сути все ее назначение сводится к тому, что мы просто копируем образы из одно места в другое.

Это позволяет экономить очень много времени, так как операция копирования намного быстрее операции сборки образа.

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

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

Немного ранее я показывал вот такое дерево. Здесь была только одна папка ci, куда образы попадали сразу после сборки.

.
├── group/
│   ├── project/
│   │   └── ci/
│   │       ├── 891e713b
│   │       ├── 891e713b-amd64
│   │       ├── 891e713b-arm64
│   │       └── ...
│   └── ...
└── ...

Но на самом деле, есть еще и вторая папка release. Так что дерево будет выглядеть так.

.
├── group/
│   ├── project/
│   │   ├── ci/
│   │   │   ├── 891e713
│   │   │   ├── 891e713-amd64
│   │   │   ├── 891e713-arm64
│   │   │   └── ...
│   │   └── release/
│   │       ├── 891e713
│   │       ├── 1.0.0
│   │       └── ...
│   └── ...
└── ...

Но зачем нам две папки?

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

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

Копирование происходит следующим образом. Если мы хотим релизиться на не продовую среду, то нам достаточно перенести образ из ci в release с сохранением имени.
artifactory.ru/group/project/ci:891e713b → artifactory.ru/group/project/release:891e713b

Если же мы хотим релизиться на прод, то мы создаем тег, к примеру, 1.0.0 и копируем образ из папки release в нее же, но с изменением имени.
artifactory.ru/group/project/release:891e713b → artifactory.ru/group/project/release:1.0.0

Но перед тем, как копировать образ, нам необходимо убедиться, что образ валиден. Поэтому мы проверяем наличие проперти tested и scanned при помощи следующей джобы

.verify-artifactory-properties:
 stage: image-release
 script:
  - image_count=$(jf rt s --count --props "tested;scanned" $ARTIFACT)
  - test $image_count -eq 1

Проверка происходит при помощи все той же утилиты jf. Выполняется подсчет образов с нужным названием и предполагаемыми проперти. Если таких образов нет, то мы пайплайн падает и выполнение последующих стадий становится невозможным. А если же такой образ все-таки существует, то можно деплоиться.

Просуммируем, что у нас получилось.

  • В реджистри есть папка ci, где оказываются «грязные» образы, сразу после их сборки. Такие образы нельзя допускать на среды, ведь они могут быть поломаны.

  • Для этого существует папка release, где лежат «чистые» образы. Образы копируются в эту папку из папки ci после прохождения проверки.

  • Деплоиться образы можно только из папки release.

  • Экономим время, ведь копирование быстрее сборки.

  • Экономим место в реджистри, так как физического копирования слоев образа в Artifactory не происходит.

  • «Проносим» образ в неизменном состоянии через все стадии пайплайна.

Deploy

У себя мы используем kubernetes и helm для деплоя. У имеется больше, чем одна среда, но джобы для деплоя на каждую идентичны, так что посмотрим только на одну.

.deploy-dev:
 stage: deploy
 environment:
  name: dev
  rules:
   - !reference [.default-rules, deploy-dev-rule]
  before_script:
   - export KUBE_CONTEXT=${KUBE_CONTEXT_DEV:-$KUBE_CONTEXT}
   - export DEPLOY_NAMESPACE=${DEPLOY_NAMESPACE_DEV:-$DEPLOY_NAMESPACE}
   - kubectl config use-context "$KUBE_CONTEXT"
   - helm upgrade -i $CI_PROJECT_NAME $HELM_CHART -n DEPLOY_NAMESPACE \
    -f $VALUES_FILE --set ...

Для того, чтобы связать kubernetes кластер и раннер гитлаба, можно использовать gitlab-agent. Здесь мы не будем рассматривать, как правильно его настраивать, но это подробно описано в документации.

Теперь немного о том, что происходит в самой джобе.

  • Сначала происходит вычисление KUBE_CONTEXT. Выражение {KUBE_CONTEXT_DEV:-$KUBE_CONTEXT} означает — если переменная KUBE_CONTEXT_DEV не определена, то используется KUBE_CONTEXT как переменная по умолчанию.

  • То же самое происходит и с неймспейсом.

  • Вычисленный контекст применяется, чтобы helm знал, куда деплоить.

  • При помощи helm upgrade -i происходит выкатка чарта в нужный нам кластер и неймспейс.

Presets

Теперь, когда мы рассмотрели все самые важные стадии пайплайна, можно вводить сущность под названием Preset. Из-за того, что стадий и джоб довольно много, будет очень неудобно использовать их в .gitlab-ci as is.

Именно поэтому и существуют пресеты. Это такие файлы, которые должен использовать конечный пользователь. В этих файлах собраны и упорядочены в выполнении все джобы для конкретного flow. Мы реализовали три пресета.

  • Backend — для бэкэнд приложений и сервисов.

  • Frontend — так как мы являемся fullstack разработчиками, то пишем еще и фронтенд.

  • Package — для python пакетов.

Давайте рассмотрим кусочек из bakend пресета.

ruff-check-static-analyze:
 extends: .ruff-check-static-analyze


kaniko-build:
 extends: .kaniko-build


kaniko-build-arm:
 extends: .kaniko-build-arm


publish-multiarch-manifest:
 needs:
   - job: kaniko-build-arm
     optional: true
   - kaniko-build
 extends: .publish-multiarch-manifest


test-compose:
 extends: .test-compose-ci


tag-tested-image:
 needs:
   - job: publish-multiarch-manifest
     optional: true
   - job: test-compose
     optional: true
 extends: .tag-tested-image

В реальности этот файл раза в 3–4 больше и там определены все джобы и стадии, которые были рассмотрены выше.

А вот и главный плюс такого подхода. Конечный пользователь может очень просто подключить к себе в .gitlab-ci такой пресет. Вот пример из одного нашего сервиса.

include:
 - project: "python-community/pypelines"
   file: "presets/backend--v1.yml"

Да, это весь файл .gitlab-ci. А давайте посмотрим на flow, который этот preset предоставляет.

Flow

Давайте посмотрим на flow, который получается, если использовать backend пресет. Будем рассматривать именно на этом пресете, так как он самый большой и интересный. Представим следующие ситуации:

  • Merge request в режиме Continuous Integration

  • Merge request в режиме Continuous Isolation

  • Master ветка без тега.

  • Master ветка с тегом.

Как я уже говорил, мы очень любим Continuous Integration, так как он поощряет частую интеграцию в master.

flow на merge-request'е в режиме Continuous Integration

flow на merge-request’е в режиме Continuous Integration

В таком режиме происходит запуск одних лишь линтеров. Мы так сделали потому что хотели, чтобы наши merge-request’ы не были заблокированы упавшими тестами или еще чем-либо. Если что-то и ломается после интеграции с мастером, то мастер чинит тот, чей merge-request его поломал.

Если же вы хотите, чтобы на ваших merge-request’ах происходила полная изоляция со сборкой, тестированием и сканированием. То вам может подойти подход с использование Continuous Isolation.

flow на merge-request'е в режиме Continuous Isolation

flow на merge-request’е в режиме Continuous Isolation

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

Но после интеграции в мастер также происходит запуск пайплайна.

flow на master'е без тега

flow на master’е без тега

В целом, тут все то же самое, что и в предыдущем примере. За одним лишь исключением: запуска линтеров не происходит, ведь мы предполагаем, что интеграция была произведена через merge-request, а на нем линтеры уже были запущены. Таким образом мы экономим еще немного времени.

А при создании тега, получаем следующее.

flow на master'е с тегом

flow на master’е с тегом

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

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

  • Стадия gitlab-release не была рассмотрена в статье, но про нее можно послушать в моем выступлении. Если кратко — то она создает релиз в гитлабе и пишет туда, что вошло в релиз.

  • Стадия E2E также не рассматривалась. Про нее можно послушать тут.

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

Скорость

Но насколько же быстро работает этот пайплайн? Замеры проводились в следующих условиях:

И вот что получилось:

  • На merge-request’e в режиме Continuous Integration пайплайн бежал 1.5 минуты. Самой медленной джобой оказалась та, что запускает mypy. Происходит это потому, что мы устанавливаем все зависимости проекта перед запуском, ведь хотим получать максимально полный анализ. Остальные же джобы пробегают где-то за 10 секунд. Так что есть пространство для улучшения.

  • Выполнение на master ветке без тега занимает 8–9 минут. Самыми долгими оказались стадии сборки и тестирования; 2 и 3 минут соответственно. Мы пока не придумали, как это можно ускорить, но активно над этим работаем.

  • А после создания тега раскатка на прод заняла 1.5 минуты.

По итогу для того, чтобы выкатить что-то на прод, нам нужно всего 11–12 минут, начиная с merge-request’а и заканчивая рабочим кодом на проде. Тут есть пространство для ускорения и по нашим подсчетам, если оптимизировать некоторые моменты — получится проводить все те же операции чуть быстрее, за 9–10 минут

Итог

Давайте подытожим, что же умеет делать наш пайплайн.

  • Пайплайн позволяет экономить время при сетапе сервисов. Все, что надо сделать — вставить три строчки кода в .gitlab-ci файл.

  • Работает в режимах Continuous Integration и Continuous Isolation.

  • Позволяет генерировать Code quality отчеты к вашему коду

  • Доставляет функционал на прод за 11–12 минут с момента создание ПРа

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

  • Экономит место в реджистри.

  • Гарантирует, что на среды попадут только чистые образы, прошедшие проверку.

  • Закрывает все базовые нужды продуктовой команды разработки всего за 3 строки в gitlab-ci файле.

Спасибо всем, кто дочитал до конца).

Здесь вы можете найти исходники данного пайплайна и скопировать все, что вам нравится.

А вот тут можно посмотреть мое выступление с этими пайплайнами.

© Habrahabr.ru