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)
⚠️ Cookies en producción

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 vs Registry

: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]
      ]
    ]
  ]
Ejercicio 13.1 Chat distribuido Intermedio

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.

Ejercicio 13.2 Leader election simple Avanzado

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 :nodedown para detectar caídas

Conexión con el proyecto final

El sistema de distribución de datos usará clustering para: