Последние пять лет принесли в нашу жизнь огромное количество технологий, с помощью которых можно быстро создавать изолированные окружения для разработки и тестирования. Но не смотря на это, организовать стабильное окружение для тестирования — далеко не самая простая задача. А если нужно тестировать сетевые взаимодействия компонентов и анализировать предельный уровень нагрузки на них, то задача становится еще сложнее. Добавив возможность быстрого развертывания окружения и гибкой настройки отдельных компонентов, мы сможем получить небольшой интересный проект.
В этой статье мы подробно расскажем о создании окружения на базе Docker контейнеров для тестирования нашего клиент-серверного приложения. При этом, если смотреть глобально, то данная статья будет хорошей иллюстрацией использования Docker и его ближайшей экосистемы.

Постановка задачи


Наше приложение собирает, анализирует и хранит все возможные типы лог-файлов. Основная задача окружения — это провести первичное тестирование сервиса под нагрузкой.
Итак, что мы имеем:
  • Наш сервис, написан на Go и имеет клиент-серверную архитектуру.
  • Сервис умеет параллельно записывать данные в хранилища различного типа. Этот момент очень важен при построении окружения для тестирования.
  • Разработчикам нужна возможность быстро и безболезненно воспроизводить найденные неисправности на тестовом окружении.
  • Мы должны протестировать сетевое взаимодействие компонентов в распределенной среде на нескольких сетевых узлах. Для этого нужно проанализировать ход трафика между клиентами и серверами.
  • Нам необходимо проконтролировать потребление ресурсов и удостовериться в стабильной работе демона при высокой нагрузке.
  • Ну и, конечно, нам хочется посмотреть на все возможные метрики в реальном времени и по результатам тестирования.

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

Архитектура окружения для тестирования


Для начала рассмотрим основные компоненты архитектуры:
  • Произвольное количество серверных экземпляров нашего приложения.
  • Произвольное количество агентов.
  • Отдельные окружения с хранилищами данных, такими как ElasticSearch, MySQL или PostgreSQL.
  • Генератор нагрузки (мы реализовали простой стресс-генератор, но можно использовать любой другой, например, Яндекс.Танк или Apache Benchmark).

Окружение для тестирования должно легко подниматься и обслуживаться.

Распределенную сетевую среду мы построили при помощи Docker контейнеров, изолирующих в себе наши и внешние сервисы, и docker-machine, которая позволяет организовать изолированное пространство для тестирования. В результате архитектура тестового окружения выглядит так:



Для визуализации окружения мы используем Weave Scope, так как это очень удобный и наглядный сервис для мониторинга Docker контейнеров.



С данным подходом удобно тестировать взаимодействие SOA компонентов, например, небольшие клиент-серверные приложения, подобные нашему.

Реализация базового окружения


Далее подробно рассмотрим каждый шаг создания тестового окружения на базе Docker контейнеров, с использованием docker-compose и docker-machine.
Начнем с docker-machine, которая позволит нам безболезненно выделить тестовое виртуальное окружение. При этом нам будет очень удобно работать с этим окружением напрямую с хост-системы.
Итак, создаем тестовую машину:

$ docker-machine create -d virtualbox testenv
Creating VirtualBox VM...
Creating SSH key...
Starting VirtualBox VM...
Starting VM...
To see how to connect Docker to this machine, run: docker-machine env testenv

Эта команда создает VirtualBox VM с установленными внутри нее CoreOS и Docker, готовым к работе (Если вы используете Windows или MacOS, то рекомендуется установить Docker Toolbox, в котором уже все заложено. А если вы используете Linux, то необходимо поставить docker, docker-machine, docker-compose и VirtualBox самостоятельно). Мы рекомендуем ознакомиться со всеми возможностями docker-machine, это довольно мощный инструмент для управления окружениями.

Как видно из вывода этой команды, docker-machine создает все необходимое для работы с виртуальной машиной. После создания, виртуальная машина запущена и готова к работе. Давайте проверим:

