Управление контейнерами из Kotlin-тестов

ikmsrfipfryagjwfz0qputumelc.jpeg

Нередко для выполнения тестов требуется запуск вспомогательных сервисов (баз данных, брокеров очередей и др.) и стандартной практикой в подходах DevOps является запуск тестов внутри управляемого окружения, где сначала создается контейнер с JVM, после чего внутри конвейера CI/CD запускаются вспомогательные контейнеры и сборка Kotlin-приложения с точкой входа для запуска тестов. Однако есть и альтернативное решение — запускать вспомогательные тестовые контейнеры непосредственно внутри JUnit-теста. В этой статье мы обсудим несколько различных подходов к управлению контейнерами из тестов для Kotlin-приложений.

Принципы работы контейнеризации

Для начала нужно разобраться в принципах работы сервера управления контейнерами. Сейчас существует несколько API:

  • Docker Engine API (документация в OpenAPI-формате) — используется в Docker, основана на REST.

  • ContainerD runtime implementation API (CRI API) — используется в свободной реализации containerd и совместимых с ней (runc, crio), на grpc.

Контейнер, с точки зрения linux-ядра, является процессом, запущенным в собственном наборе cgroups (определяют ограничения на ресурсы) и namespaces (определяют видимость процессов, учетных записей пользователей, сетевых интерфейсов и файловой системой). Файловая система для контейнеров представляется в виде одно или многослойной readonly-части (созданной на этапе сборке образа) и readwrite-слоя, который сохраняет изменения, сделанные во время выполнения процессов в контейнере. Во всем остальном процесс, запущенный в контейнере, ничем не отличается от процесса, запущенного от имени локального пользователя.

API сервера управления контейнеризацией предоставляет возможности для управления следующими типами ресурсов:

  • образы (image): сборка с помощью llb-frontend (например из Dockerfile), загрузка из Container Registry, получение информации, удаление;

  • контейнеры (container): запуск экземпляра контейнера на основе образа (с возможной установкой ограничений по cpu/mem/net/io и подключением дополнительных устройств), подключение к tty работающего контейнера, выполнение команд внутри контейнера, получение информации о контейнере, удаление;

  • сети (network) — управление виртуальными сетями для объединения нескольких контейнеров в едином адресном пространстве (создание и удаление сети, присоединение и отсоединение контейнера от сети);

  • именованные области хранения (volumes) — локальные или сетевые хранилища для монтирования к создаваемым контейнерам;

  • конфигурации (config) и секреты (secret) — используются для настройки запущенных контейнеров (конфигурация присоединяется как обычные файлы, секреты могут добавляться в переменные окружения);

  • события (events) — просмотр потока событий от сервера (может использоваться для обнаружения ошибок при выполнении операций)

Для доступа к API чаще всего используется socket-файл (/run/docker.sock для Docker, /run/containerd.sock для ContainerD), либо публикация на порт 2375.

Использование Docker в Kotlin

Одним из самых простых решений, которое подключается к API, совместимому с Docker, является библиотека Dokker. Dokker предоставляет удобный DSL-синтаксис для запуска контейнеров и управления ими непосредственно из теста. Для примера рассмотрим случай теста, для которого требуется наличие тестовой базы данных PostgreSQL.
Добавим зависимость от dokker в testImplementation (в проекте используется gradle.kts):

testImplementation("io.github.corbym:dokker:0.2.0")

Теперь в тесте (например, в статическом методе с аннотацией @BeforeAll) можно добавить подготовку объекта управления контейнером:

	companion object {
        lateinit var docker: DokkerContainer
        @JvmStatic
        @BeforeAll
        fun prepareDatabase() {
            docker = dokker {
                name("database")
                image("postgres")
                env("POSTGRES_PASSWORD" to "password")
                publish("5432" to "5432")
            }
            docker.start()
        }
        @JvmStatic
        @AfterAll
        fun closeDatabase() {
            docker.stop()
        }
    }

Конфигурация запуска позволяет определить название образа, на основе которого создается контейнер, переменные окружения, привязку к сети (по названию), название контейнера (name) и сетевое имя (hostname, если не указано, то сетевое имя в сети будет таким же как name, и его можно использовать для подключения из других контейнеров), ограничения по памяти (memory), пользователь по умолчанию (user), переопределение команды при запуске (command). Для отслеживания доступности можно определить правила healthcheck (например для проверки успешного ответа по http/https или по открытому tcp-порту) или onStartup (вызывается сразу после запуска контейнера, внутри можно обратиться к созданному контейнеру через метод executeHealthCheck).
Для объединения нескольких контейнеров в общую сеть можно создать объект DokkerNetwork с названием сети и запустить его, чтобы в дальнейшем ссылаться на сеть в создаваемых контейнерах (атрибут network в инициализаторе dokker).

