[Из песочницы] xenvman: Гибкие окружения для тестирования микросервисов (и не только)

Всем привет!

Я бы хотел немного рассказать о проекте, над которым я работал последние полгода. Проект я делаю в свободное время, но мотивация к его созданию пришла из наблюдений, сделанных на основной работе.

На рабочем проекте мы используем архитектуру микросервисов, и одна из главных проблем, которая проявилась со временем и выросшим количеством этих самых сервисов — это тестирование. Когда некий сервис зависит от пяти-семи других сервисов, плюс ещё какая-нибудь база данных (а то и несколько) в придачу, то тестировать это в «живом», так сказать виде, весьма неудобно. Приходится обкладываться моками со всех сторон так плотно, что самого теста и не разглядеть. Ну или каким-то образом организовывать тестовое окружение, где все зависимости могли бы реально быть запущены.

Собственно для облегчения второго варианта я как раз и сел писать xenvman. Если совсем в двух словах, то это что-то вроде гибрида docker-compose и test containers, только без привязки к Java (или любому другому языку) и с возможностью динамически создавать и конфигурировать окружения через HTTP API.

xenvman написан на Go и реализован как простой HTTP сервер, что позволяет пользоваться всей доступной функциональностью из любого языка, умеющего разговаривать на этом протоколе.

Основное, что xenvman умеет, это:


  • Гибко описывать содержимое окружения с помощью простых скриптов на JavaScript
  • Создавать образы «на лету»
  • Создавать нужное количество контейнеров и объединять их в единую изолированную сеть
  • Пробрасывать внутренние порты окружения наружу, дабы тесты могли достучаться до нужных сервисов даже с других хостов
  • Динамически менять состав окружения (останавливать, запускать и добавлять новые контейнеры) на ходу, без остановки работающего окружения.


Окружения

Главным действующим лицом в xenvman является окружение (environment). Это такой-себе изолированный пузырь, в котором запускаются все необходимые зависимости (упакованные в Docker контейнеры) вашего сервиса.

z5otjuqar0vsscxnoqpawyf6vxe.png

На рисунке выше, показан xenvman сервер и активные окружения, в которых запущены разные сервисы и базы данных. Каждое окружение было создано прямо из кода интеграционных тестов, и будет удалено по их завершению.


Шаблоны

Что непосредственно входит в состав окружения, определяется шаблонами (templates), которые представляют собой небольшие скрипты на JS. xenvman имеет встроенный интерпретатор этого языка, и при получении запроса на создание нового окружения, он просто выполняет указанные шаблоны, каждый из которых добавляет один или более контейнеров в список на выполнение.

JavaScript был выбран для того, чтобы позволить динамически менять/добавлять шаблоны без необходимости пересборки сервера. Кроме того, в шаблонах как правило используются только базовые возможности и типы данных языка (старый добрый ES5, никакого DOM, React и прочей магии), поэтому работа с шаблонами не должна вызвать особых трудностей даже у тех, кто совсем на знает JS.

Шаблоны параметризуемы, то есть мы можем полностью контролировать логику шаблона путём передачи тех или иных параметров в нашем HTTP запросе.


Создание образов «на лету»

Одна из наиболее удобных возможностей xenvman, на мой взгляд, это создание Docker образов прямо по ходу конфигурирования окружения. Зачем это может быть нужно?
Ну вот например у нас на проекте, чтобы получить образ сервиса, нужно закомитить изменения в отдельную ветку, запушить и подождать пока Gitlab CI соберет и зальёт образ.
Если изменился только один сервис, то занять это может 3–5 минут.

А если мы активно пилим новые фичи в наш сервис, или же пытаемся понять почему он не работает, добавляя старый добрый fmt.Printf туда-сюда, или ещё как-нибудь часто изменяя код, то даже задержка в 5 минут будет здорово гасить производительность (нашу, как писателей кода). Вместо этого, мы можем просто добавить всю необходимую отладку в код, скомпилировать его локально, и потом просто приложить готовый бинарь в HTTP запрос.

Получив такое добро, шаблон возьмёт этот бинарь и прямо на ходу создаст временный образ, из которого мы уже сможем запустить контейнер как ни в чем не бывало.

На нашем проекте, в основном шаблоне для сервисов, например, мы проверяем присутствует ли бинарь в параметрах, и если да, то собираем образ на ходу, иначе просто скачиваем latest версию dev ветки. Дальнейший код для создания контейнеров идентичен для обоих вариантов.


Небольшой пример

Для наглядности, давайте рассмотрим микро-примерчик.

Скажем, пишем мы какой-то чудо-сервер (назовём-ка его — wut), которому нужна база данных, чтобы всё там хранить. Ну и в качестве базы, выбрали мы MongoDB. Стало быть для полноценного тестирования нам нужен работающий сервер Mongo. Можно, конечно, установить и запустить его локально, но для простоты и наглядности примера мы предположим, что по какой-то причине это сделать сложно (при других, более сложных конфигурациях в реальных системах это будет больше похоже на правду).

