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