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

© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.