О Docker Swarm Mode

2017-04-16

Вернёмся к нашему рою Докеров. Я уже писал про Docker Swarm. Это когда вы можете запустить кучку Docker Engine в кластере ваших компутеров и управлять контейнерами в этом кластере. Тогда речь шла о Docker Swarm, который представляет собой несколько контейнеров, запущенных в обычном Докере. А с версии Docker Engine 1.12 (июль 2016) у нас появился Swarm Mode, непосредственно внедрённый в Docker Engine. Вот до него-то я и добрался.

Напоминаю. Раньше мы брали самый обыкновенный Докер. Запускали в нём, на каждом физическом узле, контейнер с Consul, чтобы иметь единое распределённое хранилище для метаданных нашего кластера. Потом запускали специальный образ Swarm в виде менеджера (тоже, при необходимости, распределённого) и узлов (воркеров) кластера. Этот Swarm контейнер получал доступ к Docker Engine API на каждом узле, где он запущен, путём проброса сокета Докер демона. И клиенты, включая Docker Compose, общались удалённо теперь уже с менеджером Swarm кластера по тому же самому протоколу и API. Т.е. замена одного Docker Engine на кластер Docker Swarm для клиента и пользователя выглядела почти прозрачной.

Docker pic

Теперь всё по-другому. Теперь Swarm Mode — это особый режим работы Docker Engine. И поддерживается он прямо из коробки. И привносит свои нюансы.

Docker развивается стремительно. Версия 1.12 осталась в далёком прошлом. Так же как и версия 1.13. Теперь Докер нумеруется а-ля Убунту, и текущая версия 17.04.0, соответственно, от апреля 2017 года.

Теперь у нас два Докера: Community Edition и Enterprise Edition. Второй от первого отличается, пока что, лишь наличием платной поддержки. Но, например, Community Edition уже официально не поддерживается под RedHat Enterprise Linux.

Допустим, у нас есть две-три машинки, на которые мы хотим водрузить Docker Engine и объединить их в Swarm.

Для начала нам нужно немного поконфигурировать Докер демон. Учитывая неразбериху со способами инициализации (даже в Убунту проник systemd, а в Дебиане до сих пор можно выбирать между systemd и sysvinit), универсальным способом конфигурирования становится файл /etc/docker/daemon.json. Об этом способе часто умалчивают. Ну и это JSON, со всеми его прелестями в виде отсутствия комментариев и необходимости не запутаться, где там должен быть массив, а где просто значение.

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

"hosts": [
  "unix:///var/run/docker.sock",
  "tcp://0.0.0.0:2375"
]

Если у нас есть локальный Docker Registry, и мы поленились настраивать на нём HTTPS, нам нужно включить этот Registry в список доверенных.

"insecure-registries": [
  "192.168.2.25:5000"
]

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

"labels": [
  "backend=yes",
  "worker=yes"
]

После изменения конфига, очевидно, нужно перезагрузить демон. Например посредством service docker restart.

Теперь можно создавать Swarm. На той машинке, которая станет одним из менеджеров кластера, и с которой вы хотите начать это безобразие, нужно выполнить команду. Внимание, с появлением Swarm Mode у простой и понятной команды docker появилось очень даже много подкоманд. Сейчас будем знакомиться с docker swarm.

$ docker swarm init --advertise-addr 192.168.2.12
Swarm initialized: current node (2epmgc34aa0o7x6z6ho7v8u66) is now a manager.

To add a worker to this swarm, run the following command:

    docker swarm join \
    --token SWMTKN-1-0pcqn90yv70657tpbepxaqjw3et3ihcalpbee8ndzifxg5zncn-5r3zjl1evea481i3mzetnwoff \
    192.168.2.12:2377

To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.

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

Узел, на котором мы запустили docker swarm init, становится (первым) менеджером будущего кластера. И нам дают некий токен, который нужен, чтобы подключить к этому кластеру другие узлы.

