Кластерный сервис на Эрланге: от идеи до deb-пакета

в 21:51, , рубрики: cowboy, Erlang/OTP, метки:

Задача

Нужно написать настоящий сервис на эрланге, который будет работать в кластере. Кроме того, нужно максимально упростить жизнь тем, кто будет обслуживать сервис.
Требования:

  • У сервиса будет RESTful интерфейс (это модно и современно)
  • основные настройки сервиса должны быть вынесены в маленький файл с понятным синтаксисом
  • сервис должен писать опциональный access-лог
  • сервис должен запускаться через upstart

Для простоты сервис будет представлять собой счетчик, который каждому клиенту выдает увеличивающееся с каждым запросом на 1 целое число (уникальное до перезапуска счетчика).

Технологии

Выберем все самое модное и современное:

Архитектура

Ковбой будет висеть на некотором порту, запрос обрабатывать нашим хендлером, который будет делать вызов в счетчик, далее отвечать клиенту и писать запись в лог.
Счетчик будет зарегистрирован в global, чтобы к нему можно было легко обратиться с любой ноды кластера.
При запуске счетчик пытается зарегистрироваться, если не выходит (уже зарегистрирован счетчик на другой ноде) — ждет возможности это сделать.

Скелет приложения

Нам нужно сделать OTP-приложение по всем канонам, но с минимумом усилий.
Создаем каталог erdico для проекта, делаем в нем git init, скачиваем файл erlang.mk из репозитория одноименного проекта и создаем незамысловатый Makefile:

PROJECT = erdico
ERLC_OPTS= "+{parse_transform, lager_transform}"

DEPS = cowboy lager
dep_cowboy = pkg://cowboy 0.10.0
dep_lager = https://github.com/basho/lager.git 2.0.3

include erlang.mk

Mac OS/BSD users: Понадобится wget. В линуксах он, вроде как, сейчас везде есть из коробки.
Обратите внимание, что ковбой включен как известный пакет. Репозиторий у erlang.mk хоть и маловат, но есть.

В файле src/erdico.app.src описываем наше приложение (все параметры обязательны, иначе сломается erlang.mk или relx):

{application, erdico, [
        {description, "Hello, Upstart distributed Erlang service"},
        {id, "ErDiCo"},
        {vsn, "0.1"},
        {applications, [kernel, stdlib, lager, cowboy]}, % run-time dependencies
        {modules, []},  % here erlang.mk inserts all application modules, not added automatically, required by relx
        {mod, {erdico, []}}, % application callback module
        {registered, [erdico]} % required by relx
        ]}.

Файл src/erdico.erl создаем, но пока кроме директивы -module(erdico). ничего там не пишем.
В таком состоянии make должен выкачать зависимости и собрать все, что найдет.

Запуск приложения, cowboy и простейший обработчик запросов (запускатель, обработчик)

Для простоты весь управляющий код я собрал в одном модуле erdico. Фанатики могут здесь сделать 4 модуля, а все остальные вынесут те куски, логика которых вдруг станет ощутимо нетривиальной и потому достойной отдельного модуля.

HTTP-сервер

Здесь содержится примерно минимальная конфигурация. Что там бывает еще, можно прочитать в документации

start_cowboy() ->
    DefPath = {'_', erdico_handler, []},    % Catch-all path
    Host = {'_', [DefPath]},                % No virtualhosts
    Dispatch = cowboy_router:compile([Host]),
    Env = [{env, [{dispatch, Dispatch}]}],
    cowboy:start_http(?MODULE, 10, [{port, 2080}], Env).
Обработчик запросов

Тут пока все примитивно:

-module(erdico_handler).
-behavior(cowboy_http_handler).
 
-export([init/3, handle/2, terminate/3]).

init(_Type, Req, _Options) ->
    {ok, Req, nostate}.

handle(Req, nostate) ->
    {ok, Replied} = cowboy_req:reply(200, [], <<"hellon">>, Req),
    {ok, Replied, nostate}.

terminate(_Reason, _Req, nostate) ->
    ok.
Собираем, запускаем, проверяем

Для сборки — просто make.
Для запуска нужно указать каталог с зависимостями и каталог с бинарями нашего приложения.

stolen@node1:~/erdico$ ERL_LIBS=deps erl -pa ebin -s erdico

Консоль эрланга
Erlang/OTP 17 [erts-6.1] [source-d2a4c20] [64-bit] [async-threads:10] [hipe] [kernel-poll:false]