Значит мы попробуем использовать xenvman для того, чтобы создать окружение с запущенным Mongo и нашим wut сервером.

Первым делом нам надо создать базовый каталог, в котором будут храниться все шаблоны:

$ mkdir xenv-templates && cd xenv-templates

Дальше создадим два шаблона, один для Mongo, другой для нашего сервера:

$ touch mongo.tpl.js wut.tpl.js


mongo.tpl.js

Откроем mongo.tpl.js и запишем туда следующее:

function execute(tpl, params) {
  var img = tpl.FetchImage(fmt("mongo:%s", params.tag));
  var cont = img.NewContainer("mongo");

  cont.SetLabel("mongo", "true");
  cont.SetPorts(27017);

  cont.AddReadinessCheck("net", {
    "protocol": "tcp",
    "address": '{{.ExternalAddress}}:{{.Self.ExposedPort 27017}}'
  });
}

В шаблоне должна присутствовать функция execute () с двумя параметрами.
Первый — это экземпляр tpl объекта, через который происходит конфигурация окружения. Второй аргумент (params) это просто JSON объект, с помощью которого мы будем параметризовать наш шаблон.

В строке

var img = tpl.FetchImage(fmt("mongo:%s", params.tag));

мы просим xenvman скачать docker образ mongo:, где это версия образа, который мы хотим использовать. В принципе, эта строка эквивалентна команде docker pull mongo:, с той лишь разницей, что все функции tpl объекта по-сути декларативны, то есть реально образ будет скачан только после того как xenvman выполнит все шаблоны, указанные в конфигурации окружения.

После того, как у нас есть образ, мы можем создать контейнер:

var cont = img.NewContainer("mongo");

Опять-таки, контейнер моментально не будет создан в этом месте, мы просто декларируем намерение создать его, так сказать.

Далее мы вешаем ярлык на наш контейнер:

cont.SetLabel("mongo", "true");

Ярлыки используются для того, чтобы контейнеры могли находить друг-друга в окружении, например чтобы вписать IP адрес или имя хоста в конфигурационный файл.

Теперь нам нужно вывесить внутренний порт Mongo (27017) наружу. Это легко делается так:

 cont.SetPorts(27017);

Перед тем, как xenvman отрапортует нам об успешном создании окружения, было бы здорово убедиться, что все сервисы не просто запущены, а уже и готовы принимать запросы. В xenvman для этого имеются проверки готовности.
Добавим одно такую для нашего mongo контейнера:

 cont.AddReadinessCheck("net", {
    "protocol": "tcp",
    "address": '{{.ExternalAddress}}:{{.Self.ExposedPort 27017}}'
  });

Как мы видим, здесь в строке адреса имеются заглушки, в которые будут динамически подставлены нужные значения прямо перед запуском контейнеров.

Вместо {{.ExternalAddress}} будет подставлен внешний адрес хоста, на котором запущен xenvman, а вместо {{.Self.ExposedPort 27017}} будет подставлен внешний порт, который был проброшен на внутренний 27017.

Подробнее об интерполяции можно почитать здесь.

В итоге всего этого, мы сможем подключаться к Mongo, запущенному в окружении, прямо снаружи, например с хоста, на котором мы запускаем наш тест.


wut.tpl.js

Так-c, разобравшись с монгой, напишем ещё шаблончик для нашего wut сервера.
Так как мы хотим собирать образ на ходу, шаблон будет немного отличаться:

function execute(tpl, params) {
  var img = tpl.BuildImage("wut-image");
  img.CopyDataToWorkspace("Dockerfile");

  // Extract server binary
  var bin = type.FromBase64("binary", params.binary);
  img.AddFileToWorkspace("wut", bin, 0755);

  // Create container
  var cont = img.NewContainer("wut");
  cont.MountData("config.toml", "/config.toml", {"interpolate": true});

  cont.SetPorts(params.port);

  cont.AddReadinessCheck("http", {
    "url": fmt('http://{{.ExternalAddress}}:{{.Self.ExposedPort %v}}/', params.port),
    "codes": [200]
  });
}

Так как здесь мы собираем образ, то мы используем BuildImage() вместо FetchImage():

  var img = tpl.BuildImage("wut-image");

Для того, чтобы собрать образ, нам будут нужны несколько файлов:
Dockerfile — собственно инструкция как собирать образ
config.toml — конфигурационный файл для нашего wut сервера

С помощью метода img.CopyDataToWorkspace("Dockerfile"); мы копируем Dockerfile из каталога данных шаблона во временный рабочий каталог.

Каталог данных — это каталог, в котором мы можем хранить все файлы, необходимые нашему шаблону в работе.

Во временный рабочий каталог мы копируем файлы (с помощью img.CopyDataToWorkspace ()), которые попадут в образ.

Далее следует вот такое:

  // Extract server binary
  var bin = type.FromBase64("binary", params.binary);
  img.AddFileToWorkspace("wut", bin, 0755);

Мы передаём бинарь нашего сервера прямо в параметрах, в закодированном (base64) виде. А в шаблоне мы его просто раскодируем, и получившуюся строку сохраняем в рабочий каталог в виде файла под именем wut.