Для JUnit5 можно использовать механизм расширений, для этого создадим расширение dokkerExtension с пометкой аннотацией @RegisterExtension. Внутри dokkerExtension уже можно создавать блок container → dokker для конфигурирования контейнеров. Основное преимущество такого решения в том, что контейнер будет автоматически удаляться при завершении теста:

  companion object {
    @JvmStatic
    @RegisterExtension
    val server: DokkerExtension = dokkerExtension {
      container {
	      dokker {
	        name("database")
          image("postgres")
          env("POSTGRES_PASSWORD" to "password")
          publish("5432" to "5432")
        }
      }
    }
  }

Dokker хорошо подходит для ситуаций, когда нужно запустить вспомогательные контейнеры с простой конфигурацией (память, публикация портов, переменные окружения). Однако с помощью него нельзя подключить внешнее хранилище и дополнительные файлы (например, файлы конфигурации), собрать образ из исходных текстов, а также сложно получить доступ к информации об ошибках. Частично эта проблема может быть решена через функцию расширения runCommand, применяемой к любой строке и запускаемой на хост-машине (так, например, можно создать именованное хранилище: `«docker create volume database».runCommand ()).

Gradle Docker plugin

Альтернативно можно использовать клиентскую библиотеку для Docker API и плагин для gradle. Для подключения плагина используется артефакт de.gesellix:docker-client:2024-01-16T21-45-00 (доступен на mavenCentral) с установкой через id("de.gesellix.docker") version "2024-01-17T08-50-00".
Плагин добавляет новый dsl-блок в gradle.kts для конфигурации подключения к Docker API и авторизации в Docker Registry:

docker {
    dockerHost = "unix:///run/docker.sock"
    authConfig = AuthConfig().apply {
      username = "username"
      password = "password"
      email = "user@domain.com"
      serveraddress = "https://index.docker.io/v1/"
    }
  }

Плагин публикует для использования большое количество задач (из пакета de.gesellix.gradle.docker.tasks) для управления образами, контейнерами, хранилищами, сетями и т.д. Например, для инициализации контейнера PostgreSQL (аналогично примеру, приведенному выше) можно зарегистрировать задачу с типом DockerRunTask:

tasks {
  val postgresql = register("runTestDatabase") {
    imageName.set("postgres")
    containerName.set("database")
    ports.set(listOf("5432:5432"))
    env.set(listOf("POSTGRES_PASSWORD=password"))
  }
}

Плагин позволяет не только управлять контейнерами (задачи DockerRunTask, DockerRmTask), но также выполнять сборку с использованием Dockerfile и отправлять изменения в реестр (DockerBuildTask, DockerPushTask), взаимодействовать с запущенными контейнерами (DockerExecTask), создавать сети (DockerNetwork*) и именованные разделы (DockerVolume*).
Также можно использовать реализацию Docker-клиента, который также может использоваться для развертывания стека с использованием плагина Docker Compose.

dependencies {
  implementation("de.gesellix:docker-client:2024-02-10T12-30-00-GROOVY-4")
}

Для подключения к Docker создается объект класса DockerClientImpl(socketPath), из которого можно выполнить следующие действия:

  • info() — информация о сервере

  • run(image, config, tag) — создание контейнера из образа

  • logs(name, config, callback) — извлечение логов из работающего контейнера

  • exec(name, command, callback, timeout) — запуск команды внутри контейнера

  • kill(name) — остановка и удаление контейнера

  • ps() — список активных контейнеров

  • stats(container, stream, callback, timeout) — получить обновляемую статистику о работе контейнера

  • stop(name) — остановить контейнер

  • start(name) — запустить ранее остановленный контейнер

  • wait(name) — дождаться завершения операции над контейнером Также поддерживаются операции с сущностями swarm (инициализация кластера, управление узлами и сервисами), конфигурациями и секретами, управление именованными хранилищами (volume).

Кроме перечисленных выше библиотек можно использовать java-библиотеку TestContainers, которая позволяет создавать варианты тестов с использованием контейнеров (тест помечается аннотацией @Testcontainers, поля с определением контейнеров — аннотацией @Container). TestContainers через модули поддерживает преднастроенные классы для запуска часто используемых контейнеров (например, PostgreSQLContainer), а также позволяет запускать набор контейнеров из Docker compose (с классом ComposeContainer) или одиночный контейнер (класс GenericContainer, также поддерживается создание контейнера из Dockerfile с передачей в GenericContainer объекта ImageFromDockerfile).

Статья подготовлена в преддверии старта курса Kotlin Backend Developer. Professional. На странице курса все желающие могут зарегистрироваться на бесплатный урок.

© Habrahabr.ru