$ docker-machine ls
NAME      ACTIVE   DRIVER       STATE     URL                         SWARM
testenv            virtualbox   Running   tcp://192.168.99.101:2376

Прекрасно, виртуальная машина запущена. Надо активировать доступ к ней в нашей текущей сессии. Вернемся на предыдущий шаг и внимательно посмотрим на последнюю строку:

To see how to connect Docker to this machine, run: docker-machine env testenv

Это autosetup для нашей сессии. Выполнив эту команду мы увидим следующее:

$ docker-machine env testenv
export DOCKER_TLS_VERIFY="1"
export DOCKER_HOST="tcp://192.168.99.101:2376"
export DOCKER_CERT_PATH="/Users/logpacker/.docker/machine/machines/testenv"
export DOCKER_MACHINE_NAME="testenv"
# Run this command to configure your shell:
# eval "$(docker-machine env testenv)"

Это просто набор переменных окружения, который сообщит вашему локальному docker-клиенту где искать сервер. В последней строке расположена подсказка. Выполним эту команду и посмотрим на вывод команды ls:

$ eval "$(docker-machine env testenv)"
$ docker-machine ls
NAME      ACTIVE   DRIVER       STATE     URL                         SWARM
testenv   *        virtualbox   Running   tcp://192.168.99.101:2376

В столбце ACTIVE наша активная машина помечена звездочкой. Обратите внимание, машина активна в рамках только текущей сессии. Мы можем открыть еще одно окно терминала и активировать там другую машину. Это может быть удобно для тестирования, например, оркестрации при помощи Swarm. Но это тема для отдельной статьи :).
Далее проверим наш docker-сервер:

$ docker info
docker version
Client:
 Version:      1.8.0
 API version:  1.20
 Go version:   go1.4.2
 Git commit:   0d03096
 Built:        Tue Aug 11 17:17:40 UTC 2015
 OS/Arch:      darwin/amd64

Server:
 Version:      1.9.1
 API version:  1.21
 Go version:   go1.4.3
 Git commit:   a34a1d5
 Built:        Fri Nov 20 17:56:04 UTC 2015
 OS/Arch:      linux/amd64

Акцентируем внимание на OS/Arch, там всегда будет linux/amd64, так как docker-сервер работает в VM, нужно не забывать об этом. Немного отвлечемся и заглянем внутрь VM:

