Продвинутый CI-CD или как реализовать динамические Feature стенды

в 20:15, , рубрики: Ansible, devops, docker, gitlab, helm, kubernetes, postgresql, динамические окружения

Многие разработчики стремятся протестировать свои изменения перед развертыванием в стабильные среды: prod, dev или staging. Первое, что приходит на ум — написание тестов. Однако, как показывает практика, времени на создание качественных тестов часто не хватает. В таких случаях логичное решение — настройка деплоя для отдельных веток которые можно использовать для предварительного тестирование перед мержем. Хотя эта идея кажется простой, ее реализация связана с рядом сложностей:

  • Приложение должно быть доступно извне, что требует настройки DNS-записей. Как это сделать наиболее эффективно?

  • Как организовать очистку окружений, чтобы избежать их накопления и захламления инфраструктуры?

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

  • Как управлять переменными, которые изменяются в зависимости от окружения (например, имя базы данных, хостнейм и т.д.)?

Далее в статье отвечу на эти вопросы и предложу решения для реализации динамических Feature стендов в рамках CI/CD.

Вводные данные

Для целей статьи будем исходить из следующих условий:

Есть приложение, написанное на C# (по мнению автора, лучший язык в мире =)).

  • Приложение представляет собой простейший URL Shortener.

  • В качестве базы данных используется PostgreSQL.

  • Приложение состоит из статического фронтенда на WebAssembly и API.

  • У фронтенда есть конфигурационная переменная, указывающая на хостнейм API.

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

Для CI/CD используется GitLab версии 17.

  • Сборка выполняется на Docker Runner, а в качестве регистра используется GitLab.

  • Деплой осуществляется в Kubernetes с помощью Helm.

  • DNS-записи создаются через Cloudflare. Однако мы используем External DNS, который поддерживает работу с различными DNS-провайдерами, поэтому статью можно адаптировать под ваш выбор провайдера.

Помимо feature-окружений, нам также нужны dev и prod окружения.

Чуть подробнее о приложении