Потом создаем контейнер и монтируем в него конфигурационный файл:

 var cont = img.NewContainer("wut");
 cont.MountData("config.toml", "/config.toml", {"interpolate": true});

Вызов MountData() означает, что файл config.toml из каталога данных будет смонтирован внутрь контейнера под именем /config.toml. Флаг interpolate указывает xenvman серверу, что перед монтированием в файле следует заменить все имеющиеся там заглушки.

Вот как может выглядеть конфиг:

{{with .ContainerWithLabel "mongo" "" -}}
mongo = "{{.Hostname}}/wut"
{{- end}}

Тут мы ищем контейнер с ярлыком mongo, и подставляем имя его хоста, какое бы оно не было в данном окружении.

После подстановки, файл может выглядеть как:

mongo = "mongo.0.mongo.xenv/wut”

Далее мы опять вывешиваем порт и заводим проверку готовности, на этот раз HTTP:

cont.SetPorts(params.port);

 cont.AddReadinessCheck("http", {
    "url": fmt('http://{{.ExternalAddress}}:{{.Self.ExposedPort %v}}/', params.port),
    "codes": [200]
  });

На этом наши шаблоны готовы, и мы можем использовать их в коде интеграционных тестов:

import "github.com/syhpoon/xenvman/pkg/client"
import "github.com/syhpoon/xenvman/pkg/def"

// Создаём xenvman клиент
cl := client.New(client.Params{})

// Требуем создать для нас окружение
env := cl.MustCreateEnv(&def.InputEnv{
    Name:        "wut-test",
    Description: "Testing Wut",
    // Указываем, какие шаблоны добавить в окружение
    Templates: []*def.Tpl{
        {
            Tpl: "wut",
            Parameters: def.TplParams{
                "binary": client.FileToBase64("wut"),
                "port":   5555,
            },
        },
        {
            Tpl: "mongo",
            Parameters: def.TplParams{"tag": "latest”},
         },
    },
})

// Завершить окружение после окончания теста
defer env.Terminate()

// Получаем данные по нашему wut контейнеру
wutCont, err := env.GetContainer("wut", 0, "wut")
require.Nil(t, err)

// Тоже самое для монго контейнера
mongoCont, err := env.GetContainer("mongo", 0, "mongo")
require.Nil(t, err)

// Теперь формируем адреса
wutUrl := fmt.Sprintf("http://%s:%d/v1/wut/", env.ExternalAddress, wutCont.Ports["5555”])
mongoUrl := fmt.Sprintf("%s:%d/wut", env.ExternalAddress, mongoCont.Ports["27017"])

// Всё! Теперь мы можем использовать эти адреса, что подключиться к данным сервисам из нашего теста и делать с ними, что захочется

Может показаться, что написание шаблонов будем занимать слишком много времени.
Однако при правильном дизайне, это одноразовая задача, а потом те же самые шаблоны можно переиспользовать ещё и ещё (и даже для разных языков!) просто тонко настраивая их путём передачи тех или иных параметров. Как видно в примере выше, непосредственно код теста очень простой, из-за того, что всю шелуху по настройке окружения мы вынесли в шаблоны.

В этом небольшом примере показаны далеко не все возможности xenvman, более подробное пошаговое руководство доступно здесь (на англ.)


Клиенты

На данный момент имеются клиенты для двух языков:

Go
Python

Но добавить новые не составит труда, так как предоставляемый API очень и весьма простой.


Веб интерфейс

В версии 2.0.0 был добавлен простенький веб интерфейс, с помощью которого можно управлять окружениями и просматривать доступные шаблоны.

mhcpbw3wxz9rlsyk0nsonsid8_8.png
7w_wwvi6bq_tsr81bxafvb9vzo4.png
cwyrwiotyxbnrewwvp0gujta0j0.png


Чем xenvman отличается от docker-compose?

Конечно схожего много, но xenvman мне представляется немного более гибким и динамичным подходом, в сравнении со статической конфигурацией в файле.
Вот главные отличительные особенности, на мой взгляд:


  • Абсолютно всё управление осуществляется через HTTP API, посему мы можем создавать окружения из кода любого языка, понимающего HTTP
  • Так как xenvman может быть запущен на другом хосте, мы можем использовать все его возможности даже с хоста, на котором не установлен docker.
  • Возможность динамического создание образов на лету
  • Возможность изменения состава окружения (добавление/останов контейнеров) в процессе его работы
  • Уменьшение boilerplate code, улучшение композиции и возможность переиспользования конфигурационного кода за счет использования параметризуемых шаблонов


Ссылки

Github страничка проекта
Подробный пошаговый пример, на англ.


Заключение

Вот, собственно и все. В ближайшее время я планирую добавить возможность
вызывать шаблоны из шаблонов и тем самым позволить комбинировать их с большей эффективностью.

Постараюсь ответить на любые вопросы, и буду рад, если кому-нибудь ещё этот проект окажется полезным.

© Habrahabr.ru