$ docker-machine ssh testenv
                        ##         .
                  ## ## ##        ==
               ## ## ## ## ##    ===
           /"""""""""""""""""\___/ ===
      ~~~ {~~ ~~~~ ~~~ ~~~~ ~~~ ~ /  ===- ~~~
           \______ o           __/
             \    \         __/
              \____\_______/
 _                 _   ____     _            _
| |__   ___   ___ | |_|___ \ __| | ___   ___| | _____ _ __
| '_ \ / _ \ / _ \| __| __) / _` |/ _ \ / __| |/ / _ \ '__|
| |_) | (_) | (_) | |_ / __/ (_| | (_) | (__|   <  __/ |
|_.__/ \___/ \___/ \__|_____\__,_|\___/ \___|_|\_\___|_|
Boot2Docker version 1.9.1, build master : cef800b - Fri Nov 20 19:33:59 UTC 2015
Docker version 1.9.1, build a34a1d5
docker@testenv:~$

Да, это boot2docker, но интересно не это. Посмотрим на смонтированные разделы:

docker@testenv:~$ mount
tmpfs on / type tmpfs (rw,relatime,size=918088k)
proc on /proc type proc (rw,relatime)
sysfs on /sys type sysfs (rw,relatime)
devpts on /dev/pts type devpts (rw,relatime,mode=600,ptmxmode=000)
tmpfs on /dev/shm type tmpfs (rw,relatime)
fusectl on /sys/fs/fuse/connections type fusectl (rw,relatime)
/dev/sda1 on /mnt/sda1 type ext4 (rw,relatime,data=ordered)
[... cgroup skipped ...]
none on /Users type vboxsf (rw,nodev,relatime)
/dev/sda1 on /mnt/sda1/var/lib/docker/aufs type ext4 (rw,relatime,data=ordered)
docker@testenv:~$ ls /Users/
Shared/      logpacker/
docker@testenv:~$

В данном случае мы используем MacOS и, соответственно, внутрь машины смонтирована директория /Users (аналог /home в linux). Это позволяет нам прозрачно работать с файлами на host-системе в рамках docker, то есть спокойно подключать и отключать volumes, не заботясь о прослойке в виде VM. Это действительно очень удобно. По идее, нам можно забыть про эту VM, она нужна только для того, чтобы docker работал в “родной” среде. При этом использование docker-клиента будет абсолютно прозрачным.
Итак, базовое окружение построено, далее будем запускать Docker контейнеры.

Настройка и запуск контейнеров


Наше приложение умеет работать по принципу кластера, то есть обеспечивает отказоустойчивость всей системы в случае изменения количества узлов. Благодаря внутреннему межсервисному API добавление или удаление нового узла в кластер проходит безболезненно и не требует перегрузки других узлов, и эту отличительную особенность нашего приложения нам тоже нужно учесть при построении окружения.
В принципе, все хорошо укладывается в идеологию Docker: “один процесс — один контейнер”. Поэтому мы не будем отходить от канонов и поступим точно также. На старте запустим следующую конфигурацию:
  • Три контейнера с серверной частью приложения.
  • Три контейнера с клиентской частью приложения.
  • Генератор нагрузки для каждого агента. Например, Ngnix, который будет генерировать логи, а мы его будем стимулировать Яндекс.Танком или Apache Benchmark.
  • И в еще одном контейнере мы отойдем от идеологии. Наш сервис умеет работать в так называемом “dual mode”, т.е. клиент и сервер находятся на одном и том же хосте, более того, это всего один экземпляр приложения, работающий сразу и как клиент, и как сервер. Его мы запустим в контейнере под контролем supervisord, и в этом же контейнере будет запущен наш собственный небольшой генератор нагрузки в качестве основного процесса.

Итак, у нас есть собранный бинарник нашего приложения — это один файл, да, спасибо Golang :), c которым мы соберем универсальный контейнер для запуска сервиса, в рамках тестового окружения. Разница будет в передаваемых ключах (запускаем сервер или агент), ими мы и будем управлять при запуске контейнера. Небольшие нюансы есть в последнем пункте, при запуске сервиса в “dual mode”, но об этом немного позже.
Итак, готовим docker-compose.yml. Это файл с директивами для docker-compose, который позволит нам поднять тестовое окружение одной командой:
docker-compose.yml
# external services
elastic:
  image: elasticsearch
ngx_1:
  image: nginx
  volumes:
    - /var/log/nginx
ngx_2:
  image: nginx
  volumes:
    - /var/log/nginx
ngx_3:
  image: nginx
  volumes:
    - /var/log/nginx

# lp servers
lp_server_1:
  image: logpacker_service
  command: bash -c "cd /opt/logpacker && ./logpacker -s -v -devmode -p=0.0.0.0:9995"
  links:
    - elastic
  expose:
    - "9995"
    - "9998"
    - "9999"
lp_server_2:
  image: logpacker_service
  command: bash -c "cd /opt/logpacker && ./logpacker -s -v -devmode -p=0.0.0.0:9995"
  links:
    - elastic
    - lp_server_1
  expose:
    - "9995"
    - "9998"
    - "9999"
lp_server_3:
  image: logpacker_service
  command: bash -c "cd /opt/logpacker && ./logpacker -s -v -devmode -p=0.0.0.0:9995"
  links:
    - elastic
    - lp_server_1
    - lp_server_2
  expose:
    - "9995"
    - "9998"
    - "9999"

# lp agents
lp_agent_1:
  image: logpacker_service
  command: bash -c "cd /opt/logpacker && ./logpacker -a -v -devmode -p=0.0.0.0:9995"
  volumes_from:
   - ngx_1
  links:
   - lp_server_1
lp_agent_2:
  image: logpacker_service
  command: bash -c "cd /opt/logpacker && ./logpacker -a -v -devmode -p=0.0.0.0:9995"
  volumes_from:
   - ngx_2
  links:
   - lp_server_1
lp_agent_3:
  image: logpacker_service
  command: bash -c "cd /opt/logpacker && ./logpacker -a -v -devmode -p=0.0.0.0:9995"
  volumes_from:
   - ngx_3
  links:
   - lp_server_1


В этом файле все стандартно. Первым запускаем elasticsearch, как основное хранилище, затем три экземпляра с nginx, которые будут выступать поставщиками нагрузки. Далее запускаем наши сервер-приложения. Обратите внимание, все последующие контейнеры линкуются с предыдущими. В рамках нашей docker-сети, это позволит обращаться к контейнерам по имени. Чуть ниже, когда мы будем разбирать запуск нашего сервиса в “dual mode”, мы еще вернемся к этому моменту и рассмотрим его чуть подробнее. Также с первым контейнером, в котором находится экземпляр сервер-приложения, залинкованы агенты. Это означает, что все три агента будут пересылать логи именно этому серверу.

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

И еще один момент: обратите внимание на логику монтирования volumes. На контейнерах с nginx мы указываем именованный volume, который будет доступен в docker-сети, а на контейнерах с агентами мы просто подключаем его, указав имя сервиса. Таким образом, у нас получится shared volume между потребителями и поставщиками нагрузки.

Итак, запускаем наше окружение:

$ docker-compose up -d

Проверяем, что все запустилось нормально:

$ docker-compose ps
        Name                      Command               State              Ports
--------------------------------------------------------------------------------------------
assets_lp_agent_1_1    bash -c cd /opt/logpacker  ...   Up
assets_lp_agent_2_1    bash -c cd /opt/logpacker  ...   Up
assets_lp_agent_3_1    bash -c cd /opt/logpacker  ...   Up
assets_lp_server_1_1   bash -c cd /opt/logpacker  ...   Up      9995/tcp, 9998/tcp, 9999/tcp
assets_lp_server_2_1   bash -c cd /opt/logpacker  ...   Up      9995/tcp, 9998/tcp, 9999/tcp
assets_lp_server_3_1   bash -c cd /opt/logpacker  ...   Up      9995/tcp, 9998/tcp, 9999/tcp
assets_ngx_1_1         nginx -g daemon off;             Up      443/tcp, 80/tcp
assets_ngx_2_1         nginx -g daemon off;             Up      443/tcp, 80/tcp
assets_ngx_3_1         nginx -g daemon off;             Up      443/tcp, 80/tcp
elastic                /docker-entrypoint.sh elas ...   Up      9200/tcp, 9300/tcp

Отлично, окружение поднялось, работает и все порты проброшены. В теории мы можем стартовать тестирование, но нам нужно доделать некоторые моменты.

Присвоение имен контейнерам


Вернемся к контейнеру, в котором мы хотели запустить наше приложение в “dual mode”. Основным процессом в этом контейнере будет выступать генератор нагрузки (простейший shell-сценарий). Он генерирует текстовые строки и складывает их в текстовые “лог”-файлы, которые, в свою очередь, будут являться нагрузкой для нашего приложения. Сначала нужно собрать контейнер с нашим приложением, запускаемым под supervisord. Возьмем последнюю версию supervisord, так как нам нужна возможность передачи переменных окружения в конфигурационный файл. Нам подойдет supervisord версии 3.2.0, однако в Ubuntu 14.04 LTS, которую мы взяли за базовый образ, версия supervisord достаточно старая (3.0b2). Установим свежую версию supervisord через pip. Итоговый Dockerfile получился таким:

Dockerfile
FROM ubuntu:14.04

# Setup locale environment variables
RUN locale-gen en_US.UTF-8
ENV LANG     en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL   en_US.UTF-8

# Ignore interactive
ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && 	apt-get install -y wget unzip curl python-pip

# Install supervisor via pip for latest version
RUN pip install supervisor
RUN mkdir -p /opt/logpacker
ADD final/logpacker /opt/logpacker/logpacker
ADD supervisord-logpacker-server.ini /etc/supervisor/conf.d/logpacker.conf
ADD supervisor.conf /etc/supervisor/supervisor.conf
# Load generator
ADD random.sh /opt/random.sh 
# Start script
ADD lp_service_start.sh /opt/lp_service_start.sh



Генератор нагрузки крайне прост:
#!/bin/bash

# generate random lines
OUTPUT_FILE="test.log"

while true
do
    _RND_LENGTH=`awk -v min=1 -v max=100 'BEGIN{srand(); print int(min+rand()*(max-min+1))}'`
    _RND=$(( ( RANDOM % 100 )  + 1 ))
    _A="[$RANDOM-$_RND] $(dd if=/dev/urandom bs=$_RND_LENGTH count=1 2>/dev/null | base64 | tr = d)";
    echo $_A;
    echo $_A >> /tmp/logpacker/lptest.$_RND.$OUTPUT_FILE;
done

Стартовый скрипт тоже не сложный:

#!/bin/bash

# run daemon
supervisord -c /etc/supervisor/supervisor.conf

# launch randomizer
/opt/random.sh

Вся хитрость будет заключаться в конфигурационном файле supervisord и запуске Docker-контейнера.
Рассмотрим конфигурационный файл:

[program:logpacker_daemon]
command=/opt/logpacker/logpacker %(ENV_LOGPACKER_OPTS)s
directory=/opt/logpacker/
autostart=true
autorestart=true
startretries=10
stderr_logfile=/var/log/logpacker.stderr.log
stdout_logfile=/var/log/logpacker.stdout.log

Обратите внимание на %(ENV_LOGPACKER_OPTS)s. Supervisord может выполнять подстановки в конфигурационный файл из переменных окружения. Переменная записывается как %(ENV_VAR_NAME)s и ее значение подставляется в конфигурационный файл при старте демона.
Запускаем контейнер, выполнив следующую команду:

$ docker run -it -d --name=dualmode --link=elastic -e 'LOGPACKER_OPTS=-s -a -v -devmode' logpacker_dualmode /opt/random.sh

При помощи ключа -e есть возможность установить необходимую переменную окружения, при этом она будет установлена глобально внутри контейнера. И именно ее мы подставляем в конфигурационный файл supervisord. Таким образом, мы можем управлять ключами запуска для нашего демона и запускать его в нужном нам режиме.
Мы получили универсальный образ, хотя немного не соответствующий идеологии. Заглянем внутрь:
Environment
$ docker exec -it dualmode bash
$ env
HOSTNAME=6b2a2ae3ed83
ELASTIC_NAME=/suspicious_dubinsky/elastic
TERM=xterm
ELASTIC_ENV_CA_CERTIFICATES_JAVA_VERSION=20140324
LOGPACKER_OPTS=-s -a -v -devmode
ELASTIC_ENV_JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64/jre
ELASTIC_ENV_JAVA_VERSION=8u66
ELASTIC_ENV_ELASTICSEARCH_REPO_BASE=http://packages.elasticsearch.org/elasticsearch/1.7/debian
ELASTIC_PORT_9200_TCP=tcp://172.17.0.2:9200
ELASTIC_ENV_ELASTICSEARCH_VERSION=1.7.4
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ELASTIC_PORT_9300_TCP_ADDR=172.17.0.2
ELASTIC_ENV_ELASTICSEARCH_MAJOR=1.7
ELASTIC_PORT_9300_TCP=tcp://172.17.0.2:9300
PWD=/
ELASTIC_PORT_9200_TCP_ADDR=172.17.0.2
ELASTIC_PORT_9200_TCP_PROTO=tcp
ELASTIC_PORT_9300_TCP_PORT=9300
SHLVL=1
HOME=/root
ELASTIC_ENV_JAVA_DEBIAN_VERSION=8u66-b17-1~bpo8+1
ELASTIC_PORT_9300_TCP_PROTO=tcp
ELASTIC_PORT=tcp://172.17.0.2:9200
LESSOPEN=| /usr/bin/lesspipe %s
ELASTIC_ENV_LANG=C.UTF-8
LESSCLOSE=/usr/bin/lesspipe %s %s
ELASTIC_PORT_9200_TCP_PORT=9200
_=/usr/bin/env



Помимо нашей переменной, которую мы явно указали при старте контейнера, мы видим еще и все переменные, относящиеся к залинкованному контейнеру, а именно: IP-адрес, все открытые порты и все переменные, которые были явно установлены при сборке образа elasticsearch при помощи директивы ENV. Все переменные имеют префикс с именем экспортирующего контейнера и название, указывающее на их суть. Например, ELASTIC_PORT_9300_TCP_ADDR обозначает, что в переменной хранится значение, указывающее на контейнер с именем elastic и его ip-адрес, на котором открыт порт 9300. Если поднимать отдельный discovery-сервис для поставленных задач не резонно, то это отличный способ получить IP-адрес и данные залинкованных контейнеров. При этом остается возможность использовать их в своих приложениях, которые запущены в Docker контейнерах.

Управление контейнерами и система мониторинга


Итак, мы построили окружение для тестирования отвечающее всем нашим изначальным запросам. Осталась пара нюансов. Во-первых, установим Weave Scope (скриншоты которого были в начале статьи). При помощи Weave Scope можно визуализировать среду, в которой мы работаем. Помимо отображения связей и информации о контейнерах, мы можем выполнить attach к любому контейнеру или запустить полноценный терминал с sh прямо в браузере. Это незаменимые функции при отладке и тестировании. Итак, с хост-машины выполняем следующие действия, в рамках нашей активной сессии:


$ wget -O scope https://github.com/weaveworks/scope/releases/download/latest_release/scope
$ chmod +x scope
$ scope launch

После выполнения этих команд, перейдя по адресу VM_IP:4040 мы попадаем в интерфейс управления контейнерами, представленный на картинке ниже:



Отлично, почти все готово. Для полного счастья нам не хватает системы мониторинга. Воспользуемся cAdvisor от Google:

$ docker run --volume=/:/rootfs:ro --volume=/var/run:/var/run:rw  --volume=/sys:/sys:ro --volume=/var/lib/docker/:/var/lib/docker:ro --publish=8080:8080 --detach=true --name=cadvisor google/cadvisor:latest

Теперь по адресу VM_IP:8080 у нас есть система мониторинга ресурсов в реальном времени. Мы можем отслеживать и анализировать основные метрики нашего окружения, такие как:

  • использование системных ресурсов;
  • загрузка сети;
  • список процессов;
  • прочая полезная информация.

На скриншоте ниже представлен cAdvisor интерфейс:



Заключение


Используя Docker контейнеры, мы построили полноценное тестовое окружение с функциями автоматического развертывания и сетевого взаимодействия всех узлов, и что особенно важно, с гибкой настройкой каждого компонента и системы в целом. Реализованы все основные требования, а именно:
  • Полноценная эмуляция сети для тестирования сетевого взаимодействия.
  • Добавление и удаление узлов с приложением осуществляется за счет изменений в docker-compose.yml и применяется одной командой.
  • Все узлы могут полноценно получать информацию о сетевом окружении.
  • Добавление и удаление хранилищ данных выполняется одной командой.
  • Управление и мониторинг системы доступны через браузер. Это реализовано при помощи инструментов, отдельно запущенных в контейнерах рядом с нашим приложением, что позволяет изолировать их от host-системы.

Ссылки на все инструменты, упомянутые в статье:


Комментарии (3)


  1. el777
    22.01.2016 13:22

    Что в бою используете? Насколько данная схема годится для боя?
    Дело не только в тесте, одна целей использования средств типа докера — единая бесшовная интеграция «дев -> тест -> бой».


    1. LogPacker
      25.01.2016 21:05

      Под определенные критерии данная схема полностью подходит. Интеграция «дев -> тест -> бой» — это идеальный вариант и мы стремимся к нему. Мы планируем написать об этом отдельную статью.


      1. el777
        26.01.2016 13:10

        Напишите. Очень интересно, как это у вас решается. Спасибо.