Наше приложение состоит из:

  • Фронтенда на Blazor Wasm 8 (C#).

  • Бекенда на ASP.NET Core 8 (C#).

Фронтенд:

  • Представляет собой набор статических файлов (после сборки).

  • Конфигурируется через файл appsettings.json, где указаны хостнеймы API и фронтенда.

  • Расположен в папке BlazorWasmUrlShortener, там же находится Dockerfile.

Бекенд:

  • Работает с базой данных PostgreSQL через ORM Entity Framework.

  • Конфигурируется с помощью файла appsettings.json, в котором прописана строка подключения к базе.

  • Находится в отдельной папке BlazorWasmUrlShortener.Api..

В папке BlazorWasmUrlShortener.ApiModels расположена библиотека с определением API-моделей.

Так выглядит приложение в браузере

Так выглядит приложение в браузере

Первое приближение

Набросок пайплайна

Наш пайплайн будет состоять из четырех стадий:

  1. Build — сборка и отправка образов (отдельные джобы для бекенда и фронтенда).

  2. Provision — копирование базы данных.

  3. Deploy — развертывание приложения в Kubernetes (отдельные джобы для бекенда и фронтенда).

  4. Manage — удаление окружения.

Вопрос с автоматическим удалением окружений решается с помощью джобы, которая запускается после мерджа ветки через Merge Request (MR).

Схематичное изображение пайплайна

Схематичное изображение пайплайна

Организация репозитория

В репозитории создадим два файла:

  1. .base.gitlab-ci.yml — содержит общую часть джоб для стадий build и deploy.

  2. .gitlab-ci.yml — основной файл, который через include: .base.gitlab-ci.yml подключает первый файл.

Остальные файлы, связанные с CI, разместим в папке ci, чтобы не засорять корень репозитория. В частности:

  • В папку ci/core/helm-charts/application добавим заранее подготовленный Helm-чарт для наших приложений (он достаточно универсален, и вы сможете использовать его в своих проектах).

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

Расположение файлов переменных для чарта в корневой папке

Расположение файлов переменных для чарта в корневой папке

Подготовка инфраструктуры

Внимание! Инструкции, представленные ниже, подходят только для тестовой установки и не годятся для production-окружения. Я решил включить их для полноты картины.

Для простейшей установки Kubernetes-кластера потребуется виртуальная машина на базе Ubuntu Server 22.04 с публичным IP, 4 ядрами, 8 GiB оперативной памяти и 80 GiB дискового пространства. После создания машины подключаемся к ней и устанавливаем CRI-O (контейнерный рантайм, похожий на Docker, но оптимизированный для работы с Kubernetes):

sudo apt update
sudo apt install apt-transport-https ca-certificates curl gnupg2 software-properties-common -y
export OS=xUbuntu_22.04
export CRIO_VERSION=1.24
echo "deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/$OS/ /"| sudo tee /etc/apt/sources.list.d/devel:kubic:libcontainers:stable.list
echo "deb http://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable:/cri-o:/$CRIO_VERSION/$OS/ /"|sudo tee /etc/apt/sources.list.d/devel:kubic:libcontainers:stable:cri-o:$CRIO_VERSION.list
curl -L https://download.opensuse.org/repositories/devel:kubic:libcontainers:stable:cri-o:$CRIO_VERSION/$OS/Release.key | sudo apt-key add -
curl -L https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/$OS/Release.key | sudo apt-key add -
sudo apt update
sudo apt install cri-o cri-o-runc -y
sudo systemctl start crio
sudo systemctl enable crio
sudo systemctl status crio

Затем настраиваем Kubernetes через kubeadm и устанавливаем kubectl и helm:

sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl gpg
sudo mkdir -p -m 755 /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.32/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.32/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl
sudo systemctl enable --now kubelet
sudo sed -i 's/#net.ipv4.ip_forward=1/net.ipv4.ip_forward=1/' /etc/sysctl.conf
sudo sysctl -p
sudo kubeadm init --pod-network-cidr=10.244.0.0/16 --cri-socket=unix:///var/run/crio/crio.sock --apiserver-advertise-address __PUBLIC_IP__ --node-name master-1
mkdir ~/.kube
cp /etc/kubernetes/admin.conf ~/.kube/config
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
kubectl taint nodes master-1 node-role.kubernetes.io/control-plane:NoSchedule-
kubectl get po -A

Устанавливаем Nginx Ingress, PostgreSQL, ExternalDNS и OpenEBS

Устанавливаем OpenEBS LocalPV

Это необходимо для создания диска PersistentVolume для PostgreSQL:

helm repo add openebs https://openebs.github.io/openebs
helm upgrade --install --atomic --timeout 3m --set engines.replicated.mayastor.enabled=false --namespace openebs --create-namespace openebs openebs/openebs --version 4.1.1
kubectl patch storageclass openebs-hostpath -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'

Устанавливаем PostgreSQL

Сама база данных. Указываем создание ноды на порту 31000, который будет привязан к порту 5432 PostgreSQL:

helm repo add bitnami https://charts.bitnami.com/bitnami
helm upgrade --install --atomic --timeout 3m --set primary.service.type=NodePort --set primary.service.nodePorts.postgresql=31000 --set global.postgresql.auth.database=app --set global.postgresql.auth.password=__YOUR__ROOT__PASSWORD__ --set global.postgresql.auth.username=app --set global.postgresql.auth.postgresPassword=__YOUR__PROD__APP__DB__PASSWORD --create-namespace --namespace=devops postgresql bitnami/postgresql --version 16.3.3

Устанавливаем ExternalDNS

Создаем токен в Cloudflare. Для этого переходим в настройки профиля и генерируем токен с правами на редактирование DNS-записей для домена, который планируем использовать. Подробнее процесс показан на скриншоте ниже.

Шаг первый

Шаг первый
Шаг второй

Шаг второй

Устанавливаем ExternalDNS через Helm

Для установки выполняем на машине:

helm repo add bitnami https://charts.bitnami.com/bitnami
helm upgrade --install --atomic --timeout 3m --set provider=cloudflare --set cloudflare.apiToken=__YOUR__API__TOKEN__ --set cloudflare.proxied=true --create-namespace --namespace=devops external-dns bitnami/external-dns --version 8.7.1

Устанавливаем Nginx Ingress через Helm

Для установки выполняем на машине:

helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm upgrade --install --set controller.service.enabled=false --set controller.hostNetwork=true --set controller.kind=DaemonSet --create-namespace --namespace devops ingress-nginx ingress-nginx/ingress-nginx --version 4.11.3

Второе приближение: пишем CI/CD

Первым делом добавляем базовые настройки в файл .base.gitlab-ci.yml:

stages:
- build
- provision
- deploy
- manage
# Public image that contains Ansible, Kubectl, Helm
image: public-docker-repository.avant-it.ru/public-ci-job:4.4.0
.base:
  tags:
  # Enter your runner tag here
  - avant_gitlab_com

Настраиваем сборку

Определим основную логику джобы build в файле .base.gitlab-ci.yml. В этой части мы шаблонизируем конфигурационные файлы приложения с помощью шаблонизатора Jinja и Ansible. Это единственное отличие от типовой конфигурации сборки Docker-образа.

.build:
  extends: .base
  stage: build
  script:
  # If J2_TEMPLATES variable (in yaml string array format) is defined, we template every file name
  #   E.g. [".env.j2"] will result in templated file .env
  #   E.g. ["src/.env.j2"] will result in templated file src/.env
  - |
    if [ -n "${J2_TEMPLATES}" ];
    then
      echo "Templating ${J2_TEMPLATES}"
      ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook ci/core/ansible/template.yaml -v
    fi
  # Show environment variables for debugging
  - printenv
  # Building and pushing im
  - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
  - >
    docker build
    ${DOCKER_BUILD_EXTRA_ARGUMENTS}
    -f "${CI_PROJECT_DIR}/${DOCKERFILE_PATH}"
    -t "${IMAGE_TAG}-${APP_NAME}"
    ${DOCKERFILE_CONTEXT_PATH}
  - docker push "${IMAGE_TAG}-${APP_NAME}"
  variables:
    DOCKER_BUILD_EXTRA_ARGUMENTS: ""
    DOCKERFILE_PATH: Dockerfile
    DOCKERFILE_CONTEXT_PATH: "."
    DOCKER_BUILDKIT: "1"
    IMAGE_TAG: "${CI_REGISTRY_IMAGE}:${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}-${CI_PIPELINE_ID}"
    APP_NAME: "main"
  rules:
  # Do not run Build on MR
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    when: never
  # Auto start for production Production branch
  - if: $CI_COMMIT_REF_SLUG == "master"
    when: on_success
    variables:
      ENV_NAME: prod
      ENV_NAME_PREFIX: ""
  # Auto start for production Development branch
  - if: $CI_COMMIT_REF_SLUG == "dev"
    when: on_success
    variables:
      ENV_NAME: dev
      ENV_NAME_PREFIX: dev-
  # Manual start for production Preview branches
  - when: manual
    variables:
      ENV_NAME: pre
      ENV_NAME_PREFIX: pre-${CI_COMMIT_REF_SLUG}-
  tags:
  - avant_gitlab_com

Создаем простейший Ansible Playbook

Playbook размещаем по пути ci/core/ansible/template.yaml. Он будет шаблонизировать файлы, указанные в переменной J2_TEMPLATES:

- hosts: localhost
  tasks:
  - name: Parse J2_TEMPLATES
    set_fact:
      items: "{{ lookup('env', 'J2_TEMPLATES') | from_yaml }}"
  - name: Templating items {{ lookup('env', 'J2_TEMPLATES') }}
    template:
      src: "{{ lookup('env', 'CI_PROJECT_DIR') }}/{{ item }}"
      dest: "{{ lookup('env', 'CI_PROJECT_DIR') }}/{{ item | regex_replace('.j2$') }}"
    loop: "{{ items }}"
  - name: Print
    debug:
      msg: "{{ lookup('file', lookup('env', 'CI_PROJECT_DIR') + '/' + item | regex_replace('.j2$')) }}"
    loop: "{{ items }}"

Основной файл и шаблонизация

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

Build frontend:
  extends: .build
  variables:
    APP_NAME: "frontend"
    DOCKERFILE_PATH: BlazorWasmUrlShortener/Dockerfile
    J2_TEMPLATES: "['BlazorWasmUrlShortener/wwwroot/appsettings.json.j2']"
Build backend:
  extends: .build
  variables:
    APP_NAME: "backend"
    DOCKERFILE_PATH: BlazorWasmUrlShortenerApi/Dockerfile
    J2_TEMPLATES: "['BlazorWasmUrlShortenerApi/appsettings.json.j2']"

В этой джобе прописана шаблонизация файла BlazorWasmUrlShortener/wwwroot/appsettings.json.j2 в BlazorWasmUrlShortener/wwwroot/appsettings.json. Это необходимо, чтобы фронтенд знал, на каком хостнейме работают он и API. Вот как выглядит шаблон его конфига:

{
  "AppUrl": "https://{{ lookup('env', 'ENV_NAME_PREFIX') }}shortener.avant-it.ru",
  "ApiUrl": "https://{{ lookup('env', 'ENV_NAME_PREFIX') }}sapi.avant-it.ru"
}

А так выглядит шаблон конфигурационного файла бекенда:

{% if lookup('env', 'ENV_NAME') == 'prod' %}
{% set db_user = 'app' %}
{% set db_password = lookup('env', 'PROD_DB_APP_PASSWORD') %}
{% else %}
{% set db_user = 'dev-app' %}
{% set db_password = lookup('env', 'DEV_PRE_DB_APP_PASSWORD') %}
{% endif %}
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "Database": "Host=postgresql.devops:5432;Database={{ lookup('env', 'ENV_NAME_PREFIX') }}app;Username={{ db_user }};Password={{ db_password }};SSL Mode=Disable;Timeout=300;CommandTimeout=300;Include Error Detail=True;"
  }
}

