How to Monitor libcluster with Vigilmon
libcluster is the de-facto Elixir library for automatic cluster node discovery and formation. It supports multiple topology strategies — Gossip UDP, DNS polling, Kubernetes endpoints, EC2 tags, and custom strategies — and ensures that nodes joining or leaving your distributed Elixir application are automatically connected to the cluster without manual intervention.
Because libcluster operates at the networking and process group layer, its failures are silent from the outside. A node that fails to connect due to a misconfigured strategy continues running normally; only the cluster-aware features break. Distributed GenServers can't locate processes on other nodes. Horde registries diverge. Phoenix PubSub messages don't reach disconnected nodes. None of this surfaces as an HTTP error unless you explicitly instrument cluster state.
Vigilmon lets you expose libcluster formation health through a health endpoint and alert when node membership deviates from what you expect.
Why Monitor libcluster?
Cluster formation failures produce distributed application failures, not application errors:
- Node connection failure — a misconfigured topology strategy silently fails; the node runs but is isolated from the cluster
- Split cluster — a network partition disconnects a subset of nodes; distributed state diverges silently
- Topology strategy crash — the libcluster strategy process itself can crash; without monitoring its supervisor tree, you won't know until a scheduled reconnect attempt fails
- DNS TTL mismatch — in Kubernetes, a pod IP changes between DNS TTL cycles; libcluster can lose a node reference until the next poll
- Expected node count drift — rolling deploys or autoscaling events should produce transient membership changes; prolonged drift indicates a failed pod or misconfigured strategy
- Cookie mismatch — a node with the wrong Erlang cookie silently fails to authenticate to the cluster; libcluster marks it disconnected but reports no explicit error
Key Metrics to Track
| Metric | What it tells you | |--------|-------------------| | Connected node count | How many nodes are currently in the cluster | | Expected vs actual node count | Whether autoscaling or deploys left nodes unreachable | | Topology strategy uptime | Whether the libcluster strategy process is running | | Node connect/disconnect events | Rate of cluster membership changes | | Heartbeat from each node | Whether each node can reach Vigilmon independently | | Health endpoint response | Whether the app on each node is healthy |
Step 1: Add libcluster to Your Application
# mix.exs
defp deps do
[
{:libcluster, "~> 3.3"},
# ... other deps
]
end
Configure your topology strategy in config/runtime.exs:
# config/runtime.exs
config :libcluster,
topologies: [
my_app: [
strategy: Cluster.Strategy.Gossip,
config: [
secret: System.get_env("LIBCLUSTER_SECRET", "dev-secret"),
multicast_addr: {230, 1, 1, 251},
port: 45892,
if_addr: {0, 0, 0, 0}
]
]
]
For Kubernetes:
# config/runtime.exs
config :libcluster,
topologies: [
my_app_k8s: [
strategy: Cluster.Strategy.Kubernetes,
config: [
mode: :dns,
kubernetes_node_basename: System.get_env("K8S_NODE_BASENAME", "myapp"),
kubernetes_selector: System.get_env("K8S_SELECTOR", "app=myapp"),
kubernetes_namespace: System.get_env("K8S_NAMESPACE", "default"),
polling_interval: 10_000
]
]
]
Add libcluster to your supervision tree:
# lib/my_app/application.ex
def start(_type, _args) do
topologies = Application.get_env(:libcluster, :topologies, [])
children = [
{Cluster.Supervisor, [topologies, [name: MyApp.ClusterSupervisor]]},
MyApp.Repo,
MyAppWeb.Endpoint,
MyApp.ClusterMonitor
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
Step 2: Track Cluster Membership with Telemetry
Subscribe to :net_kernel node events and emit telemetry on every membership change:
# lib/my_app/cluster_monitor.ex
defmodule MyApp.ClusterMonitor do
use GenServer
require Logger
@poll_interval :timer.seconds(30)
@expected_node_count String.to_integer(System.get_env("CLUSTER_EXPECTED_NODES", "1"))
def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
def init(state) do
:net_kernel.monitor_nodes(true)
schedule()
{:ok, state}
end
# Node joined the cluster
def handle_info({:nodeup, node}, state) do
Logger.info("[Cluster] node UP: #{node}")
:telemetry.execute(
[:my_app, :cluster, :node_up],
%{count: 1},
%{node: node, connected: length(Node.list())}
)
{:noreply, state}
end
# Node left the cluster
def handle_info({:nodedown, node}, state) do
Logger.warning("[Cluster] node DOWN: #{node}")
:telemetry.execute(
[:my_app, :cluster, :node_down],
%{count: 1},
%{node: node, connected: length(Node.list())}
)
{:noreply, state}
end
# Periodic cluster membership report
def handle_info(:poll, state) do
connected_nodes = Node.list()
node_count = length(connected_nodes) + 1 # +1 for self
:telemetry.execute(
[:my_app, :cluster, :membership],
%{node_count: node_count, expected: @expected_node_count},
%{nodes: connected_nodes, self: node()}
)
if node_count < @expected_node_count do
Logger.warning(
"[Cluster] only #{node_count}/#{@expected_node_count} nodes connected: #{inspect(connected_nodes)}"
)
end
schedule()
{:noreply, state}
end
defp schedule, do: Process.send_after(self(), :poll, @poll_interval)
end
Attach a handler to log and forward telemetry:
# lib/my_app/cluster_telemetry.ex
defmodule MyApp.ClusterTelemetry do
require Logger
def attach do
events = [
[:my_app, :cluster, :node_up],
[:my_app, :cluster, :node_down],
[:my_app, :cluster, :membership]
]
:telemetry.attach_many("my-app-cluster", events, &handle_event/4, nil)
end
def handle_event([:my_app, :cluster, :node_up], %{count: _}, meta, _) do
Logger.info("[ClusterTelemetry] node joined: #{meta.node}, total: #{meta.connected + 1}")
end
def handle_event([:my_app, :cluster, :node_down], %{count: _}, meta, _) do
Logger.warning("[ClusterTelemetry] node left: #{meta.node}, remaining: #{meta.connected + 1}")
end
def handle_event([:my_app, :cluster, :membership], metrics, meta, _) do
Logger.debug(
"[ClusterTelemetry] cluster size: #{metrics.node_count}/#{metrics.expected} — #{inspect(meta.nodes)}"
)
end
end
Call MyApp.ClusterTelemetry.attach() in your Application.start/2.
Step 3: Build a Cluster Health Endpoint
# lib/my_app_web/controllers/health_controller.ex
defmodule MyAppWeb.HealthController do
use MyAppWeb, :controller
@expected_node_count String.to_integer(System.get_env("CLUSTER_EXPECTED_NODES", "1"))
def index(conn, _params) do
connected = Node.list()
node_count = length(connected) + 1
quorum = node_count > div(@expected_node_count, 2)
cluster_healthy = node_count >= @expected_node_count
checks = %{
cluster: %{
status: if(cluster_healthy, do: "ok", else: "degraded"),
node_count: node_count,
expected: @expected_node_count,
connected_nodes: connected,
quorum: quorum
},
libcluster: %{
status: check_libcluster_supervisor()
}
}
http_status = if cluster_healthy, do: 200, else: 503
conn
|> put_status(http_status)
|> json(%{
status: if(http_status == 200, do: "ok", else: "degraded"),
node: node(),
checks: checks
})
end
defp check_libcluster_supervisor do
case Process.whereis(MyApp.ClusterSupervisor) do
pid when is_pid(pid) -> "ok"
nil -> "error"
end
end
end
Register the route:
# lib/my_app_web/router.ex
scope "/health", MyAppWeb do
get "/", HealthController, :index
end
Step 4: Heartbeat Monitor per Node
In a distributed cluster, each node should send its own heartbeat to Vigilmon. Add a heartbeat GenServer:
# lib/my_app/cluster_heartbeat.ex
defmodule MyApp.ClusterHeartbeat do
use GenServer
require Logger
@interval :timer.minutes(1)
def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
def init(state) do
schedule()
{:ok, state}
end
def handle_info(:ping, state) do
url = System.get_env("VIGILMON_CLUSTER_HEARTBEAT_URL")
if url do
case :httpc.request(
:get,
{String.to_charlist(url), []},
[{:timeout, 5_000}],
[]
) do
{:ok, _} ->
Logger.debug("[ClusterHeartbeat] ping sent from #{node()}")
{:error, reason} ->
Logger.warning("[ClusterHeartbeat] ping failed: #{inspect(reason)}")
end
end
schedule()
{:noreply, state}
end
defp schedule, do: Process.send_after(self(), :ping, @interval)
end
Add to your supervision tree so every node runs its own heartbeat process.
Step 5: Set Up Monitoring in Vigilmon
HTTP Monitor (cluster health endpoint)
- Sign in at vigilmon.online
- Click New Monitor → HTTP
- Set URL to
https://your-app.example.com/health - Expected status:
200 - Body must contain:
"status":"ok" - Check interval: 60 seconds
For multi-node deployments on Fly.io or Kubernetes, add a separate HTTP monitor per node:
https://myapp.fly.dev/health # primary region
https://lhr.myapp.fly.dev/health # secondary region
Each monitor alerts independently, letting you pinpoint whether a cluster failure is node-specific or global.
Heartbeat Monitor (per-node liveness)
- Click New Monitor → Heartbeat
- Name:
Cluster Node Liveness — [node name] - Expected interval: 2 minutes
- Copy the URL → set as
VIGILMON_CLUSTER_HEARTBEAT_URLin your environment
A missed heartbeat on any node means that node is either down or isolated from the internet — even if the other nodes are healthy.
Step 6: Alerting
In Vigilmon, configure Notifications → New Channel:
Slack for cluster degradation:
🔴 DOWN: Cluster Health — MyApp
Monitor: /health returned cluster.status = "degraded"
Check: node_count vs expected in the response body
Action: kubectl get pods / fly status to identify disconnected nodes
PagerDuty for heartbeat miss:
A missed heartbeat means a node is completely unreachable. Configure a high-priority PagerDuty integration in Vigilmon for heartbeat monitors — these warrant immediate response.
Quorum alert:
Configure a second HTTP monitor that checks the response body for "quorum":true:
Alert: body does not contain '"quorum":true'
Meaning: fewer than half of expected cluster nodes are reachable — distributed features are unavailable
Common libcluster Issues and Fixes
Gossip strategy not discovering nodes on the same host:
# Use epmd strategy for local multi-node development
config :libcluster,
topologies: [
dev: [
strategy: Cluster.Strategy.Epmd,
config: [
hosts: [:"node1@127.0.0.1", :"node2@127.0.0.1"]
]
]
]
Kubernetes DNS strategy not finding pods:
# Verify the DNS headless service resolves
kubectl exec -it <pod> -- nslookup myapp.default.svc.cluster.local
# Should return one IP per pod — if not, check headless service config
Cookie mismatch preventing connection:
# In releases, set the Erlang cookie via env var
# rel/env.sh.eex
export RELEASE_COOKIE="${RELEASE_COOKIE:-dev-cookie}"
libcluster supervisor not starting:
# Verify topologies are configured before the supervisor starts
topologies = Application.get_env(:libcluster, :topologies, [])
IO.inspect(topologies, label: "libcluster topologies")
# Empty list = no strategies configured = nodes never connect
What You've Built
| What | How |
|------|-----|
| Node event tracking | :net_kernel.monitor_nodes/1 with telemetry on join/leave |
| Periodic membership report | GenServer polling Node.list() vs expected count |
| Cluster health endpoint | /health reporting node count, quorum, libcluster supervisor status |
| Per-node heartbeat | GenServer pinging Vigilmon every minute from each node |
| HTTP monitor | Vigilmon checks endpoint for "status":"ok" |
| Heartbeat monitor | Vigilmon detects silent node failure via missed heartbeat |
| Quorum alerting | Body check for "quorum":true catches split-cluster scenarios |
libcluster handles the mechanics of connecting your cluster. Vigilmon tells you when the result isn't what you expected.
Next Steps
- Add per-strategy health checks (e.g., verify the Kubernetes DNS endpoint resolves before reporting cluster healthy)
- Export telemetry to Grafana for node membership history and event rate dashboards
- Use Vigilmon incident history to correlate cluster events with deploy timestamps
- Consider adding a monitor for your Erlang Port Mapper Daemon (epmd) endpoint to catch port conflicts on cluster formation
Get started free at vigilmon.online.