Eshell V6.1  (abort with ^G)
1> 15:01:14.486 [info] Application lager started on node nonode@nohost
15:01:14.493 [info] Application ranch started on node nonode@nohost
15:01:14.506 [info] Application crypto started on node nonode@nohost
15:01:14.506 [info] Application cowlib started on node nonode@nohost
15:01:14.513 [info] Application cowboy started on node nonode@nohost
15:01:14.530 [info] Application erdico started on node nonode@nohost

1> 

Видно, что даже lager начал как-то работать (кроме консоли он еще и на диск написал).

stolen@node2:~$ curl node1:2080
hello

Счетчик

Что ж, приложение запускается и работает. Настало время добавить смысл в его существование.
Не буду вдаваться в подробности реализации, просто читайте патч.

Демонстрация

Пока что обе эрланговые ноды запустим на одном хосте node1 — e1@node1 и e2@node1. Для этого порт, на котором висит сервер, настраивается из командной строки.
На первой ноде накручиваем счетчик до 20, на второй — до 1. Собираем кластер и видим, что счетчик на второй ноде убивается, после чего обращение к счетчику со второй ноды вызывает первый счетчик.

e1@node1

stolen@node1:~/erdico$ ERL_LIBS=deps erl -pa ebin -s erdico -setcookie erdico -sname e1 -erdico port 2081
Erlang/OTP 17 [erts-6.1] [source-d2a4c20] [64-bit] [async-threads:10] [hipe] [kernel-poll:false]
...............
(e1@node1)2> erdico_counter:inc(10).
{ok,20}
(e1@node1)3> 16:11:30.422 [info] global: Name conflict terminating {erdico_counter,<10869.102.0>}
(e1@node1)3> erdico_counter:inc().  
{ok,22}
e2@node1

stolen@node1:~/erdico$ ERL_LIBS=deps erl -pa ebin -s erdico -setcookie erdico -sname e2 -erdico port 2082
Erlang/OTP 17 [erts-6.1] [source-d2a4c20] [64-bit] [async-threads:10] [hipe] [kernel-poll:false]
..............
(e2@node1)1> erdico_counter:inc().
{ok,1}
(e2@node1)2> net_adm:ping(e1@node1).
pong
(e2@node1)3> 16:11:30.423 [error] Supervisor erdico had child counter started with erdico_counter:start_link() at <0.102.0> exit with reason killed in context child_terminated
(e2@node1)3> erdico_counter:inc().  
{ok,21}

Cowboy и счетчик

Ну, это просто.

Работает!

stolen@node2:~$ curl node1:2081
value = 1
stolen@node2:~$ curl node1:2082
value = 2
stolen@node2:~$ curl node1:2081
value = 3
stolen@node2:~$ curl node1:2082
value = 4
stolen@node2:~$ curl node1:2082
value = 5
stolen@node2:~$ curl node1:2081
value = 6

Простая часть поста подошла к концу.

access.log

Lager — примерно единственный живой фреймворк для записи логов в эрланге. К сожалению, ему не хватает лаконичной документации с примерами из жизни. Надеюсь, этот пост станет таким примером хотя бы для рунета.
Кроме того, интернет не очень щедр на примеры записи access.log для cowboy. Это я надеюсь тоже исправить данным постом.

lager tracing

В конфигурации lager события распределяются по файлам согласно их важности (severity). Нам это не подходит, потому что для записи логов HTTP-сервера нужно явно направить событие в конкретный лог. Для этого в lager есть специальный запил под названием tracing, которым мы и воспользуемся.
На этом этапе нам уже понадобится конфиг-файл.
Здесь мы перенаправим креш-лог, создадим лог с более-менее значимыми событиями, а также объявим access.log, который будет писаться только через трейсинг, когда в метаданных события будет {tag, access}. В формате все более-менее понятно — строки вставляются как строки, а атомы заменяются на значения из метаданных по соответствующим ключам (далее расскажу, как этим пользоваться).
Для всех настроенных логов включена ротация в полночь с сохранением 5 старых файлов. Ротация по размеру лога отключена.

erdico.config

Файл целиком

[
    {lager, [
            {crash_log, "logs/crash.log"}, {crash_log_size, 0}, {crash_log_date, "$D0"}, {crash_log_count, 5},
            {error_logger_hwm, 20},
            {async_threshold, 30}, {async_threshold_window, 10},
            {handlers, [
                    {lager_file_backend, [{file, "logs/events.log"}, {level, notice}, {size, 0}, {date, "$D0"}, {count, 5}, {formatter, lager_default_formatter},
                                          {formatter_config, [date, " ", time," [",severity,"] ",pid, " ", message, "n"]}]},
                    {lager_file_backend, [{file, "logs/access.log"}, {level, none}, {size, 0}, {date, "$D0"}, {count, 5}, {formatter, lager_default_formatter},
                                          {formatter_config, [date, " ", time," [",severity,"] ",pid, " ", peer, " "", method, " ", url, "" ", status, "n"]}]}
                    ]},
            {traces, [
                    {{lager_file_backend, "logs/access.log"}, [{tag, access}], info}
                    ]}
            ]}
    ].
