Многие разработчики стремятся протестировать свои изменения перед развертыванием в стабильные среды: 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-моделей.

Первое приближение
Набросок пайплайна
Наш пайплайн будет состоять из четырех стадий:
-
Build — сборка и отправка образов (отдельные джобы для бекенда и фронтенда).
-
Provision — копирование базы данных.
-
Deploy — развертывание приложения в Kubernetes (отдельные джобы для бекенда и фронтенда).
-
Manage — удаление окружения.
Вопрос с автоматическим удалением окружений решается с помощью джобы, которая запускается после мерджа ветки через Merge Request (MR).

Организация репозитория
В репозитории создадим два файла:
-
.base.gitlab-ci.yml — содержит общую часть джоб для стадий build и deploy.
-
.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'
Итоговый результат
В итоге мы получили следующее:





Вместо заключения
Полный исходный код доступен в публичном репозитории по адресу: https://gitlab.com/avantit1/CiCdWithFeatureBranchesP1
В следующей статье расскажу, как быть, если репозиториев несколько и приложения в разных репозиториях зависят друг от друга (например, фронтенд и бекенд).
Автор: QTU100