Клёвые фичи в Docker Compose — профили и шаблоны
Сейчас мы расскажем вам историю. Историю о том, как мы разработали API и решили написать на него E2E-тесты. Тесты были простыми, описывали и проверяли функциональность API, но оказались мудрёные в плане запуска. Но обо всём по порядку.
В этой статье рассмотрим решение к которому пришли на примере простой Docker Compose конфигурации.
Ручной запуск тестов
Мы искали удобный инструмент для написания E2E-тестов для API. Практически сразу нам встретился инструмент Karate. Полистали документацию и сначала решили запустить тесты вручную такой командой:
java -jar karate.jar .
Но выяснилось, что для этого надо установить Karate и Java-рантайм для него, а потом написать об этом инструкцию для трех операционок: Windows, Linux и macOS.
Используем Docker Compose
Чтобы избежать установки множества инструментов (а еще и определенных версий!), решили запускать тесты в Docker, где все зависимости описаны в Dockerfile, и при сборке контейнера они устанавливаются в сам контейнер автоматически. Ниже приводим пример нашего Dockerfile для запуска Karate-тестов. Вдохновлялись официальной документацией. Для запуска тестов в Docker мы решили использовать Docker Compose, так как он позволяет нам поднять сразу несколько сервисов одной командой. В нашем случае это API, база данных и контейнер с тестами:
Для запуска всех сервисов написали docker-compose.yml
файл.
version: '3.8'
services:
db:
image: postgres:13
container_name: 'db'
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
api:
container_name: 'api'
build:
context: .
dockerfile: Api/Dockerfile # Путь до докерфайла, из которого собирается
# образ API
ports:
- 5000:80
depends_on: # При запуске API миграции применяются к базе данных автоматически,
# поэтому API запускается, только когда база данных уже запущена
- db
karate_tests:
container_name: 'karate_tests'
build:
dockerfile: KarateDockerfile # Путь до докерфайла, из которого собирается
# образ Karate (скачивается karate.jar файл,
# а Java уже существует внутри контейнера)
context: .
depends_on:
- api # Если API не будет запущен, тесты упадут, поэтому ждём запуска API
command: ["/karate"] # Запускаем тесты из папки /karate
volumes:
- .:/karate # Монтируем папку с тестами в папку /karate
environment:
API_URL: 'http://api'
KarateDockerfile
FROM openjdk:11-jre-slim
RUN apt-get update && apt-get install -y curl
RUN apt-get install -y unzip
RUN curl -o /karate.jar -L 'https://github.com/intuit/karate/releases/download/v1.3.0/karate-1.3.0.jar'
ENTRYPOINT ["java", "-jar", "/karate.jar"]
Два файла Docker Compose
Выглядит неплохо и удобно, но не всей команде нужны все контейнеры. Разработчику, например, нужна только база данных, так как API он запускает у себя в IDE.
Решили разделить Docker Compose файл на два: docker-compose.yml
— остался без изменений и docker-compose-db.yml
— содержащий только контейнер с базой данных:
docker-compose-db.yml
version: '3.8'
services:
db:
image: postgres:13
restart: always
container_name: 'db'
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 11237:5432
Запуск тестов в пайплайне
Удобная конфигурация готова, а значит можно запустить тесты в пайплайне на создание мерж-реквеста в основную ветку. Для этих целей мы использовали файл docker-compose.yml
, так как в нём есть всё необходимое для запуска тестов.
Написали пайплайн для GitHub, в котором запустили все контейнеры, в том числе и с самими тестами.
run: |
LOGS=$(docker-compose --file docker-compose.yml up --abort-on-container-exit) # запишем все логи в переменную,
# чтобы в дальнейшем их разобрать и проверить,
# прошли тесты или нет
# проверим, что нет упавших тестов
if [ "$FAILED" -gt 0 ]; then
echo "Failed tests found! Failing the pipeline..."
exit 1
fi
# проверим, что тесты в целом прошли, чтобы избежать ложного успешного завершения пайплайна
if [ "$PASSED" -eq 0 ]; then
echo "No tests passed! Failing the pipeline..."
exit 1
fi
Получилось немного костыльно, но пока не нашли лучшего способа обрушить пайплайн при упавших Karate-тестах.
Фактически, мы можем использовать два файла при сборке контейнеров, указывая их через флаг --file:
docker-compose --file docker-compose.yml --file docker-compose-db.yml up
Но это решение не самая лучшая идея. Между файлами можно запутаться, и придется каждый раз проверять, что один и тот же сервис не присутствует в нескольких файлах сразу. А ещё файлов будет много — столько же, сколько конфигураций запуска. Мы определили как минимум:
local-environment — запускается база данных и API;
db-only — запускается только база данных для взаимодействия с ней сервиса, запущенного в IDE;
e2e-local-environment — запускаются API, база данных и контейнер с Karate-тестами, которые тестируют API, запущенный в Docker;
e2e-production-environment — мы сторонники TDD (Test-Driven Development) и тестирования в проде. А потому хотим запускать тесты не только в фича-ветке до мержа в основную ветку, но и после деплоя в прод. Эта конфигурация запускает только karate_tests контейнер, который нацелен на прод.
Профили
Порылись в документации Docker Compose и обнаружили удобный инструмент — профили. Эта фича поможет разделить сервисы так, как это нужно, и при этом все они будут в одном файле. Тогда для запуска нужных сервисов понадобится указать в команде docker-compose up аргумент --profile с нужным именем профиля.
docker-compose --profile db-only up
Мы вновь объединили все контейнеры в один файл docker-compose.yml
и распределили между ними профили, чтобы выбирать для запуска только те контейнеры, которые нужны сейчас.
version: '3.8'
services:
db:
image: postgres:13
container_name: 'db'
profiles: ['db-only', 'e2e-local-environment', 'local-environment'] # только при запуске с этими профилями сервис будет запущен
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
api:
container_name: 'api'
profiles: ['e2e-local-environment', 'local-environment']
build:
context: .
dockerfile: Api/Dockerfile
ports:
- 5000:5000
depends_on:
- db
karate_tests:
container_name: 'karate_tests'
profiles: ['e2e-local-environment']
build:
dockerfile: KarateDockerfile
context: .
depends_on:
- api
command: ['/karate']
volumes:
- .:/karate
environment:
API_URL: 'http://api'
Шаблоны
Теперь нам нужно запускать те же самые E2E-тесты, но уже по задеплоенному сервису. Для этого нужно заменить адрес в API_URL на адрес API в проде.
Мы создали второй karate_tests сервис, где переменная окружения API_URL имеет уже другое значение.
version: '3.8'
services:
# остальные сервисы (API, database)
local_karate_tests:
container_name: 'local_karate_tests'
profiles: ['e2e-local-environment']
build:
dockerfile: KarateDockerfile
context: .
depends_on:
- api
command: ['/karate']
volumes:
- .:/karate
environment:
API_URL: 'http://api'
production_karate_tests:
container_name: 'production_karate_tests'
profiles: ['e2e-production-environment']
build:
dockerfile: KarateDockerfile
context: .
depends_on:
- api
command: ['/karate']
volumes:
- .:/karate
environment:
API_URL: 'https://my-deployed-service.com'
Но получается так, что эти два сервиса, запускающие Karate-тесты, практически полностью дублируют друг друга. У них меняется только API_URL.
Эту проблему мы решили используя фичу Docker Compose — extends блок, который позволяет сервису наследоваться от какого-то другого сервиса и переопределить лишь часть конфигурации, которая отличается между наследуемым сервисом и наследником.
Мы создали шаблон base_karate_tests в файле docker-compose.yml
. Он содержит в себе те данные, которые у контейнеров не меняются: KarateDockerfile, из которого собирается образ, команда для запуска и volume.
Теперь применим этот шаблон к сервисам с помощью блока extends таким образом:
version: '3.8'
services:
# остальные сервисы (API, database)
base_karate_tests:
build:
dockerfile: KarateDockerfile
context: .
command: ['/karate']
volumes:
- .:/karate
local_karate_tests:
container_name: 'local_karate_tests'
profiles: ['e2e-local-environment']
extends:
service: base_karate_tests
environment:
API_URL: 'http://api'
production_karate_tests:
container_name: 'production_karate_tests'
profiles: ['e2e-local-environment']
extends:
service: base_karate_tests
environment:
API_URL: 'https://my-deployed-service.com'
Один шаблон не занимает много места. Однако, если у нас появится система, в которой шаблонов и наследуемых сервисов будет больше, сам шаблон можно вынести в отдельный файл и ссылаться на него. Для этого создадим файл templates.yml
.
version: '3.8'
services:
base_karate_tests:
build:
dockerfile: KarateDockerfile
context: .
command: ['karate', '/karate']
volumes:
- .:/karate
Опишем здесь все нужные шаблоны, а в docker-compose.yml
будем использовать параметр file, помимо service, чтобы применить шаблон, но уже из другого файла.
extends:
file: templates.yml
service: base_karate_tests
Для маленькой системы в этом нет необходимости, но для более сложной автоматизации может быть самое то.
Итоги
Используя Docker Compose мы смогли успешно и с удовольствием запустить E2E-тесты API и в пайплайне мерж-реквеста перед закрытием фичи и после деплоя этой фичи в прод.
При запуске в Docker Compose мы прошли следующий путь эволюции решения:
Отдельные файлы для запуска конкретных сервисов, где манифест дублируется;
Привязка сервисов к профилям, в которых они должны запускаться;
Вынесение дублирующегося кода сервисов в шаблоны.
Итого имеем компактный Docker Compose файл, из которого мы можем запустить только нужные сервисы одной командой и всего лишь с одним аргументом профиля. Также это решение нормально расширяется при добавлении новых режимов запуска.
Если вы сталкивались с подобными проблемами, расскажите нам, как вы их решили?
Авторы: Колесникова Анна, Шинкарев Александр
Вычитка и фидбек: Ядрышникова Мария, Черных Виктор, Сипатов Максим, Магденко Юлия
Оформление: Маргарита Шур