Запускаем, проверяем

stolen@node1:~/erdico$ ERL_LIBS=deps erl -pa ebin -config erdico.config -s erdico -setcookie erdico -sname e1 -erdico port 2081
Erlang/OTP 17 [erts-6.1] [source-d2a4c20] [64-bit] [async-threads:10] [hipe] [kernel-poll:false]

Eshell V6.1  (abort with ^G)
(e1@node1)1> lager:log(notice, [{pid, self()}], "hello ~s ~w", [world, 2.7]).
ok
(e1@node1)3> lager:log(info, [{pid, self()}, {tag, access}, {peer, "fake"}, {status, 418}], "", []).
ok

Результат:

stolen@node1:~/erdico$ cat logs/events.log 
2014-06-28 17:22:43.994 [notice] <0.39.0> hello world 2.7
stolen@node1:~/erdico$ cat logs/access.log 
2014-06-28 17:25:57.286 [info] <0.39.0> fake "Undefined Undefined" 418
cowboy onresponse hook

Очень хочется свалить максимум работы на уже готовый код. Поэтому вместо вставки логирования в каждое место, вызывающее cowboy_req:reply/4, мы вставим логирование в сам ковбой. Для этого, как оказалось, даже есть специальное место в виде хука на ответ. Документация — ваш друг.
Решение «в лоб» выглядит так и пишет

годные логи

stolen@node1:~/erdico$ cat logs/access.log 
2014-06-28 17:54:44.429 [info] <0.103.0> 10.0.2.4 "GET http://node1:2081/" 200
2014-06-28 17:54:46.085 [info] <0.104.0> 10.0.2.4 "GET http://node1:2081/" 200
non-blocking hook

Те, кто прочитал документацию по onresponse-хуку, уже могли догадаться, что в описанном выше решении ответ будет послан строго после записи в лог.
Это значит, что подзалипший логгер (диск, например, медленно работает) увеличит время ответа.
А еще это значит, что если мы решим писать в лог время обработки запроса, то оно не будет включать время, потраченное на логирование, и может сильно разойтись с точкой зрения клиента.
Поэтому мы еще раз посмотрим документацию и переделаем хук так, чтобы запись в лог производилась строго после отсылки ответа клиенту.

Более правильный хук

access_log_hook(Status, Headers, Body, Req) ->
    {[{PeerAddr, _}, Method, Url], Req2} = lists:mapfoldl(fun get_req_prop/2, Req, [peer, method, url]),
    {ok, ReqReplied} = cowboy_req:reply(Status, Headers, Body, Req2),
    PeerStr = inet_parse:ntoa(PeerAddr),
    lager:info([{tag, access}, {peer, PeerStr}, {method, Method}, {url, Url}, {status, Status}], ""),
    ReqReplied.

get_req_prop(Prop, Req) ->
        cowboy_req:Prop(Req).
отключаемый лог

Для случаев, когда хочется померяться RPS-ами, нужно иметь возможность не писать строчку в лог на каждый запрос.
Пусть хука не будет, если в конфигурации явно сказано, что лог не нужен.
После этого патча добавление в строку запуска параметра «-erdico log_access false» отключает лог.

Релизы и relx

Релизы — наверное, одна из самых больших болей в разработке на Эрланге. relx сделан для того, чтобы избавить пользователя от этой боли. (Спойлер: не совсем)

Просто сборка релиза

После заполнения этого файла вызов make соберет релиз в каталоге _rel:

relx.config

{release, {erdico, "0.1"}, [erdico]}.
{extended_start_script, true}.

У меня без расширенного стартового скрипта не взлетело, но он нам все равно понадобится позже.

Запуск релиза