Команда не идемпотентна. Попытка проинициализировать Swarm повторно приводит к ошибке.

$ docker swarm init --advertise-addr 192.168.2.12
Error response from daemon: This node is already part of a swarm. Use "docker swarm leave" to leave this swarm and join another one.

С одной стороны, понятно почему. С другой стороны, такая ошибка затрудняет автоматизацию. Можно задать ключ --force-new-cluster true, тогда каждый раз будет создаваться новый кластер, это тоже не то, что хотелось бы. А вот ключа игнорировать наличие кластера — нет.

Итак, у нас, оказывается, есть два типа узлов. Менеджер — с него можно управлять кластером. Менеджеров может быть несколько, они устраивают выборы, определяют кворум и всё такое. По умолчанию узел-менеджер также является и воркером, но можно создать и чистого менеджера. Воркер — запускает на себе контейнеры и всё такое. Чтобы присоединить к кластеру воркера или менеджера, используются разные токены.

Токен для присоединения воркера пишется в выхлопе docker swarm init. Это вариант для человеков. Для автоматизации вам нужно просто вывести токен. Это можно.

$ docker swarm join-token worker -q
SWMTKN-1-0pcqn90yv70657tpbepxaqjw3et3ihcalpbee8ndzifxg5zncn-5r3zjl1evea481i3mzetnwoff

Чтобы присоединить другой Docker Engine к этому Swarm, его нужно присоединить по токену. Нужно ещё указать адрес одного из менеджеров.

$ docker swarm join --token SWMTKN-1-... 192.168.2.12:2377

Эта команда снова не идемпонентна. Попытка присоединиться к кластеру, если узел уже в кластере, приводит к ошибке.

Узлы кластера общаются между собой по кучке портов. TCP порт 2377 — обращение к менеджерам. TCP и UDP порт 7946 — обмен метаданными между узлами. UDP порт 4789 — overlay сеть между узлами. А если мы собираемся использовать зашифрованный overlay, нужно разрешить ещё и протокол 50 (ESP). Но вроде как дополнительно шифровать overlay не очень нужно, ибо, если верить документации, весь трафик между узлами и так шифруется.

Чтобы разобраться с узлами кластера у нас добавилось семейство команд docker node. Например, можно узнать, сколько у нас узлов в кластере и каково их здоровье.

$ DOCKER_HOST=tcp://192.168.2.12:2375 docker node ls
ID                           HOSTNAME  STATUS  AVAILABILITY  MANAGER STATUS
5qt2all7nm1sapczihel6crn7 *  docker2   Ready   Active        Leader
tbojo6bwq3p9rzxf1gzni1ig6    docker1   Ready   Active

Это команды, которые работают только в Swarm Mode, и работают только на узлах-менеджерах. Но мы не зря разрешали удалённый доступ к Docker Engine. Можно выполнить команды удалённо, указав адрес менеджера в переменной окружения DOCKER_HOST.

Swarm workers

Docker Swarm у нас вроде готов. Попробуем взять docker-compose.yml и развернуть его.

$ DOCKER_HOST=tcp://192.168.2.12:2375 docker-compose up

Compose does not use swarm mode to deploy services to multiple nodes in a swarm. All containers will be scheduled on the current node.

To deploy your application across the swarm, use `docker stack deploy`.

Что?

Ну да. Swarm Mode — это вам не старый добрый Swarm на контейнерах. Swarm Mode не совместим ни с чем, кроме себя самого. А Docker Compose теперь пригоден только для запуска кучки связанных контейнеров на одном хосте (локально).

Вместо docker run у нас теперь docker service create. Вместо кучи других команд, которые работают только на одном Докере, у нас теперь целая куча команд docker service, работающих в Swarm Mode.

Сервисы в Docker Swarm — это немножко покруче просто контейнеров, запущенных через docker run. Это, в общем-то, сервисы в понимании Docker Compose. Можно крутить их количество в кластере с помощью docker service scale. Можно смотреть, на каких узлах кластера сервис развёрнут: docker service ps. А самое клёвое, можно на лету менять настройки сервисов, например, проброшенные порты, с помощью docker service update.

