Деплой Elixir при помощи Docker. Часть 2
В предыдущей статье мы рассмотрели процесс упаковки Elixir-приложения в Docker-образ. Один из недостатков использования Docker для Elixir-приложений заключается в том, что из-за наличия у контейнеров собственной сети, две Elixir-ноды, находящиеся в двух разных контейнерах, не могут подключаться друг к другу, даже если запускать их на одном и том же физическом сервере.
Более того, деплой контейнеров проводится на платформе Rancher, которая распределяет их между несколькими физическими машинами. По этой причине отобразить порты Docker-контейнера на сервер невозможно, ведь на каждом сервере несколько контейнеров могут запускать одно и то же приложение. Однако в Rancher имеется встроенный DNS-сервис для автоматического поиска новых нод и подключения к ним.
Как работает Rancher
Основой Rancher являются сервисы. Каждый сервис представляет собой Docker-образ с соответствующими конфигурациями (ENV, command, volumes и др.) и способен запускать несколько одинаковых контейнеров на нескольких машинах.
Для осуществления маршрутизации между этими контейнерами в Rancher существует своя оверлейная сеть и DNS-протокол обнаружения сервисов.
Например, у нас есть сервис «my-hello-service» с тремя контейнерами, запускающий функцию nslookup
в одном из контейнеров:
/opt/app $ nslookup my-hello-service
Name: my-hello-service
Address 1: 10.42.72.199
Address 2: 10.42.96.65
Address 3: 10.42.240.66
Поскольку все контейнеры одного сервиса могут связываться друг с другом посредством этой оверлейной сети, нужно лишь сделать так, чтобы каждая нода «видела» все другие ноды.
Динамические адреса и имена
Все вышеуказанные IP-адреса являются динамическими, а значит, срок их жизни равен сроку жизни контейнера. Адреса изменятся если перезапустить/обновить контейнер или провести масштабирование (добавить контейнер в сервис или удалить его). В связи с этим использовать статическую конфигурацию файла из sys.config
(подробнее об этом здесь) уже не получится.
Так что познакомим наше приложение с Rancher DNS и постараемся извлечь из этого максимум пользы.
Настройка ноды Elixir
Прежде чем перейти к обнаружению нод, сделаем так, чтобы Elixir-ноды смогли «видеть» друг друга. При использовании модуля mix_docker (точнее distillery) ноде по умолчанию даётся имя appname@127.0.0.1
. Но, если нужно подключиться к другим нодам сети 10.42.x.x
, это имя необходимо будет изменить. Кроме того, оно должно динамически создаваться внутри контейнера при его запуске (только в этом случае можно быть уверенным, что сеть Rancher перекроет IP-адрес).
Я достаточно долго размышлял на тему того, как это можно сделать, и, наконец, нашёл решение. В двух словах, нужно сделать так, чтобы vm.args
стали доступны переменные среды, а затем поместить эти переменные в контейнер.
ПРИМЕЧАНИЕ: прежде чем читать дальше, рекомендую сначала ознакомиться с предыдущей статьёй об Elixir и Docker.
Для начала укажем в модуле distillery путь к своему vm.args
файлу:
# rel/config.exs
# ...
environment :prod do
# ...
set vm_args: "rel/vm.args"
end
А сам файл vm.args
будет выглядеть так:
# rel/vm.args
## Name of the node - this is the only change
-name hello@${RANCHER_IP}
## Cookie for distributed erlang
-setcookie something-secret-here-please-change-me
## Heartbeat management; auto-restarts VM if it dies or becomes unresponsive
## (Disabled by default..use with caution!)
##-heart
## Enable kernel poll and a few async threads
##+K true
##+A 5
## Increase number of concurrent ports/sockets
##-env ERL_MAX_PORTS 4096
## Tweak GC to run more often
##-env ERL_FULLSWEEP_AFTER 10
# Enable SMP automatically based on availability
-smp auto
Синтаксис ${RANCHER_IP}
и конфигурация REPLACE_OS_VARS=true
модуля distillery позволяют задавать имя ноды динамически на основе переменной среды RANCHER_IP
.
Далее, воспользуемся встроенным в Rancher Metadata API, чтобы создать переменную RANCHER_IP
. Поместим следующий код в скрипт rel/rancher_boot.sh
, который будет выступать в качестве точки входа контейнера.
#!/bin/sh
set -e
export RANCHER_IP=$(wget -qO- http://rancher-metadata.rancher.internal/latest/self/container/primary_ip)
/opt/app/bin/hello $@
И напоследок, немного изменим Docker-файл релиза: будем использовать свои настройки вместо стандартных значений из mix_docker.
# Dockerfile.release
FROM bitwalker/alpine-erlang:6.1
RUN apk update && \
apk --no-cache --update add libgcc libstdc++ && \
rm -rf /var/cache/apk/*
EXPOSE 4000
ENV PORT=4000 MIX_ENV=prod REPLACE_OS_VARS=true SHELL=/bin/sh
ADD hello.tar.gz ./
RUN chown -R default ./releases
USER default
# the only change are these two lines
COPY rel/rancher_boot.sh /opt/app/bin/rancher_boot.sh
ENTRYPOINT ["/opt/app/bin/rancher_boot.sh"]
Построив образ и обновив сервис Rancher, проверим, могут ли контейнеры подключаться друг к другу. Для этого подключимся к удалённой машине с помощью SSH и запустим docker exec
:
core@host1 ~ $ docker exec -it e3c6a817b618 /opt/app/bin/rancher_boot.sh remote_console
Erlang/OTP 18 [erts-7.3.1] [source] [64-bit] [smp:2:2] [async-threads:10] [kernel-poll:false]
Interactive Elixir (1.3.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(hello@10.42.96.65)1>
Как видно из подсказки iex, ноде присвоен IP оверлейной сети Rancher. Теперь запустим ещё один контейнер для того же сервиса и получим его IP (в той же сети 10.42.x.x
). Чтобы убедиться, что ноды могут «видеть» друг друга, попробуйте подключить одну ноду к другой через Node.connect
. Предположим, что IP-адрес второго контейнера — 10.42.240.66
:
iex(hello@10.42.96.65)1> Node.connect :"hello@10.42.240.66"
true
iex(hello@10.42.96.65)2> Node.list
[:"hello@10.42.240.66"]
Для второй ноды:
core@host2 ~ $ docker exec -it 47dd4308fe8e /opt/app/bin/rancher_boot.sh remote_console
Erlang/OTP 18 [erts-7.3.1] [source] [64-bit] [smp:2:2] [async-threads:10] [kernel-poll:false]
Interactive Elixir (1.3.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(hello@10.42.240.66)1> Node.list
[:"hello@10.42.96.65"]
Теперь в Elixir/Erlang можно осуществлять распределения с помощью модуля Node
.
Автообнаружение на основе DNS
Мы рассмотрели, как установить связь между двумя нодами Elixir, но для этого пришлось вводить IP-адрес второй ноды вручную. Такой способ определённо неуместен в продакшне, поэтому попробуем сделать это через DNS.
Эквивалентом nslookup в Elixir выступает функция :inet.gethostbyname
, а если быть точнее, то :inet_tcp.getaddrs(name)
. Вызвав эту функцию в одном из контейнеров, получим список всех IP-адресов контейнеров сервиса:
iex(hello@10.42.96.65)11> :inet_tcp.getaddrs 'my-hello-service'
{:ok, [{10, 42, 72, 199}, {10, 42, 240, 66}, {10, 42, 96, 65}]}
Теперь осталось только создать на каждой ноде erlang-процесс, который бы периодически вызывал эту функцию и подключался бы к другим нодам. Достаточно создать простой GenServer, который будет проверять DNS каждые 5 секунд.
# lib/hello/rancher.ex
defmodule Hello.Rancher do
use GenServer
@connect_interval 5000 # try to connect every 5 seconds
def start_link do
GenServer.start_link __MODULE__, [], name: __MODULE__
end
def init([]) do
name = Application.fetch_env!(:hello, :rancher_service_name)
send self, :connect
{:ok, to_char_list(name)}
end
def handle_info(:connect, name) do
case :inet_tcp.getaddrs(name) do
{:ok, ips} ->
IO.puts "Connecting to #{name}: #{inspect ips}"
for {a,b,c,d} <- ips do
Node.connect :"hello@#{a}.#{b}.#{c}.#{d}"
end
{:error, reason} ->
IO.puts "Error resolving #{inspect name}: #{inspect reason}"
end
IO.puts "Nodes: #{inspect Node.list}"
Process.send_after(self, :connect, @connect_interval)
{:noreply, name}
end
end
Подключим его в дерево супервизоров приложения:
# lib/hello.ex
children = [
# ...
worker(Hello.Rancher, [])
]
Ноды будут автоматически удаляться из списка сразу после отключения. Node.connect
предотвращает двойное подключение к одной и той же ноде, поэтому проверять Node.list
перед подключением не обязательно.
Ещё одна возможность, которая упростит вам жизнь, — это динамическое именование сервисов Rancher.
Как видно из примера выше, имя сервиса (а значит, и DNS-сервера) взято из ключа :rancher_service_name
конфигурации приложения. В config/prod.exs
можно сделать это имя статическим, но гораздо лучше оставить всё как есть.
Поместим следующий код в config/prod.exs:
config :hello, :rancher_service_name, "${RANCHER_SERVICE_NAME}"
А rel/rancher_boot.sh
приведём в соответствие со следующим:
#!/bin/sh
set -e
export RANCHER_IP=$(wget -qO- http://rancher-metadata.rancher.internal/latest/self/container/primary_ip)
export RANCHER_SERVICE_NAME=$(wget -qO- http://rancher-metadata.rancher.internal/latest/self/service/name)
/opt/app/bin/hello $@
Здесь для получения имени текущего сервиса снова используется встроенный в Rancher сервис метаданных. Такой подход позволяет использовать один и тот же образ-контейнер повторно для различных сервисов (с различными конфигурациями среды выполнения).
Победа!
Посмотреть, как в реальности работает то, что у нас получилось, можно на видео по ссылке. С левой стороны представлен живой лог одного из контейнеров, а с правой — список контейнеров, запущенных сервисом «my-hello-service». При добавлении и удалении запущенных сервисом контейнеров можно видеть, как в логе контейнера справа учитываются все изменения и обновляется список подключённых нод.