stolen@node1:~/erdico$ _rel/erdico/bin/erdico console
Exec: /home/stolen/erdico/_rel/erdico/erts-6.1/bin/erlexec -boot /home/stolen/erdico/_rel/erdico/releases/0.1/erdico -env ERL_LIBS /home/stolen/erdico/_rel/erdico/releases/0.1/lib -config /home/stolen/erdico/_rel/erdico/releases/0.1/sys.config -args_file /home/stolen/erdico/_rel/erdico/releases/0.1/vm.args -- console
Root: /home/stolen/erdico/_rel/erdico
/home/stolen/erdico/_rel/erdico
Erlang/OTP 17 [erts-6.1] [source-d2a4c20] [64-bit] [async-threads:10] [hipe] [kernel-poll:false]

18:39:18.318 [info] Application lager started on node 'erdico@127.0.0.1'
18:39:18.321 [info] Application cowboy started on node 'erdico@127.0.0.1'
18:39:18.343 [info] Application erdico started on node 'erdico@127.0.0.1'
Eshell V6.1  (abort with ^G)
(erdico@127.0.0.1)1> 

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

Включение в релиз годных настроек

Итак, мы хотим, чтобы релиз стартовал с правильным именем ноды, а также чтобы на запуске нода подключалась к сестрам по кластеру. А еще чтобы эти и другие настройки можно было задавать в файле с понятным синтаксисом, не разваливающемся от пропущенной запятой.
Для начала все захардкодим.
Обратите внимание на опции ядра sync_nodes_optional и sync_nodes_timeout — вместе они делают так, чтобы нода при запуске подключалась к указанным сестрам и ждала от них ответа в течение 1 секунды. В течение этой секунды вызов global:sync() в счетчике блокируется, избавляя от излишних смертей на старте.
В vm.args, очевидно, можно написать и другие опции. Но если не указать -name или -sname, то релиз не стартует.
Теперь релиз можно скопировать целиком на вторую ноду, и после запуска кластер волшебным образом соберется — проверка при помощи curl пройдена. Важно, что эрланг на второй ноде не установлен, то есть, релиз самодостаточен.

Раскрытие переменных скриптом релиза

Одна из прекрасных возможностей, которые нам дает relx — раскрытие переменных. Как это происходит, можно посмотреть, найдя строку RELX_REPLACE_OS_VARS в скрипте запуска релиза _rel/erdico/bin/erdico. Все настолько просто, что даже не гибко.

Параметризованный конфиг

Параметризуем список сестр:

{sync_nodes_optional, [${CLUSTERNODES}]}

Запускать так:

RELX_REPLACE_OS_VARS=1 CLUSTERNODES=erdico@node2 _rel/erdico/bin/erdico console

Одна беда: без раскрытия переменных релиз теперь не стартует.

Хак: Нераскрытие переменных скриптом релиза

Чтобы релиз запускался и с раскрытием, и без раскрытия, я придумал такой хак. Поскольку раскрытие все равно уйдет в скрипт upstart, в котором заодно будет читаться человеческий конфиг, мы спрячем все переменные в комментарии и добавим переменную, завершающую комментарий. патч, который позволяет запускать релиз как он есть или с указанием соседних нод —

RELX_REPLACE_OS_VARS=1 CLUSTERNODES="erdico@node2, erdico@node1" NL=$'n' _rel/erdico/bin/erdico console
Комбо-хак: Раскрытие-перекрытие имени

Давайте сделаем так, чтобы релиз можно было запустить грязными руками, не конфликтуя с продакшном. Для этого нам нужно, чтобы имя ноды тоже параметризовалось. Заодно через параметризацию будем вписывать туда полное имя (с FQDN).
С одной стороны, нельзя оставить vm.args без имени ноды. С другой — предыдущий хак позволяет добавить строчку в конфиг, но не позволяет убрать. С третьей — если отдать эрлангу несколько имен, но его выбор не очень предсказуем.
Оказалось, что в vm.args все, что написано после директивы -extra, идет в отдельную секцию параметров, и ядром не читается. Этим мы и воспользуемся.
Параметризованный запуск теперь происходит так:

RELX_REPLACE_OS_VARS=1 CLUSTERNODES="'erdico@node2.example.net', 'erdico@node1.example.net'" FQDN=`hostname -f` NL=$'n' _rel/erdico/bin/erdico console

Сборка deb-пакета

Дебиан доставляет разработчику много боли. Боль начинается с кучи файликов в каталоге debian, продолжается невозможностью указать ни корень проека, ни альтернативное расположение каталога debian, ни путь для складывания собранных пакетов.
Известно, что собранные пакеты отправляются в каталог уровнем выше каталога с исходниками проекта. Отсюда следует, что закопать всю эту гадость надо глубоко.
Еще в конфиге upstart очень скудные возможности скриптования, поэтому пришлось обернуть стартовый скрипт в еще один скрипт conf_erdico.sh, который готовит годное окружение.
Оказалось, что лагер не может писать логи, расположенные под симлинком (из-за особенностей filelib:ensure_dir/1). Поэтому пришлось в конфиге вонзать хаки для замены путей к логам.
На самом деле, раз уж все равно написан внешний скрипт, можно было уже все замены в конфигах делать при помощи sed. Пусть пока остается как есть, будет proof-of-concept.