В нем уже видны достаточно хитрые конструкции.

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

Настраиваем копирование базы из dev-окружения

Здесь всё немного сложнее. По причинам, которые станут понятны при реализации джобы на удаление окружения, мы не можем использовать переменную CI_COMMIT_REF_SLUG для именования базы. Вместо этого создаем аналог этой переменной с помощью следующих строк:

# Since we can not use gitlab's CI_COMMIT_REF_SLUG variable, we create our own
- export SRC_BRANCH_SLUG=$(echo "$CI_COMMIT_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:]]+//g')
# To defend against branch names starting from number we add ref- prefix
- export SRC_BRANCH_SLUG=ref-${SRC_BRANCH_SLUG}

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

- export TARGET_DB_NAME="${ENV_NAME_PREFIX}app"
- |
  if psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -lqt | cut -d | -f 1 | grep -qw "${TARGET_DB_NAME}"; then
    echo "Database already exist, skipping!"
    exit 0
  fi

После всех этих действий копируем базу данных:

# Dumping dev db
- pg_dump -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -j 10 -F directory -f /tmp/app ${DEV_DB_NAME}
- ls -lah /tmp/app
# Creating preview env database
- psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -d postgres -c "CREATE DATABASE "${TARGET_DB_NAME}""
- pg_restore -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -j 10 -F directory -d "${TARGET_DB_NAME}" /tmp/app

Добавляем создание dev-пользователя:

# Create dev user
- psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -d postgres -c "CREATE ROLE "${DB_DEV_DB_USER}" WITH LOGIN CREATEDB CREATEROLE NOREPLICATION ENCRYPTED PASSWORD '${DEV_PRE_DB_APP_PASSWORD}';" || true
- psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -d postgres -c "ALTER ROLE "${DB_DEV_DB_USER}" WITH ENCRYPTED PASSWORD '${DEV_PRE_DB_APP_PASSWORD}';"

И, на всякий случай, исправляем права на базу:

# Fixing permisions
- psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -d postgres -c "ALTER DATABASE "${TARGET_DB_NAME}" OWNER TO "${DB_DEV_DB_USER}";"
- psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -d ${TARGET_DB_NAME} -c "GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON ALL TABLES IN SCHEMA "public" TO "${DB_DEV_DB_USER}";"
- psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -d ${TARGET_DB_NAME} -c "REASSIGN OWNED BY app TO "${DB_DEV_DB_USER}";"

В итоге получаем такую джобу:

Copy Dev Db to Pre Db:
  extends: .base
  stage: provision
  script:
  # Since we can not use gitlab's SLUG variable, we create our own
  - export SRC_BRANCH_SLUG=$(echo "$CI_COMMIT_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:]]+//g')
  # To defend against branch names starting from number we add ref- prefix
  - export SRC_BRANCH_SLUG=ref-${SRC_BRANCH_SLUG}
  - echo "Extracted SRC_BRANCH_SLUG=${SRC_BRANCH_SLUG}"
  - |
    if [ $ENV_NAME = "prod" ]; then
      export ENV_NAME_PREFIX=""
    elif [ $ENV_NAME = "dev" ]; then
      export ENV_NAME_PREFIX="dev-"
    else
      export ENV_NAME_PREFIX="pre-${SRC_BRANCH_SLUG}-"
    fi
  - echo "Extracted ENV_NAME_PREFIX=${ENV_NAME_PREFIX}"
  - export TARGET_DB_NAME="${ENV_NAME_PREFIX}app"
  - |
    if psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -lqt | cut -d | -f 1 | grep -qw "${TARGET_DB_NAME}"; then
      echo "Database already exist, skipping!"
      exit 0
    fi
  # Dumping dev db
  - pg_dump -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -j 10 -F directory -f /tmp/app ${DEV_DB_NAME}
  - ls -lah /tmp/app
  # Creating preview env database
  - psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -d postgres -c "CREATE DATABASE "${TARGET_DB_NAME}""
  - pg_restore -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -j 10 -F directory -d "${TARGET_DB_NAME}" /tmp/app
  # Create dev user
  - psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -d postgres -c "CREATE ROLE "${DB_DEV_DB_USER}" WITH LOGIN CREATEDB CREATEROLE NOREPLICATION ENCRYPTED PASSWORD '${DEV_PRE_DB_APP_PASSWORD}';" || true
  - psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -d postgres -c "ALTER ROLE "${DB_DEV_DB_USER}" WITH ENCRYPTED PASSWORD '${DEV_PRE_DB_APP_PASSWORD}';"
  # Fixing permisions
  - psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -d postgres -c "ALTER DATABASE "${TARGET_DB_NAME}" OWNER TO "${DB_DEV_DB_USER}";"
  - psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -d ${TARGET_DB_NAME} -c "GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON ALL TABLES IN SCHEMA "public" TO "${DB_DEV_DB_USER}";"
  - psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -d ${TARGET_DB_NAME} -c "REASSIGN OWNED BY app TO "${DB_DEV_DB_USER}";"
  rules:
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    when: never
  - if: $CI_COMMIT_REF_SLUG == "master"
    when: never
  - if: $CI_COMMIT_REF_SLUG == "dev"
    when: never
  - when: on_success
  variables:
    DB_DEV_DB_USER: "dev-app"
  resource_group: provision

Обратите внимание на строку: resource_group: provision. Она необходима для того, чтобы джоба не выполнялась параллельно в случае одновременного запуска двух пайплайнов.

Настраиваем деплой

Здесь кода будет побольше.

Загрузка версий Helm и kubectl

Первым делом загружаем версии Helm и kubectl, совместимые с нашей версией кластера (1.31). Иначе можно столкнуться с неожиданными ошибками. Для этого в базовом образе есть скрипт /root/load-kube-version.sh. Затем загружаем kube-конфиг, добавленный в виде файловой переменной, в /root/.kube/config, чтобы Helm мог его использовать. Все это делается следующими строками:

# Load helm and kubectl for our kubernetes version
- /root/load-kube-version.sh 1.31
# Copy kubeconfig from KUBECONFIG !file! variable to well-known location
- mkdir /root/.kube
- cat ${KUBECONFIG} > /root/.kube/config
# If J2_TEMPLATES variable (in yaml string array format) is defined, we template every file name
#   E.g. [".env.j2"] will result in templated file .env
#   E.g. ["src/.env.j2"] will result in templated file src/.env
- |
    if [ -n "${J2_TEMPLATES}" ];
    then
        echo "Templating ${J2_TEMPLATES}"
        ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook ci/core/ansible/template.yaml -v
    fi

Подгрузка переменных для Helm-чарта

Мы используем несколько файлов переменных:

  • ci/values.yaml[.j2] — общие настройки для всех приложений и окружений.

  • ci/APP_NAME.values.yaml[.j2] — частные настройки для конкретного приложения.

  • ci/ENV_NAME/values.yaml[.j2] — частные настройки для конкретного окружения (всех приложений в нем).

  • ci/ENV_NAME/APP_NAME.values.yaml[.j2] — частные настройки для конкретного окружения и конкретного приложения.

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

Пример того, как выглядит структура в репозитории

Пример того, как выглядит структура в репозитории

Каждый файл может иметь расширение .j2, и тогда он будет шаблонизирован. Это удобно, так как в Helm-чарте создается Ingress, и хостнейм в нем зависит от ветки. Благодаря шаблонизации, мы можем использовать переменную ENV_NAME_PREFIX, которая содержит уникальный префикс окружения, и подставить её перед хостнеймом.

Например, так выглядит frontend.values.yaml.j2:

nginxIngress:
  enable: true
  ingresses:
  - className: "nginx"
    hostname: "{{ lookup('env', 'ENV_NAME_PREFIX') }}shortener.avant-it.ru"
    paths:
    - path: /
      pathType: Prefix
      servicePort: 5000

А так выглядит фрагмент кода, отвечающий за обработку этих файлов и установку приложения:

# Sometimes you need to pass some gitlab var to app's env variables. In that case you can user .j2 values files
  #   and add somewhere inside them something like: {{ lookup('env', 'CI_COMMIT_BTANCH') }}
  - |
    if [ -e ./ci/values.yaml.j2 ]
    then
      echo "Templating ./ci/values.yaml.j2"
      ansible localhost -m ansible.builtin.template -a "src=./ci/values.yaml.j2 dest=./ci/values.yaml"
    fi
  - |
    if [ -e ./ci/${APP_NAME}.values.yaml.j2 ]
    then
      echo "Templating ./ci/${APP_NAME}.values.yaml.j2"
      ansible localhost -m ansible.builtin.template -a "src=./ci/${APP_NAME}.values.yaml.j2 dest=./ci/${APP_NAME}.values.yaml" || true
    fi
  - |
    if [ -e ./ci/${ENV_NAME}/values.yaml.j2 ]
    then
      echo "Templating ./ci/${ENV_NAME}/values.yaml.j2"
      ansible localhost -m ansible.builtin.template -a "src=./ci/${ENV_NAME}/values.yaml.j2 dest=./ci/${ENV_NAME}/values.yaml" || true
    fi
  - |
    if [ -e ./ci/${ENV_NAME}/${APP_NAME}.values.yaml.j2 ]
    then
      echo "Templating ./ci/${ENV_NAME}/${APP_NAME}.values.yaml.j2"
      ansible localhost -m ansible.builtin.template -a "src=./ci/${ENV_NAME}/${APP_NAME}.values.yaml.j2 dest=./ci/${ENV_NAME}/${APP_NAME}.values.yaml" || true
    fi
  - |
    helm upgrade 
    --install 
    --create-namespace 
    --namespace=${ENV_NAME}-${SRC_BRANCH_SLUG} 
    --set image=${IMAGE_TAG}-${APP_NAME} 
    --set imagePullCredentials.url=${CI_REGISTRY} 
    --set imagePullCredentials.user=${CI_REGISTRY_USER} 
    --set imagePullCredentials.password=${CI_REGISTRY_PASSWORD} 
    --set appName=${APP_NAME} 
    ${HELM_EXTRA_ARGUMENT} 
    --wait=${HELM_WAIT} 
    --timeout=${HELM_TIMEOUT} 
    --atomic=${HELM_ATOMIC} 
    ${APP_NAME}-${CI_PROJECT_ID} 
    ${HELM_CHART_PATH} 
    -f ./ci/values.yaml 
    -f ./ci/${APP_NAME}.values.yaml 
    -f ./ci/${ENV_NAME}/values.yaml 
    -f ./ci/${ENV_NAME}/${APP_NAME}.values.yaml

Код джобы

Весь код джобы выглядит так:

.deploy:
  extends: .base
  stage: deploy
  script:
  # Since we can not use gitlab's SLUG variable, we create our own
  - export SRC_BRANCH_SLUG=$(echo "$CI_COMMIT_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:]]+//g')
  # To defend against branch names starting from number we add ref- prefix
  - export SRC_BRANCH_SLUG=ref-${SRC_BRANCH_SLUG}
  - echo "Extracted SRC_BRANCH_SLUG=${SRC_BRANCH_SLUG}"
  - |
    if [ $ENV_NAME = "prod" ]; then
      export ENV_NAME_PREFIX=""
    elif [ $ENV_NAME = "dev" ]; then
      export ENV_NAME_PREFIX="dev-"
    else
      export ENV_NAME_PREFIX="pre-${SRC_BRANCH_SLUG}-"
    fi
  - echo "Extracted ENV_NAME_PREFIX=${ENV_NAME_PREFIX}"
  # Load helm and kubectl for our kubernetes version
  - /root/load-kube-version.sh 1.31
  # Copy kubeconfig from KUBECONFIG !file! variable to well-known location
  - mkdir /root/.kube
  - cat ${KUBECONFIG} > /root/.kube/config
  # If J2_TEMPLATES variable (in yaml string array format) is defined, we template every file name
  #   E.g. [".env.j2"] will result in templated file .env
  #   E.g. ["src/.env.j2"] will result in templated file src/.env
  - |
    if [ -n "${J2_TEMPLATES}" ];
    then
      echo "Templating ${J2_TEMPLATES}"
      ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook ci/core/ansible/template.yaml -v
    fi
  # Sometimes you need to pass some gitlab var to app's env variables. In that case you can user .j2 values files
  #   and add somewhere inside them something like: {{ lookup('env', 'CI_COMMIT_BTANCH') }}
  - |
    if [ -e ./ci/values.yaml.j2 ]
    then
      echo "Templating ./ci/values.yaml.j2"
      ansible localhost -m ansible.builtin.template -a "src=./ci/values.yaml.j2 dest=./ci/values.yaml"
    fi
  - |
    if [ -e ./ci/${APP_NAME}.values.yaml.j2 ]
    then
      echo "Templating ./ci/${APP_NAME}.values.yaml.j2"
      ansible localhost -m ansible.builtin.template -a "src=./ci/${APP_NAME}.values.yaml.j2 dest=./ci/${APP_NAME}.values.yaml" || true
    fi
  - |
    if [ -e ./ci/${ENV_NAME}/values.yaml.j2 ]
    then
      echo "Templating ./ci/${ENV_NAME}/values.yaml.j2"
      ansible localhost -m ansible.builtin.template -a "src=./ci/${ENV_NAME}/values.yaml.j2 dest=./ci/${ENV_NAME}/values.yaml" || true
    fi
  - |
    if [ -e ./ci/${ENV_NAME}/${APP_NAME}.values.yaml.j2 ]
    then
      echo "Templating ./ci/${ENV_NAME}/${APP_NAME}.values.yaml.j2"
      ansible localhost -m ansible.builtin.template -a "src=./ci/${ENV_NAME}/${APP_NAME}.values.yaml.j2 dest=./ci/${ENV_NAME}/${APP_NAME}.values.yaml" || true
    fi
  - |
    helm upgrade 
    --install 
    --create-namespace 
    --namespace=${ENV_NAME}-${SRC_BRANCH_SLUG} 
    --set image=${IMAGE_TAG}-${APP_NAME} 
    --set imagePullCredentials.url=${CI_REGISTRY} 
    --set imagePullCredentials.user=${CI_REGISTRY_USER} 
    --set imagePullCredentials.password=${CI_REGISTRY_PASSWORD} 
    --set appName=${APP_NAME} 
    ${HELM_EXTRA_ARGUMENT} 
    --wait=${HELM_WAIT} 
    --timeout=${HELM_TIMEOUT} 
    --atomic=${HELM_ATOMIC} 
    ${APP_NAME}-${CI_PROJECT_ID} 
    ${HELM_CHART_PATH} 
    -f ./ci/values.yaml 
    -f ./ci/${APP_NAME}.values.yaml 
    -f ./ci/${ENV_NAME}/values.yaml 
    -f ./ci/${ENV_NAME}/${APP_NAME}.values.yaml
  rules:
  # Do not run on MR
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    when: never
  # Auto start after previous stage for Production branch
  - if: $CI_COMMIT_REF_SLUG == "master"
    when: on_success
    variables:
      ENV_NAME: prod
  # Auto start after previous stage for Development branch
  - if: $CI_COMMIT_REF_SLUG == "dev"
    when: on_success
    variables:
      ENV_NAME: dev
  # Auto start after previous stage for Preview branches
  - when: on_success
    variables:
      ENV_NAME: pre
  variables:
    IMAGE_TAG: "${CI_REGISTRY_IMAGE}:${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}-${CI_PIPELINE_ID}"
    APP_NAME: "main"
    HELM_WAIT: "true"
    HELM_ATOMIC: "true"
    HELM_TIMEOUT: "3m"
    HELM_CHART_PATH: "ci/core/helm-charts/application"
  environment:
    name: ${ENV_NAME}-${CI_COMMIT_REF_SLUG}
    on_stop: Destory environment

Обратите внимание на флаг --atomic при установке через Helm. Он обеспечивает автоматический откат на предыдущую версию, если приложение крашится при старте.

В итоге джобы на деплой в файле .gitlab-ci.yml выглядят компактно:

Deploy frontend:
  extends: .deploy
  variables:
    APP_NAME: "frontend"
Deploy backend:
  extends: .deploy
  variables:
    APP_NAME: "backend"

Реализуем логику удаления окружений

Сама логика достаточно проста: мы удаляем все Helm-релизы в неймспейсе, созданном для окружения, затем удаляем сам неймспейс, а после — базу данных и пользователя. Однако автоматизация удаления после завершения Merge Request (MR) — это отдельная задача.

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

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

Извлекаем имя ветки окружения

Первым делом извлекаем имя ветки, которую будем удалять:

# Parse string like: Merge branch 'feature' into 'dev'
- export SRC_BRANCH=$(echo $CI_COMMIT_TITLE | grep -oE "branch.+into" | tr -d "'" | grep -oE " .+ " | tr -d " ")
- echo "Extracted SRC_BRANCH=${SRC_BRANCH}"
# Since we can not use gitlab's SLUG variable, we create our own
- export SRC_BRANCH_SLUG=$(echo "$SRC_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:]]+//g')
# To defend against branch names starting from number we add ref- prefix
- export SRC_BRANCH_SLUG=ref-${SRC_BRANCH_SLUG}

Удаляем окружение

В коде можно заметить || true в конце каждой команды. Это нужно для того, чтобы джобу можно было перезапустить, даже если она завершится с ошибкой:

# Delete all helm releases in that namespace in case (useful if helm chart contains cluster scoped resources that will not be deleted on namespace deletion)
- (helm ls -a -n pre-${SRC_BRANCH_SLUG} --max 1000 | grep -v "UPDATED" | awk '{ print $2 " " $1 }' | xargs -n 2 helm uninstall -n) || true
# Delete kubernetes namespace
- kubectl delete ns pre-${SRC_BRANCH_SLUG} || true
# Delete database
- psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -d postgres -c "DROP DATABASE IF EXISTS "pre-${SRC_BRANCH_SLUG}-app" WITH (FORCE);" || true

Весь код:

Destory environment:
  extends: .base
  stage: manage
  needs: []
  script:
  # Parse string like: Merge branch 'feature' into 'dev'
  - export SRC_BRANCH=$(echo $CI_COMMIT_TITLE | grep -oE "branch.+into" | tr -d "'" | grep -oE " .+ " | tr -d " ")
  - echo "Extracted SRC_BRANCH=${SRC_BRANCH}"
  # Since we can not use gitlab's SLUG variable, we create our own
  - export SRC_BRANCH_SLUG=$(echo "$SRC_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:]]+//g')
  # To defend against branch names starting from number we add ref- prefix
  - export SRC_BRANCH_SLUG=ref-${SRC_BRANCH_SLUG}
  - echo "Extracted SRC_BRANCH_SLUG=${SRC_BRANCH_SLUG}"
  - |
    if [ $ENV_NAME = "prod" ]; then
      export ENV_NAME_PREFIX=""
    elif [ $ENV_NAME = "dev" ]; then
      export ENV_NAME_PREFIX="dev-"
    else
      export ENV_NAME_PREFIX="pre-${SRC_BRANCH_SLUG}-"
    fi
  - echo "Extracted ENV_NAME_PREFIX=${ENV_NAME_PREFIX}"
  # Load helm and kubectl for our kubernetes version
  - /root/load-kube-version.sh 1.30
  - mkdir /root/.kube
  - cat ${KUBECONFIG} > /root/.kube/config
  # Delete all helm releases in that namespace in case (usefull if helm chart contains cluster scoped resources that will not be deleted on namespace deletion)
  - (helm ls -a -n pre-${SRC_BRANCH_SLUG} --max 1000 | grep -v "UPDATED" | awk '{ print $2 " " $1 }' | xargs -n 2 helm uninstall -n) || true
  # Delete kubernetes namespace
  - kubectl delete ns pre-${SRC_BRANCH_SLUG} || true
  # Delete database
  - psql -h ${DB_HOST} -U ${PGPASSWORD_USER} -p ${DB_PORT} -d postgres -c "DROP DATABASE IF EXISTS "pre-${SRC_BRANCH_SLUG}-app" WITH (FORCE);" || true
  environment:
    name: ${ENV_NAME}-${CI_COMMIT_REF_SLUG}
    action: stop
  rules:
  # Always run on commits that are created after merge in Production branch
  - if: '$CI_COMMIT_BRANCH == "master" && $CI_COMMIT_TITLE =~ /^Merge branch .+ into .master.$/'
    when: always
  # Always run on commits that are created after merge in Development branch
  - if: '$CI_COMMIT_BRANCH == "dev" && $CI_COMMIT_TITLE =~ /^Merge branch .+ into .dev.$/'
    when: always
  # Never run on MR
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    when: never
  # Never run on Production and Development branches itself
  - if: $CI_COMMIT_REF_SLUG == "master"
    when: never
  - if: $CI_COMMIT_REF_SLUG == "dev"
    when: never
  # Allow manuall run for Preview branches
  - when: manual
    variables:
      # Since we extract branch name from CI_COMMIT_TITLE, we have to send fake CI_COMMIT_TITLE =)
      #   That's dirty hack to simplify code =)
      CI_COMMIT_TITLE: Merge branch '$CI_COMMIT_BRANCH' into 'nothing'

Итоговый результат

В итоге мы получили следующее:

Картина пайплайнов после вкатки feature-окружения

Картина пайплайнов после вкатки feature-окружения
После вливания в dev, а затем в master

После вливания в dev, а затем в master
Пайплайн в feature-ветке

Пайплайн в feature-ветке
Вот как это выглядит в Kubernetes

Вот как это выглядит в Kubernetes
Список переменных

Список переменных

Вместо заключения

Полный исходный код доступен в публичном репозитории по адресу: https://gitlab.com/avantit1/CiCdWithFeatureBranchesP1

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

Автор: QTU100

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js