skip to content
Euller Peixoto

Desenvolvedor Full Stack especializado em arquiteturas de sistemas e serviços de alta disponibilidade.

Escalando Instâncias Phoenix com Docker e Libcluster

Escalando Instâncias Phoenix com Docker e Libcluster

Requisitos Iniciais

Certifique-se de que você tenha o Erlang e Elixir instalados em sua máquina. Para este tutorial, estou usando as seguintes versões:

Erlang/OTP 26 [erts-14.2.5.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns]
Elixir 1.17.3 (compilado com Erlang/OTP 26)

Você também precisará do Docker em execução. Estou usando Docker Compose, então certifique-se de que ele também esteja instalado.

Configurando o Ambiente

Para começar, vamos criar duas instâncias Phoenix separadas:

mix phx.new first_instance --no-ecto
mix phx.new second_instance --no-ecto

Antes de rodar as instâncias, ainda há algo a ser feito. Navegue para os diretórios de ambas aplicações e configure os arquivos importantes:

Criar docker-compose.yml:

No diretório raiz, crie docker-compose.yml:

version: "3.8"
services:
app:
container_name: ${APP_NAME}
build:
context: .
volumes:
- .:/app
environment:
- APP_NAME=${APP_NAME}
- APP_PORT=${APP_PORT}
- APP_PUBSUB=${APP_PUBSUB}
- APP_CLUSTER_COOKIE=${APP_CLUSTER_COOKIE}
networks:
- example_network
restart: "on-failure"
ports:
- "${APP_PORT}:${APP_PORT}"
networks:
example_network:
external: true

Certifique-se de que o nome da rede seja o mesmo em todas as instâncias.

Criar Dockerfile:

No diretório raiz, crie Dockerfile:

FROM elixir:1.17.0-otp-27-alpine AS build
RUN apk add --no-cache build-base git
RUN apk add inotify-tools
WORKDIR /app
RUN mix local.hex --force && \
mix local.rebar --force
COPY . .
COPY entrypoint-dev.sh /usr/local/bin/entrypoint-dev.sh
RUN chmod +x /usr/local/bin/entrypoint-dev.sh
CMD ["/usr/local/bin/entrypoint-dev.sh"]

Criar entrypoint-dev.sh:

No diretório raiz, crie entrypoint-dev.sh:

#!/bin/sh
cd /app
mix deps.get
mix deps.compile
PORT=$APP_PORT elixir --sname app@$APP_NAME --cookie $APP_CLUSTER_COOKIE -S mix phx.server

Criar .env:

No diretório raiz, crie .env:

APP_NAME=example_one
APP_PORT=4000
# Certifique-se de que estes dois sejam os mesmos entre todas as instâncias
APP_PUBSUB=ExampleApp.PubSub
APP_CLUSTER_COOKIE=example_cookie

Editar dev.exs:

Para tornar ambas as instâncias acessíveis via localhost, navegue até config/dev.exs e edite a linha 12.

http: [ip: {127, 0, 0, 1}, port: 4000],
http: [ip: {0, 0, 0, 0}, port: System.get_env("APP_PORT")],

Executando as Instâncias

Abra seu terminal na pasta raiz do projeto e crie a rede que conectará os containers:

Terminal window
docker network create example_network

Finalmente, inicie os containers com o seguinte comando:

Terminal window
docker compose up -d --build

Agora, as aplicações devem estar rodando nas portas especificadas no seu arquivo .env. No meu caso, elas estão acessíveis em:

Configurando o Libcluster

Agora, vamos configurar o cluster para as instâncias.

Instalar libcluster:

Para começar, precisamos instalar o libcluster como dependência. Para isso, adicione-o ao seu arquivo mix.exs:

defp deps do
[
...
{:libcluster, "~> 3.3"}
]
end

Configurando a Topologia

Em seguida, vamos configurar a topologia. Neste exemplo, vamos usar a estratégia Gossip, que descobre automaticamente as nodes dentro de uma rede.

No arquivo config/config.exs, adicione o seguinte ao final, antes de import_config:

config :libcluster,
topologies: [
example_gossip: [
strategy: Elixir.Cluster.Strategy.Gossip,
config: [
port: 45892,
if_addr: "0.0.0.0",
multicast_addr: "255.255.255.255",
broadcast_only: true
]
]
]

Se você quiser mais controle sobre as nodes, também pode usar a Estratégia EPMD. Certifique-se de conferir a documentação do libcluster para outras opções disponíveis.

Inicializando o Libcluster

Agora, precisamos ativar o libcluster durante a inicialização da aplicação.