Но мы же не хотим забрасывать наши любимые и тщательно выверенные docker-compose.yml? Не заменять же их шелловыми скриптами с docker service? Слава хипстерам, создатели Докера на нас не забили, и у нас есть docker stack.

Тут всё странно. Стек — это множество сервисов. docker stack deploy разворачивает это множество в Swarm согласно описанию в формате Distributed Application Bundles. А получить этот DAB файл (который на самом деле JSON) из docker-compose.yml можно с помощью docker-compose. Зачем ещё один формат?

Но docker stack deploy умеет работать и с docker-compose.yml.

$ DOCKER_HOST=tcp://192.168.2.12:2375 \
docker stack deploy --compose-file docker-compose.yml ${PROJECT}

Тут надо явно указать название стека, который вы разворачиваете. В Docker Compose это называлось проектом и по умолчанию бралось из имени текущего каталога.

Но и docker-compose.yml тут должен быть не простой. Он должен быть версии 3 или выше (сейчас крайняя версия 3.2).

Основное отличие версии 3 от старой доброй версии 2 — свойство deploy у каждого сервиса. docker-compose это свойство игнорирует, а docker stack deploy как раз использует.

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

version: '3'

services:

  nginx:
    image: nginx
    deploy:
      placement:
        constraints:
          - 'engine.labels.frontend == yes'
      mode: replicated      # maybe 'global' is better here
    ports:
      - '80:80'
    networks:
      - frontend

  worker:
    image: worker
    deploy:
      placement:
        constraints:
          - 'engine.labels.worker == yes'
      mode: replicated
    networks:
      - frontend

networks:
  frontend:

deploy.placement.constraint — это ограничения. engine.labels.* — это метки, что мы задали в daemon.json, т.е. метки Docker Engine. Есть ещё метки узлов. Они навешиваются через docker node update, который нужно выполнить, соответственно, на менеджере кластера. Опять неудобно для автоматизации, нужно ходить на менеджера, но знать имена всех остальных узлов. Метки узлов доступны для проверки как node.labels.*. Синтаксис ограничений, по сравнению со старым Swarm, изменился.

deploy.mode — это режим развёртывания. Пока их два. replicated — это по умолчанию. Сервисов будет запущено столько, сколько скажете сразу при создании, либо до скольки отмасштабируете через docker service scale. И размазаны их контейнеры будут по кластеру ровным слоем, учитывая ограничения, конечно. global — будет запущен ровно один (не более) сервис на каждом узле кластера, учитывая ограничения.

Сети, указанные в docker-compose.yml здесь по умолчанию будут overlay. Т.е. будут спокойно работать между узлами кластера.

Swarm services

С сетью всё вообще очень интересно. Давным-давно, как вы помните, нужно было проставлять linkи между контейнерами, и имя линка появлялось в /etc/hosts. Теперь внутри Docker Engine у нас есть DNS сервер. Linkи не нужны, достаточно, чтобы сервисы были в одной сети. А одна сеть на docker-compose.yml и так формируется по умолчанию. Имена сервисов резолвятся в IP адреса в этих внутренних сетях. А если сервисов отмасштабировано несколько экземпляров, то одно доменное имя будет резолвится на несколько IP адресов. Обычный round robin DNS. И всё это справедливо для нынешнего Docker Compose.

В Swarm Mode пошли чуть дальше. Теперь каждому сервису выдаётся один виртуальный IP (VIP). Доменное имя сервиса резолвится в этот один единственный VIP. А трафик на этот VIP уже магическим образом распределяется между актуальными контейнерами (уже со своими IP). Понятно, зачем это надо. В зависимости от нагрузки на узлы кластера, может потребоваться более тонкая балансировка, чем простой round robin. Но при желании можно вернуться на старое поведение, без VIP, а с множеством IP адресов на одно доменное имя сервиса.

