Эфемерные среды для каждого MR: Docker Compose поднимает стек за минуту
Строим ephemeral environments в GitLab CI: Docker Compose разворачивает изолированный стек на каждый merge request, после мержа - автоматически сносится.
Практика ephemeral environments в CI/CD на базе Docker Compose и Kubernetes namespaces
Тестовая среда, которую никто не сносит - это баг, который живёт вечно. Мы несколько недель назад переключились на подход ephemeral environments: каждый merge request получает собственный изолированный стек, который поднимается при открытии MR и сносится при мерже или закрытии. Расскажем как это выглядит на Docker Compose и почему не сразу полезли в Kubernetes namespaces.
Проблема с «вечной» staging-средой
Классика: есть одна staging-среда, в неё деплоят всё подряд, состояние непредсказуемое. Разработчик А задеплоил свою ветку, разработчик Б - свою, они конфликтуют на уровне базы данных, тестировщик не понимает что вообще сейчас там крутится. Отдельная боль - «я только что деплоил, всё работало» и «у меня не работает» от двух людей одновременно.
Плюс staging-среда обычно живёт постоянно, её данные расходятся с production, и она превращается в отдельный legacy-артефакт который надо поддерживать.
Ephemeral environments - другая механика. Каждый MR получает свой стек: своя база данных, свой application server, свой набор переменных. Параллельно могут работать десять сред по числу открытых MR, они не пересекаются. Закрыл MR - среда ушла.
Почему начали с Docker Compose, а не с Kubernetes namespaces
У нас есть несколько кластеров под managed-управлением, и идея с Kubernetes namespaces выглядит логично: создал namespace на каждый MR, задеплоил через Helm, после - удалил namespace. Аккуратно, изолированно, масштабируется.
Но там есть нюансы инициализации: надо тащить в namespace секреты, настраивать RBAC, давать права CI-пользователю на создание и удаление namespaces. Для команды которая только налаживает процесс - порог входа выше чем хотелось бы. Docker Compose на выделенном CI-раннере оказался быстрее в запуске: три часа настройки вместо двух дней.
Шаблон пайплайна
Пайплайн в .gitlab-ci.yml выглядит примерно так:
stages:
- test
- deploy-review
- cleanup
variables:
COMPOSE_PROJECT_NAME: "mr-${CI_COMMIT_REF_SLUG}"
APP_PORT: "3${CI_JOB_ID: -3}"
deploy-review:
stage: deploy-review
script:
- docker-compose -f docker-compose.review.yml up -d --build
- echo "Review env: http://${CI_RUNNER_TAGS}:${APP_PORT}"
environment:
name: review/${CI_COMMIT_REF_SLUG}
url: http://review.internal:${APP_PORT}
on_stop: stop-review
only:
- /^mr-/
stop-review:
stage: cleanup
script:
- docker-compose -f docker-compose.review.yml down -v
environment:
name: review/${CI_COMMIT_REF_SLUG}
action: stop
when: manual
only:
- /^mr-/
Ключевых момента два. Первый - COMPOSE_PROJECT_NAME включает номер MR, и Docker Compose изолирует все ресурсы (контейнеры, сети, тома) под этим именем. Десять параллельных MR - десять изолированных проектов. Второй - on_stop указывает GitLab что при закрытии MR нужно запустить job stop-review. GitLab сам вызывает его когда MR мержится или закрывается.
docker-compose.review.yml отличается от production-compose несколькими вещами:
services:
app:
build: .
ports:
- "${APP_PORT}:8080"
environment:
- DATABASE_URL=postgresql://app:app@db:5432/app_${CI_COMMIT_REF_SLUG}
- ENV=review
db:
image: postgres:10
environment:
- POSTGRES_DB=app_${CI_COMMIT_REF_SLUG}
- POSTGRES_USER=app
- POSTGRES_PASSWORD=app
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
База данных поднимается своя, пустая, с именем включающим номер MR. Никакого пересечения данных между средами. При down -v том удаляется вместе с контейнером.
Что работает хорошо
Изоляция. Десять MR - десять независимых баз. Разработчики не мешают друг другу, тестировщик знает точно что в среде для MR-47 крутится именно MR-47.
Скорость. Образы кешируются на раннере, up --build для типичного приложения - полторы-две минуты. Это быстрее чем ждать пока staging-среда освободится.
Автоматическая очистка. on_stop работает надёжно - проверили на двух-трёх циклах. Среды не накапливаются, раннер не забивается старыми контейнерами.
Ссылка в MR. GitLab показывает кнопку «View app» прямо в интерфейсе merge request - тестировщику не нужно искать адрес, он всегда в MR.
Где трётся
Порты. Простая арифметика «808 + номер» не работает при двузначных числах - порт выходит за пределы диапазона или конфликтует. Мы взяли три последних цифры CI_JOB_ID со смещением, но это самодеятельность которая требует поддержки. Нормальное решение - reverse proxy на раннере и маршрутизация по хосту вместо порта, но это следующий шаг.
Ресурсы раннера. Десять сред с postgres - это десять инстансов PostgreSQL, каждый ест память. Пока влезает, но у нас проекты небольшие. Если сред будет больше или база тяжелее - раннер станет узким местом.
Подготовка данных. Пустая база - хорошо для юнит-тестов, но тестировщику часто нужно что-то с данными. Пишем отдельный seed-скрипт который наполняет базу минимальным набором фикстур при старте среды. Это чуть усложнило deploy-review job, зато среда сразу пригодна для ручного тестирования.
Внешние зависимости. Среда изолирована внутри, но если приложение ходит во внешние сервисы - S3, внешнее API - это или мокается или ходит в тот же production-эндпоинт что и все. Для нас пока решается моками в ENV переменных, но это надо держать в голове.
Kubernetes namespaces: куда движемся
Docker Compose на раннере - рабочее решение, но с ограничениями по масштабу. Там где сред нужно много, или команды большие, или уже есть кластер - Kubernetes namespaces логичнее. Механика та же: kubectl create namespace mr-47, деплой через Helm с переопределением values, kubectl delete namespace mr-47 при закрытии MR.
Мы это пробуем параллельно на одном из кластеров - пока на уровне скриптов, без автоматики GitLab environments. Сложнее в части прав доступа, но масштабируется лучше и ресурсы управляются через request/limit а не через «сколько влезет на раннере».
Пока оба подхода живут в параллели: Docker Compose там где просто и быстро, Kubernetes там где уже есть кластер и нужна изоляция на уровне сети.