Клёвые фичи в 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, база данных и контейнер с тестами:

fd72rhkzmu6ar5lpbx_h_qa_iwc.jpeg

Для запуска всех сервисов написали 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 файл, из которого мы можем запустить только нужные сервисы одной командой и всего лишь с одним аргументом профиля. Также это решение нормально расширяется при добавлении новых режимов запуска.

Если вы сталкивались с подобными проблемами, расскажите нам, как вы их решили?

Авторы: Колесникова Анна, Шинкарев Александр
Вычитка и фидбек: Ядрышникова Мария, Черных Виктор, Сипатов Максим, Магденко Юлия
Оформление: Маргарита Шур

© Habrahabr.ru