Swarm Service VIP

Серьёзные изменения произошли в публикации портов. Синтаксис остался прежним. А вот смысл теперь совсем другой.

Раньше, когда мы публиковали порт, это означало, что Docker Engine на том хосте, где запущен контейнер с данным сервисом, начинал слушать указанный порт и пробрасывать соединения в контейнер. Но мы не можем точно контролировать, на каком узле будет запущен сервис. В результате нам либо нужно запускать сервисов заведомо столько, сколько узлов в кластере, убеждаться, что они запустились действительно на всех узлах, и использовать внешний балансер. Ну, по сути, делать то, что теперь нам гарантирует deploy.mode = global. Либо наоборот, внедрять балансер, например, HAProxy, в виде сервиса, прибивать его, с помощью ограничений, к определённому узлу, весь внешний трафик направлять на этот узел, а балансировать уже внутри Докера.

Теперь этих мучений во многих случаях можно избежать. Потому что в Swarm Mode есть Ingress Routing Mesh. Если вы публикуете порт в каком-то сервисе, этот порт начинает слушаться на всех узлах кластера. И принимать внешний трафик можно на любом узле кластера. Даже том, где контейнер с данным сервисом в данный момент не запущен. Swarm магическим образом всё это дело смаршрутизирует и сбалансирует до нужного контейнера. Круто? Круто.

Но есть нюансы. Так как эти меши, это, по сути, трансляция адресов, ваше приложение в контейнере видит не реальный IP адрес клиента, а некий адрес во внутренней сети Докера.

Если вам нужно доставить IP адрес клиента до вашего сервиса, у вас есть два пути. Либо мы снова добавляем внешний балансер, и пусть он каким-то образом внедряет IP адрес клиента. В случае HTTP это может быть дополнительный заголовок X-Forwarded-For. Для других протоколов можно использовать HAProxy Proxy protocol. Либо можно отключить этот меш для данного сервиса, и использовать обычный проброс портов, работающий только на одном хосте. Это должно хорошо работать для global сервисов.

Чтобы можно было указать режим публикации портов, синтаксис всё-таки изменился. Можно воспользоваться возможностью обновлять сервисы.

$ docker service update ${PROJECT}_nginx --publish-add mode=host,target=80,published=80

А можно сразу прописать новый хитрый синтаксис в docker-compose.yml.

ports:
  - target: 80
    published: 80
    protocol: tcp
    mode: host

Последний вариант требует версии compose файла аж 3.2 и будет работать только в только-только релизящемся Docker Engine 17.4.0.

Mesh with external balancer

Ну что ж. Docker Swarm Mode — состоявшийся факт. Оно не совместимо с предыдущим Swarm, который, тем не менее, продолжает поддерживаться.

Создание Swarm кластера существенно упростилось. Хотя не очень удачные неидемпотентные команды и необходимость копирования токенов, не сильно помогают в автоматизации развёртывания.

Появилось оооочень много новых команд docker. Старые команды тоже остались, но они не будут работать со Swarm Mode.

Docker Compose сократили до локального инструмента. Вместо него предлагают использовать новые команды docker, в частности docker stack deploy. Однако, весьма полная совместимость на уровне compose файлов осталась.

Mesh роутинг и VIP здорово упрощают развёртывание и разработку в типовых случаях (http микросервисы?). В сложных случаях без явного балансировщика всё равно не обойтись, либо внешнего, либо внутри Swarm.

UPD
Ещё один момент. В deploy можно указать параметры перезапуска сервисов. По умолчанию Swarm пытается запустить нужное количество контейнеров, всегда, и после ребута узлов. Если контейнер падает, снова запускается новый экземпляр. А несколько трупиков от предыдущих запусков или попыток запуска сохраняются. Если в образе ошибка, и контейнер падает всегда, получается вечный цикл: запуск, падение, подчистка старых трупиков, снова запуск. Выглядит это очень странно, и нужно успеть вскрыть трупики, чтобы понять причину падения.