Brincando com Erlang nodes: eclero
- 11 minutes read - 2311 wordsEste post faz parte de uma série de outros posts relacionados a como usar Erlang distribution protocol.
Vamos iniciar alguns trabalhos para explorar como desenvolver, testar e experimentar utilizando nodes Erlang e uma aplicação distribuída.
Na primeira parte dos trabalhos vou contar um pouco sobre o design, detalhes de implementação e testes. A intenção foi implementar uma aplicação simples mas que utilize diversos conceitos e recursos do ambiente podendo ser utilizada para futuras experimentações.
O objetivo da aplicação é receber um request HTTP na qual deve responder com sucesso caso todos os nós do ambiente estejam operacionais. Caso algum nó da solução esteja indisponível a aplicação deve retornar um erro com o respectivo HTTP status code.
Alguns cenários de exemplos:
- Com todos os nós operacionais as requests HTTP são respondidas com code status 200 OK
- Caso um nó entre em falha, as requests retornam code status 503 Service Unavailable
- Mais nós em falha.
A aplicação foi nomeada de ’eclero’ por falta de um nome melhor. E a implementação foi feita em Erlang/OTP. É importante ressaltar que o único motivo de utiizar Erlang neste projeto foi para usar o framework de testes chamado common_test. Poderia ter utilizado Elixir e os resultados seriam os mesmos.
Todo o código fonte foi disponibilizado aqui: joaohf/eclero.
Antes de desmembrar o projeto é importante definirmos alguns requisitos (funcionais e não funcionais):
- Cada nó necessita detectar e ser notificado de qualquer falha dos outros nós
- O cluster de nós Erlang deve ser configurado utilizando algum tipo de configuração vinda do ambiente
- O ambiente de execução é Linux
- Desejável poder rodar em um ambiente embarcado com o mínimo de recursos necessários
- Mínimo de 3 nós para a solução funcionar
Design geral
O deployment da solução vai ocorrer em um ambiente simulado contendo no mínimo 3 nós Erlang e cada nó com uma instância da aplicação eclero:
A aplicação eclero possui as seguintes responsabilidades:
- detectar se algum nó falhou
- decidir se o cluster está em um estado consistente
- prover uma interface HTTP
Para isso vamos dividir a aplicação nos seguintes blocos:
detector
, responsável por detectar se algum nó falhoudecision
, recebe eventos de node up e node down vindas do detector, e decide se o cluster está saudável (health) ou nãohealth
, cliente utilizando Erlang RPC para consultar o estado de saúde de todos os nósint_http
, interface HTTP exportando alguns endpoints para consulta.
O código fonte foi organizado seguindo a estrutura OTP com os seguintes módulos no diretório src:
- ecleroapp, implementa o behaviour _application
- eclero_sup, supervisor principal da aplicação
- eclerodecision_server: _gen_server para o bloco decision
- eclerodetector_server: _gen_server para receber eventos do detector de falhas (aten)
- eclero_detector: behaviour para facilitar testes e possível troca de detector
- eclero_health: cliente utilizando Erlang RPC
- eclero_http: implementa as callbacks necessárias para a interface HTTP
eclero_sup é um supervisor no qual possui dois workers sob seu comando: eclero_decision_server e eclero_detector_server.
Todos os testes foram implementados utilizando commontest e estão contidos no diretório _test.
Já para gestão de build, a ferramenta escolhida foi o rebar3 utilizando dois plugins:
- rebar3_lint, para chamar o elvis e fazer uma checagem de melhores práticas
- coveralls, para enviar o resultados de cobertura dos testes para o serviço coveralls.io
Neste projeto, rebar3 é utilizado para:
- gestão de dependências
- compilação
- execução dos testes
- geração de release
O arquivo rebar.config possui todas as configurações feitas.
Também configuramos um CI (Continuous Integration) apenas para fazer uma checagem e executar os testes a cada commit. Utilizamos o serviço travis-ci integrado com o github. Toda a configuração foi feita no arquivo travis.yml.
Implementação
Detector de falhas
O módulo eclero_detector_server foi implementado utilizando um gen_server. Na callback de inicialização registramos os nós que temos interesse em sermos notificados quando algum evento ocorre:
Inicialização do decision server. https://github.com/joaohf/eclero/blob/mignon-20/apps/eclero/src/eclero_detector_server.erl 1init(_Args) ->
2 process_flag(trap_exit, true),
3
4 Nodes = application:get_env(eclero, nodes, []),
5 Module = application:get_env(eclero, detector_module, eclero_detector),
6
7 {ok, #state{detector_module = Module}, {continue, {register, Nodes}}}.
8
9handle_continue({register, RNodes}, #state{detector_module = DM,
10 nodes = Nodes} = State) ->
11 {ok, INodes} = register_interest(DM, RNodes, Nodes),
12
13 {noreply, State#state{nodes = INodes}, {continue, up}};
A callback register_interest/3
é um wrapper para as funções do aten. Estamos
usando uma aplicação chamada aten na qual
implementa o algorítimo utilizado para detectar se algum nó está vivo ou não.
Um ponto interessante é que a callback init/1
obtem as configurações do
ambiente, inicializa o estado a aplicação e depois retorna uma tupla contendo o
atom ‘continue’. Isso vai fazer com que o gen_server execute a callback
handle_continue/2
e continue a inicialização do gen_server.
Quando há qualquer mudança no estado dos nós, aten envia uma mensagem para o
processo local registrado como eclero_detector_server. Então devemos implementar
a callback handle_info/2
para tratar este tipo de mensagem. Caso contrário o
server irá descartar estas mensagens importantes.
1handle_info({node_event, Node, Event}, #state{nodes = Nodes} = State) ->
2 Nodes0 = update_interest(Nodes, Node, Event),
3
4 ok = eclero_decision_server:node_status(Node, Event),
5
6 {noreply, State#state{nodes = Nodes0}}.
Com o evento e o nome do nó, avisamos o eclero_decision_server no qual vai tomar a decisão e registrar a informação do evento.
Também implementamos a callback terminate/2
para desregistrar o monitoramento
do nó feito pelo aten.
1terminate(_Reason, #state{detector_module = DM, nodes = Nodes}) ->
2 deregister_interest(DM, Nodes),
3 ok.
Servidor de decisão
Já no módulo eclero_decision_server a principal responsabilidade é responder se o cluster está operacional ou não. O requisito principal é apenas responder se um cluster está operacional se todos os nós estão operacionais.
Um ponto interessante é que o record state possui a quantidade de nós em estado
up e down, bem como um set
(sets) com
todos os nós que participal do cluster:
1-record(state, {nodes_up = 0 :: non_neg_integer(),
2 nodes_down = 0 :: non_neg_integer(),
3 nodes = sets:new() :: sets:set(atom())}).
As funções do módulo sets são chamadas na callback handle_cast/3
para
adicionar ou remover os nós:
1handle_cast({node_status, Node, down}, #state{nodes_up = Up,
2 nodes_down = Down,
3 nodes = Nodes} = State) ->
4 {noreply, State#state{nodes_up = decrease(Up),
5 nodes_down = Down + 1,
6 nodes = sets:del_element(Node, Nodes)}};
7
8handle_cast({node_status, Node, up}, #state{nodes_up = Up,
9 nodes_down = Down,
10 nodes = Nodes} = State) ->
11 {noreply, State#state{nodes_up = Up + 1,
12 nodes_down = decrease(Down),
13 nodes = sets:add_element(Node, Nodes)}}.
Algumas APIs são exportadas por este módulo para consultar o estado de saúde do cluster. Em especial, a API is_health/0 e is_health/1 mostram dois comportamentos:
APIs para consultar o estado do cluster https://github.com/joaohf/eclero/blob/mignon-20/apps/eclero/src/eclero_decision_server.erl1is_health() ->
2 gen_server:call(?MODULE, {is_health}).
3
4is_health(Nodes) ->
5 gen_server:multi_call(Nodes, ?MODULE, {is_health}).
A função is_health/1
, aceita uma lista dos nós passando para a função
gen_server:multi_call/3
na qual faz uma chamada RPC para todos os nós e
aguarda o retorno dos resultados.
Já quando chamada com arity 0, ou seja is_health/0
, o nó local responde a
chamada.
Health API
eclero utiliza uma interface HTTP para responder as requests vindas. Mas o protocolo HTTP, neste caso, é apenas uma interface para alguma API interna da aplicação.
O módulo eclero_health exporta uma API (get/0
) mais simples para a interface
HTTP usar, coletando a resposta de todos os nós e retornando as respostas.
1get() ->
2 {ok, Nodes} = eclero_decision_server:nodes(),
3
4 {Replies, _BadNodes} = eclero_decision_server:is_health(Nodes),
5
6 {ok, Replies}.
Interface HTTP
E finalmente temos a interface HTTP implementada no módulo eclero_http, no qual implementamos algumas callbacks necessárias para o cowboy funcionar.
Quando há alguma request HTTP, o primeiro passo é verificar se o cluster está
operacional. cowboy disponibiliza a callback service_available/2
na qual vamos
para perguntar ao decision server se está tudo Ok.
1service_available(Req, State) ->
2 Res = eclero_decision_server:is_health(),
3 {Res, Req, State}.
Caso a função eclero_decision_server:is_health/0
retorne true, a request vai
prosseguir.
O próximo passo da request é processar a request. Isso é feito na função
to_text/2
:
1to_text(Req, State) ->
2 Body = to_text(eclero_health:get()),
3 {Body, Req, State}.
eclero_health:get/0
vai obter e retornar a resposta de todos os nós do cluster
e a função to_text/1
transformar a resposta em texto.
Adicionei um pequeno test unitário para a função privada to_text/1
, utilizando
eunit:
1to_text({ok, Results}) ->
2 Fun = fun({Node, Value}, AccIn) ->
3 L = io_lib:format("~s:~w", [Node, Value]),
4 [L | AccIn]
5 end,
6 L0 = lists:foldl(Fun, [], Results),
7 L1 = lists:join(",", L0),
8 list_to_binary(L1).
9
10
11-ifdef(TEST).
12-include_lib("eunit/include/eunit.hrl").
13
14to_text_test() ->
15 L = to_text({ok, [{a, true}, {b, false}]}),
16 ?assertEqual(<<"b:false,a:true">>, L),
17 ok.
18
19-endif.
Queria ter certeza que a conversão está funcionando. Desta forma, com eunit, posso testar uma função privada.
Aplicação e Supervisor
E por fim o módulo eclero_app com algumas callbacks necessárias pedidas pelo behaviour application.
Na função start/2
, obtemos a configuração da porta HTTP e iniciamos a
interface HTTP. Logo após iniciamos o supervisor.
1start(_StartType, _StartArgs) ->
2
3 Port = application:get_env(eclero, http, 8000),
4
5 ok = eclero_http:start(Port),
6 eclero_sup:start_link().
Já na função stop/1
, primeiro paramos a interface HTTP (para não responder
nenhuma request a mais) e retornamos.
1stop(_State) ->
2 ok = eclero_http:stop(),
3 ok.
O módulo eclero_sup contem a criação da árvore de supervisão.
stop da aplicação https://github.com/joaohf/eclero/blob/mignon-20/apps/eclero/src/eclero_sup.erl 1init([]) ->
2 SupFlags = #{strategy => one_for_all,
3 intensity => 1,
4 period => 5},
5 Decision = #{id => eclero_decision_server,
6 start => {eclero_decision_server, start_link, []},
7 shutdown => 5000},
8 Detector = #{id => eclero_detector_server,
9 start => {eclero_detector_server, start_link, []},
10 shutdown => 5000},
11 ChildSpecs = [Decision, Detector],
12 {ok, {SupFlags, ChildSpecs}}.
Testes unitários
Não optei por utilizar testes unitários para testar o código da aplicação. Apenas usei o eunit para fazer um pequeno teste em um dos módulos.
A estratégia de testes adotada é algo mais sistêmica utilizando uma abordagem mais de fora para dentro.
Testes funcionais
Todos os testes foram implementados utilizando o framework common test. Cada arquivo implementa uma suite de testes com uma certa organização dos casos de testes, funções de setup e teardown do ambiente.
A implementação do testes foi feita em apenas um arquivo: apps/eclero/test/eclero_SUITE.erl e alguns grupos de casos de testes foram definidos:
- system: utilizando o módulo ct_slave para criação de nós Erlang adicionais, podemos testar como a aplicação eclero quando há mais de um nó
- decision: queremos apenas testar o server, o resto da aplicação não é inicializada
- detector: quermoes apenas testar o server, o resto da aplicação não é inicializada
1groups() ->
2 [
3 {system, [], [
4 {start_stop, [sequence], start_stop()},
5 {missing_nodes, [sequence], missing_nodes()},
6 {normal, [sequence], dist_tests()}
7 ]},
8 {decision, [sequence], decision_tests()},
9 {detector, [sequence], detector_tests()}
10 ].
Quando executamos os testes do grupo system
, cada teste irá executar num
ambiente como o descrito na figura abaixo:
O nó ct apenas estimula, ou seja, chama as funções ou faz requests para o cluster. Enquanto outros nós estão executando a aplicação eclero e todas as suas dependências.
Em geral, common test é um framework bastante poderoso e exige organização dos testes para não virar uma grande confusão. Algumas dicas:
- estruturar os testes por blocos funcionais organizando em grupos lógicos
- iniciar pelo grupo lógico no qual consiga testar o máximo de elementos com o mínimo de código
- utilizar as funções
init_per_group/1
eend_per_group/0
,init_per_testcase/1
eend_per_testcase/1
para fazer o setup e teardown dos grupos e casos de teste - não é necessário utilizar asserts para averiguar o resultado esperado. Usando apenas pattern match é possível verificar as expectativas
- caso deseje falhar algum testes, use as funções do ct. Exemplo:
ct:fail/1
- utilize as funções de logs quando necessário. Exemplo
ct:pal/1
- use os resultados dos relatórios de coverage e também dos logs de testes para guiar os próximos passos
Para executar os testes e também extrair as informaçoes de coverage, configuramos o rebar3 da seguinte forma:
Configuração para habilitar o coverage automático https://github.com/joaohf/eclero/blob/mignon-20/rebar.config1{cover_enabled, true}.
Assim quando executamos os comando abaixo, podemos acessar os relatórios.
rebar3 ct
: _build/test/logs/index.html
rebar3 cover
: _build/test/cover/index.html
Para facilitar, criei um alias com os seguintes comandos:
Configuração para habilitar o coverage automático https://github.com/joaohf/eclero/blob/mignon-20/rebar.config1{alias, [
2 {check, [xref,
3 {eunit, "-c"},
4 {ct, "-c"},
5 {cover, "-v --min_coverage=80"}]}
6]}.
Assim posso executar rebar3 check
e realizar todos os testes e análises
necessárias.
Testes locais
Queremos também poder executar a aplicação localmente para algum teste específico. Utilizando rebar3 é possível configurar um shell com a aplicação inicializada para podermos exercitar alguns comandos.
Adicionamos a seguinte configuração no arquivo rebar.config para iniciar um shell configurado como um nó.
Configuração para habilitar distribution node https://github.com/joaohf/eclero/blob/mignon-20/rebar.config1{dist_node, [
2 {setcookie, 'eclero'},
3 {sname, 'eclero0'}
4]}.
E para executar a aplicação: rebar3 shell
Agora é uma boa hora para chamarmos o observer e visualizar alguns outros detalhes
Com a aplicação executando, podemos enviar uma request e ver o resultado:
1 $ curl -v
2http://localhost:8000/check
3
4- Trying 127.0.0.1...
5- TCP_NODELAY set
6- Connected to localhost (127.0.0.1) port 8000 (#0)
7 > GET /check HTTP/1.1 Host: localhost:8000 User-Agent: curl/7.58.0 Accept: _/_
8 >
9 > < HTTP/1.1 200 OK < content-length: 18 < content-type: text/plain < date:
10 > Mon, 16 Dec 2019 20:40:37 GMT < server: Cowboy <
11- Connection #0 to host localhost left intact
Conclusão
Neste post, analisamos alguns aspectos da implementação da aplicação eclero. Foi uma navegada rápida nos principais pontos que julguei necessários.
Nos próximos posts vamos continuar a explorar como integrar e executar o eclero em um ambiente simulado e analisar o comportamento da solução.