Adicione as seguintes linhas em lib/*_instance/application.ex:

def start(_type, _args) do
topologies = Application.get_env(:libcluster, :topologies) || []
children = [
...
{Phoenix.PubSub, name: FirstInstance.PubSub},
{Cluster.Supervisor, [topologies, [name: FirstInstance.ClusterSupervisor]]},
...

Verificando a Conexão

Agora, vamos reconstruir as instâncias e observar os logs. Execute os seguintes comandos em ambos os diretórios:

Terminal window
docker compose up -d --build
docker logs --follow instance_name

Quando ambas estiverem rodando, você deve ver algo assim em uma delas (geralmente a que iniciou primeiro):

Terminal window
[info] [libcluster:example_gossip] conectado a :app@example_two

Isso significa que nossas instâncias se conectaram com sucesso e estão prontas para interagir entre si via PubSub!

Configurando o PubSub

Agora é hora de configurar o PubSub para permitir a interação entre as instâncias.

Neste tutorial, vou separar as tarefas em duas instâncias:

  • Instância 1 enviará mensagens.
  • Instância 2 as receberá.

Mas primeiro, precisamos garantir que ambas as instâncias estejam usando o mesmo PubSub.

Para isso, abra config/config.exs em ambas as instâncias e edite a linha 22 (ou onde estiver a configuração do endpoint):

config :first_instance, FirstInstanceWeb.Endpoint,
...
pubsub_server: FirstInstance.PubSub,
pubsub_server: System.get_env("APP_PUBSUB") |> String.to_atom()
...

Em seguida, em lib/*_instance/application.ex, atualize a função de início:

def start(_type, _args) do
topologias = Application.get_env(:libcluster, :topologias) || []
children = [
...
{DNSCluster, query: Application.get_env(:first_instance, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, nome: FirstInstance.PubSub},
{Phoenix.PubSub, nome: System.get_env("APP_PUBSUB") |> String.to_atom()},
...

Configurando o sender

Esta etapa é bem simples; tudo o que precisamos fazer é chamar a função PubSub.broadcast_from/3 de algum lugar. Vamos usar o Phoenix LiveView para isso.

Crie os seguintes arquivos:

lib/first_instance_web/live/sender_live/index.ex:

defmodule FirstInstanceWeb.SenderLive.Index do
use FirstInstanceWeb, :live_view
alias Phoenix.PubSub
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_event("send_signal", _params, socket) do
pubsub_identifier = System.get_env("APP_PUBSUB") |> String.to_atom()
pid = self()
PubSub.broadcast_from(pubsub_identifier, pid, "topic", %{mensagem: "Hello from sender!"})
{:noreply, put_flash(socket, :info, "Signal sent")}
end
end

lib/first_instance_web/live/sender_live/index.html.heex:

<button phx-click="send_signal" class="bg-gray-200 px-4 py-2">Send signal</button>

Adicione a nova rota ao escopo do navegador em lib/first_instance_web/router.ex:

scope "/", FirstInstanceWeb do
pipe_through :browser
get "/", PageController, :home
live "/sender", SenderLive.Index, :index
end

Agora, visite http://localhost:4000/sender e teste clicando no botão. Se tudo estiver configurado corretamente, você verá uma mensagem de sucesso.

Configurando o Receptor

Para este tutorial, inicializarei a assinatura do PubSub junto com a aplicação, para evitar a criação de outra página. Com o PubSub, você pode enviar mensagens para qualquer página, mas apenas os usuários conectados a essa página receberão as mensagens. Ao inicializar o PubSub com a aplicação, permite que eu o use como um aplicativo de mensagens (por exemplo, RabbitMQ), habilitando interações com eventos no receptor, independentemente de haver ou não usuários conectados.

Crie o arquivo lib/second_instance_web/listener.ex com o seguinte conteúdo:

defmodule SecondInstance.PubSubListener do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
@impl true
def init(state) do
pubsub_identifier = System.get_env("APP_PUBSUB") |> String.to_atom()
Phoenix.PubSub.subscribe(pubsub_identifier, "topic")
{:ok, state}
end
@impl true
def handle_info(%{message: message}, state) do
IO.puts("Message received: #{message}")
{:noreply, state}
end
@impl true
def handle_info(_, state) do
{:noreply, state}
end
end

Finalmente, em lib/second_instance/application.ex, adicione o listener à lista de filhos:

def start(_type, _args) do
...
children = [
...
{Cluster.Supervisor, [topologies, [name: SecondInstance.ClusterSupervisor]]},
SecondInstance.PubSubListener,
...
]
...
end

Teste Final

Ótimo! Agora que você completou a configuração, o sistema deve funcionar como esperado:

  1. Abra os logs do Docker para o receptor (como example_two no meu caso) usando o seguinte comando:
Terminal window
docker logs --follow example_two
  1. Vá para a página da primeira instância (por exemplo, example_one) em seu navegador: http://localhost:4000/sender.

  2. Clique no botão para enviar o sinal. Sempre que você clicar no botão, a seguinte mensagem deve aparecer nos logs do receptor:

Message received: Hello from Sender

Isso significa que o PubSub está funcionando corretamente e as instâncias estão se comunicando via LibCluster e Phoenix PubSub. Agora, suas duas instâncias estão prontas para interagir umas com as outras, enviando e recebendo mensagens através do cluster.

Você pode usar esse método para uma variedade de casos de uso, como:

  • Notificações em tempo real: Enviar notificações entre diferentes nós em seu cluster.
  • Processamento de tarefas distribuídas: Dividir tarefas entre vários nós para processamento paralelo.
  • Aplicações de chat multi-instância: Construir um sistema de chat onde usuários conectados a diferentes nós ainda podem trocar mensagens em tempo real.
  • Atualizações ao vivo em painéis: Enviar atualizações para painéis de usuário em tempo real, mesmo que o usuário esteja conectado a um nó diferente de onde os dados se originaram.

Estou usando essa configuração com mais de 30 instâncias, nenhuma das quais está ciente da existência das outras, mas todas recebem eventos de uma instância “central”. Essa abordagem permite uma comunicação eficiente entre diferentes instâncias sem necessidade de coordenação direta entre elas.

Você pode adaptar essa configuração para atender às necessidades do seu projeto!