Nodos y Clustering
Conecta múltiples instancias de la BEAM para crear sistemas distribuidos. ⏱️ 2.5 horas
Distribución nativa en la BEAM
Una de las características más poderosas de Erlang/Elixir es la capacidad de conectar múltiples nodos (instancias de la BEAM) y comunicarse entre ellos de forma transparente.
Enviar un mensaje a un proceso en otro nodo usa exactamente la misma sintaxis que enviar localmente. La BEAM se encarga del resto.
Iniciando nodos
Nombres cortos (sname)
Para desarrollo local y redes donde todos los nodos comparten dominio:
# Terminal 1
$ iex --sname node1
iex(node1@hostname)>
# Terminal 2
$ iex --sname node2
iex(node2@hostname)>
Nombres largos (name)
Para producción y redes distribuidas:
# Con IP específica
$ iex --name [email protected]
# Con FQDN
$ iex --name [email protected]
Conectando nodos
# Desde node2, conectar a node1
iex(node2@hostname)> Node.connect(:node1@hostname)
true
# Ver nodos conectados
iex(node2@hostname)> Node.list()
[:node1@hostname]
# Ver tu propio nombre
iex(node2@hostname)> node()
:node2@hostname
Cookies de seguridad
Los nodos solo se conectan si comparten el mismo cookie.
Por defecto se genera uno aleatorio en ~/.erlang.cookie.
# Especificar cookie al iniciar
$ iex --sname node1 --cookie mi_secreto_compartido
# O en runtime (antes de conectar)
Node.set_cookie(:mi_secreto_compartido)
El cookie es texto plano y se transmite sin cifrar. En producción, usa siempre VPN o red privada entre nodos. El cookie no es seguridad real, es más bien un "estás seguro de que quieres conectar estos nodos".
Comunicación entre nodos
Ejecutar código en otro nodo
# Spawn en nodo remoto
pid = Node.spawn(:node1@hostname, fn ->
IO.puts("Ejecutando en #{node()}")
end)
# Ejecutar función de módulo en nodo remoto
:rpc.call(:node1@hostname, Enum, :sum, [[1, 2, 3]])
# => 6
Enviar mensajes a procesos remotos
# Si conoces el PID remoto
send(remote_pid, {:precio, "BTCUSD", 67543.21})
# Si el proceso tiene nombre registrado globalmente
send({PriceServer, :node1@hostname}, {:tick, data})
# GenServer.call a proceso remoto
GenServer.call({PriceServer, :node1@hostname}, :get_price)
Global: registro de nombres entre nodos
El módulo :global permite registrar nombres que son
visibles en todo el cluster:
# Registrar un proceso globalmente
:global.register_name(:price_master, self())
# Encontrar desde cualquier nodo
pid = :global.whereis_name(:price_master)
# Enviar mensaje por nombre global
send(:global.whereis_name(:price_master), :ping)
# O usar {:global, name}
GenServer.call({:global, :price_master}, :get_state)
:global es para nombres únicos en todo el cluster.
Registry es solo local al nodo pero más eficiente.
Para registros distribuidos con más features, considera
:pg (process groups) que veremos en el siguiente capítulo.
Monitoreando nodos
defmodule ClusterMonitor do
use GenServer
require Logger
def start_link(_) do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end
@impl true
def init(:ok) do
# Suscribirse a eventos de nodos
:net_kernel.monitor_nodes(true)
{:ok, %{nodes: Node.list()}}
end
@impl true
def handle_info({:nodeup, node}, state) do
Logger.info("Node connected: #{node}")
{:noreply, %{state | nodes: [node | state.nodes]}}
end
@impl true
def handle_info({:nodedown, node}, state) do
Logger.warn("Node disconnected: #{node}")
{:noreply, %{state | nodes: List.delete(state.nodes, node)}}
end
end
Ejemplo: Cluster de precio distribuido
defmodule DistributedPriceCache do
use GenServer
require Logger
@impl true
def init(_) do
:net_kernel.monitor_nodes(true)
{:ok, %{prices: %{}, peers: []}}
end
# Actualizar precio y propagar a peers
def handle_cast({:update, symbol, precio, propagate: propagate}, state) do
new_prices = Map.put(state.prices, symbol, precio)
# Propagar a otros nodos (si no es ya una propagación)
if propagate do
Enum.each(Node.list(), fn peer ->
GenServer.cast(
{__MODULE__, peer},
{:update, symbol, precio, propagate: false}
)
end)
end
{:noreply, %{state | prices: new_prices}}
end
# Cuando un nuevo nodo se conecta, sincronizar
@impl true
def handle_info({:nodeup, node}, state) do
Logger.info("Syncing prices to #{node}")
# Enviar todos nuestros precios al nuevo nodo
Enum.each(state.prices, fn {symbol, precio} ->
GenServer.cast(
{__MODULE__, node},
{:update, symbol, precio, propagate: false}
)
end)
{:noreply, state}
end
@impl true
def handle_info({:nodedown, node}, state) do
Logger.warn("Node #{node} disconnected")
{:noreply, state}
end
end
libcluster: Formación automática de clusters
En producción, no quieres conectar nodos manualmente.
libcluster automatiza la formación del cluster:
# mix.exs
{:libcluster, "~> 3.3"}
# config/config.exs
config :libcluster,
topologies: [
k8s: [
strategy: Cluster.Strategy.Kubernetes,
config: [
kubernetes_selector: "app=mi-app",
kubernetes_node_basename: "mi-app"
]
]
]
# Para desarrollo local con DNS
config :libcluster,
topologies: [
local: [
strategy: Cluster.Strategy.Epmd,
config: [
hosts: [:node1@localhost, :node2@localhost]
]
]
]
Crea un sistema de chat simple donde:
- Usuarios se registran globalmente con
:global - Pueden enviar mensajes a usuarios en cualquier nodo
- Broadcast a todos los usuarios del cluster
Pruébalo con 2-3 nodos IEx conectados.
Implementa un sistema donde:
- Un solo nodo es "leader" en cualquier momento
- El leader se registra como
:global.register_name(:leader, pid) - Si el leader cae, otro nodo toma el rol
- Usa
:nodedownpara detectar caídas
Conexión con el proyecto final
El sistema de distribución de datos usará clustering para:
- Escalado horizontal: añadir nodos para más capacidad
- Alta disponibilidad: si un nodo cae, otros continúan
- Distribución geográfica: nodos cerca de clientes para menor latencia
- libcluster: formación automática en Kubernetes