Многие разработчики стремятся протестировать свои изменения перед развертыванием в стабильные среды: 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
В следующей статье расскажу, как быть, если репозиториев несколько и приложения в разных репозиториях зависят друг от друга (например, фронтенд и бекенд).
ссылка на оригинал статьи https://habr.com/ru/articles/892512/
Добавить комментарий