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

Задача Нужно написать настоящий сервис на эрланге, который будет работать в кластере. Кроме того, нужно максимально упростить жизнь тем, кто будет обслуживать сервис.Требования: У сервиса будет 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, [], <<"hello\n">>, 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.

© Habrahabr.ru