Использованные при пакетировании хитрости

(весь коммит)

  • сделан каталог для сборки pkg/erdico, в который положен каталог debian со всеми потрохами и дополнительные файлы
  • Makefile верхнего уровня приобрел цель deb, которая ссылается на Makefile в каталоге пакета
  • Makefile в каталоге пакета для цели all (сборка) вызывает make на верхнем уровне для сборки актуального релиза
  • Чтобы upstart был доволен, скрипту запуска отдается параметр foreground. При использовании традиционного init можно использовать араметры start, stop, ping
  • Поскольку скрипт запуска при редактировании конфигов кладет сгенерированные файлы строго рядом с оригиналами, пришлось сделать симлинки из /var/lib/erdico/
  • при вонзании хаков на раскрытие переменных в конфиге лагера были использованы особенности работы proplists
  • при помощи шелла список хостов (FQDN) в /etc/erdico.conf раскрывается в список нод (с одинарными кавычками, чтобы точно были атомы)

Собираем, устанавливаем, настраиваем, запускаем!

Первая (сборочная) машина

stolen@node1:~/erdico$ make deb
stolen@node1:~/erdico$ sudo dpkg -i pkg/erdico_0.1_amd64.deb
stolen@node1:~/erdico$ scp pkg/erdico_0.1_amd64.deb node2:
stolen@node1:~/erdico$ sudo vim /etc/erdico.conf  # CLUSTERHOSTS="node1.example.net node2.example.net"
stolen@node1:~/erdico$ sudo service erdico start
Вторая машина

stolen@node2:~$ sudo dpkg -i erdico_0.1_amd64.deb
stolen@node2:~$ sudo vim /etc/erdico.conf  # CLUSTERHOSTS="node1.example.net node2.example.net"
stolen@node2:~$ sudo service erdico start

Работает!

После перезагрузки обеих машин

stolen@node1:~$ curl node1:2080
value = 1
stolen@node1:~$ curl node2:2080
value = 2
stolen@node1:~$ curl node1:2080
value = 3
stolen@node1:~$ curl node2:2080
value = 4
stolen@node1:~$ tail -5 /var/log/erdico/access.log
2014-06-29 00:43:03.044 [info] <0.380.0> 10.0.2.4 "GET http://node1:2080/" 200
2014-06-29 00:54:34.563 [info] <0.424.0> 10.0.2.4 "GET http://node1:2080/" 200
2014-06-29 00:54:36.932 [info] <0.425.0> 10.0.2.4 "GET http://node1:2080/" 200
2014-06-29 00:56:10.709 [info] <0.383.0> 10.0.2.15 "GET http://node1:2080/" 200
2014-06-29 00:56:14.490 [info] <0.384.0> 10.0.2.15 "GET http://node1:2080/" 200

Обещанный REST

Вот же, положил.

Демо

stolen@node1:~$ curl node1:2080
value = 1
stolen@node1:~$ curl node2:2080
value = 2
stolen@node1:~$ curl node1:2080/inc/400
value = 402
stolen@node1:~$ curl node2:2080
value = 403
stolen@node1:~$ curl node1:2080
value = 404

Мораль

Жизнь — это боль.
Лагер хорош, но ему не хватает гибкости конфига (например, один раз на конфиг задать коренной каталог и опции файловых логов по умолчанию).
Ковбой хорош, но нужно понимать, как он устроен, чтобы производительность не проседала.
Дебиан хорош, но сборка пакетов под него сделана мутантами и для мутантов.
Апстарт хорош, но он слишком мало позволяет делать в конфиге сервиса, приходится выносить логику в дополнительный скрипт.
Эрланг хорош, пока не возникает нужда отдать приложение на нем на поддержку тем, кто его не знает.
Менеджеры зависимостей для эрланга есть, они работают, но у них никак не решена проблема dependency hell.
Сборка релизов в эрланге все еще доставляет боль, хоть и все меньше. Relx ждет коммитов, без которых пользоваться им все еще неудобно. Кроме того, он может сойти с ума, если есть цикл из симлинков или собранный релиз где-то в зависимостях.

Что еще можно сделать в этом приложении

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

Автор: stolen

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js