Pinned Post

NodePulse: so your Lightning node speaks up before it gets expensive

Bild
  A Lightning node is an odd kind of device. It sits on a shelf somewhere, a Raspberry Pi with an SSD, and as long as everything works you don’t look at it for weeks. That is exactly the problem. When LND hangs, the SSD fills up or a peer has been offline for days, nobody tells you. You only notice when a payment fails. Or worse: when an HTLC is about to expire and nobody reacts. RaspiBlitz comes with a lot, but not with alerting that actively nudges me. The ready-made solutions I looked at were either too big (Prometheus, Grafana and Alertmanager on a Pi that is supposed to run a Bitcoin node) or they ran as a service somewhere else, with access to my node data. I didn’t want either. So I built NodePulse. A small program that runs directly on the node, checks every minute whether everything is fine, and sends me a Telegram message when it isn’t. And another one once the problem is gone. On top of that there is a dashboard in the browser. In this article I explain what NodePulse...

Ein ML-Modell und eine Web-App auf zwei BEAM-Knoten trennen — der technische Bauplan

Das Problem in einem Satz

Die Web-App (Phoenix + Postgres) läuft produktiv auf einer kleinen Linux-Box ohne GPU. Das Embedding-Modell (intfloat/multilingual-e5-base, ~278 MB, XLM-RoBERTa-Backbone, EXLA-kompiliert) will Rechenleistung, die dort nicht ist. Die sitzt in einem MacBook Pro. Ziel: nur die schwere Inferenz auf den Mac auslagern, alles andere bleibt auf dem Server — und der aufrufende Code soll nicht wissen, wo gerechnet wird.

Die übliche Antwort wäre ein zweiter Service mit gRPC/REST-API, Serialisierung, Service-Discovery, Timeouts, Retries und einem zweiten Deployment-Artefakt. Auf der BEAM ist „eine andere Maschine" dagegen ein Plattform-Primitive. Was es braucht, ist keine neue Infrastruktur, sondern eine saubere Aufteilung einer Codebasis in zwei Releases und drei Knoten-Rollen.

Das mentale Modell: eine Codebasis, zwei Releases, drei Rollen

Es gibt genau eine Codebasis (ein Umbrella-Projekt) und keine zweite Sprache. Was variiert, ist nur, was ein gestarteter Knoten hochfährt. Das steuert eine einzige Umgebungsvariable, FINANCE_ROLE:

RolleKnotenStartetFINANCE_ROLE
:webLinuxPhoenix, Postgres, Jido, Klassifikations-Logikweb
:mlMacBooknur die Nx.Serving (Modell + EXLA)ml
:allDev/Testalles in einem Knoten(default)

Wichtig: Der Web-Knoten fährt die Klassifikations-Logik hoch (Label-Vektoren aus der DB, Cosine-Similarity), nur den einen schweren Schritt — das Einbetten des Textes in einen Vektor — schiebt er auf den ML-Knoten. Der ML-Knoten hat keine Datenbank, kein Phoenix, kein DATABASE_URL und kein SECRET_KEY_BASE.

Drei Bausteine machen das aus, in dieser Reihenfolge:

  1. Dependency-Partitionierung — welche Libraries landen überhaupt in welchem Release.
  2. Rollenbasierter Supervision-Tree — was jeder Knoten zur Laufzeit startet.
  3. Verteilte Nx.Serving — wie der Inferenz-Aufruf knotenübergreifend geroutet wird, ohne dass der Aufrufer es merkt.

Baustein 1: Dependencies partitionieren mit runtime: false

Das ist der Teil, der am wenigsten offensichtlich und am wichtigsten ist. Der ML-Stack (bumblebee, exla, tokenizers) bringt architektur-spezifische NIFs mit: libexla, libex_tokenizers. Diese werden für die Maschine kompiliert, auf der das Release gebaut wird. Ein auf macOS (arm64) gebautes libexla lädt auf einem Linux-x86_64-Host schlicht nicht. Es darf also gar nicht erst im Web-Release landen.

Der Hebel dafür ist runtime: false in der mix.exs der finance-App:

# apps/finance/mix.exs
defp deps do
[
{:libcluster, "~> 3.5"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
# ...
# nx läuft auf BEIDEN Knoten: der Web-Knoten braucht es für die Cosine-
# Similarity und den verteilten Nx.Serving.batched_run. Direkt deklariert,
# damit es im Web-Release bleibt, obwohl seine üblichen Quellen
# (bumblebee/exla) runtime: false sind.
{:nx, "~> 0.12"},
# Der gesamte ML-Stack läuft nur auf dem finance_ml-Knoten. runtime: false
# hält diese — und ihre arch-spezifischen NIFs (libexla, libex_tokenizers) —
# aus dem OTP-Application-Closure, sie landen also nicht im Web-Release.
{:bumblebee, "~> 0.6", runtime: false},
{:exla, "~> 0.9", runtime: false},
{:tokenizers, "~> 0.5", runtime: false, override: true},
{:jido, "~> 2.2"}
]
end

runtime: false bedeutet: Die Dependency wird zum Kompilieren benutzt, aber nicht in das OTP-Application-Closure aufgenommen, das mix release für die Boot-Reihenfolge berechnet. Sie wird also nicht automatisch gestartet und — entscheidend — standardmäßig nicht ins Release gepackt.

nx ist bewusst eine normale Dependency, denn es läuft auf beiden Knoten: Der Web-Knoten braucht Nx für die Cosine-Similarity und für den batched_run-Aufruf selbst. Nur die schwere Compilation (EXLA) und das Modell-Loading (Bumblebee) sind exklusiv für den ML-Knoten.

Zwei Release-Definitionen

Die Release-Definitionen in der Umbrella-mix.exs ziehen die Trennlinie:

# mix.exs (Umbrella-Root)
defp releases do
[
# Web-Knoten (Linux): Phoenix + DB + Klassifikations-Orchestrierung.
finance_web: [
applications: [
finance: :permanent,
finance_web: :permanent
],
include_erts: false,
steps: [:assemble, :tar]
],
# ML-Knoten (MacBook): nur Nx.Serving, kein web/DB. Schließt finance_web
# aus, sodass hier nie ein Endpoint startet. Zieht den ML-Stack explizit
# wieder herein (bumblebee/exla/tokenizers sind runtime: false in finance);
# bumblebee bringt transitiv axon/safetensors/etc. mit.
finance_ml: [
applications: [
finance: :permanent,
bumblebee: :permanent,
exla: :permanent,
tokenizers: :permanent
],
include_erts: true,
steps: [:assemble, :tar]
]
]
end

Zwei Dinge sind hier subtil:

  • finance_web enthält finance_web (Phoenix), aber NICHT den ML-Stack. Weil bumblebee/exla/tokenizers runtime: false sind, sind sie nicht im Closure von finance und landen damit nicht im Web-Release. Genau das wollen wir: keine macOS-NIFs auf dem Linux-Host.
  • finance_ml enthält finance (für die Serving-Definition und die Rollen-Logik), den ML-Stack — aber NICHT finance_web. Auf dem ML-Knoten startet nie ein Phoenix-Endpoint. Indem wir bumblebee, exla und tokenizers explizit in applications: listen, holen wir sie trotz runtime: false zurück in genau dieses Release.

include_erts: warum die zwei Releases sich unterscheiden

  • finance_web: include_erts: false. Das Release bringt keine Erlang-Runtime mit; der Linux-Host muss eine kompatible Erlang/Elixir-Version bereitstellen.
  • finance_ml: include_erts: true. Der ML-Knoten läuft auf derselben Maschine, auf der er gebaut wird (dem Mac), also ist das Bundling der ERTS sicher — und es vermeidet die Abhängigkeit davon, welches Erlang der Run-Ordner über asdf/PATH auflöst. Ein Mismatch dort führt zu einem load_failed von kernel/stdlib beim Boot.

Die Konsequenz für den Build: pro Architektur einmal bauen

Weil NIFs und (bei finance_ml) die ERTS nicht portabel sind, gilt: Jedes Release wird auf seiner Ziel-Architektur gebaut. finance_web auf einem Linux-x86_64-Host, finance_ml auf macOS-arm64. Cross-Compiling ist kein Pfad, den dieses Setup geht.

Ein Stolperstein beim Bauen: mix release --overwrite lässt stale App-Verzeichnisse stehen (z.B. exla, das aus finance_web ausgeschlossen ist). Deshalb wird das Release-Verzeichnis vor jedem Build geleert:

release_web:
rm -rf _build/prod/rel/finance_web
MIX_ENV=prod mix release finance_web

release_ml:
rm -rf _build/prod/rel/finance_ml
MIX_ENV=prod mix release finance_ml

Baustein 2: Der rollenbasierte Supervision-Tree

Zur Laufzeit liest die Application.start/2 die Rolle und setzt den Supervision-Tree aus drei Teilen zusammen. Es ist dieselbe Binary-Logik in beiden Releases — nur die Rolle entscheidet, welche Kinder starten:

# apps/finance/lib/finance/application.ex
def start(_type, _args) do
role = role()

children =
cluster_children() ++ web_children(role) ++ serving_children(role)

case Supervisor.start_link(children, strategy: :one_for_one, name: Finance.Supervisor) do
{:ok, _} = ok ->
if role in [:web, :all] do
if Application.get_env(:finance, :seed_default_labels, true) do
Finance.Transactions.ensure_default_labels()
end

ensure_classification_agent()
end

ok

other ->
other
end
end

defp role, do: Application.get_env(:finance, :role, :all)

Die drei Helfer machen die Trennung explizit:

# Alles außer der Nx.Serving. Die Klassifikations-Logik (Label-Embeddings,
# Cosine-Similarity) lebt hier — sie lagert nur den Embedding-Aufruf an den
# ml-Knoten via verteiltem Nx.Serving.batched_run aus.
defp web_children(:ml), do: []

defp web_children(_role) do
[
Finance.Repo,
{Phoenix.PubSub, name: Finance.PubSub},
Finance.Jido
]
end

# Die Nx.Serving (Modell-Load + EXLA). Startet auf dem ml-Knoten, und auf :all,
# wenn ein echter Classifier konfiguriert ist (Bumblebee in dev, Stub → nichts).
defp serving_children(:web), do: []
defp serving_children(:ml), do: [Finance.Classifier.Bumblebee]

defp serving_children(:all) do
case Application.get_env(:finance, :classifier) do
nil -> []
Finance.Classifier.Stub -> []
module -> [module]
end
end

Lies das als Matrix:

Kind:web:ml:all
Finance.Repo (Postgres)✅❌✅
Phoenix.PubSub, Jido✅❌✅
Nx.Serving (Bumblebee)❌✅(config-abh.)
Default-Labels seeden✅❌✅
Classification-Agent starten✅❌✅

Der Web-Knoten hat also die volle App, aber keine Nx.Serving. Der ML-Knoten hat nur die Nx.Serving. Im Dev/Test (:all) läuft alles in einem Prozess — mit dem Stub-Classifier in Tests, sodass kein Modell geladen werden muss.

Baustein 3: Verteilte Nx.Serving — Location-Transparenz

Jetzt die Frage, die alles zusammenhält: Wenn der Web-Knoten keine Serving startet, wie ruft sein Klassifikations-Code die Inferenz auf dem ML-Knoten auf?

Die Antwort: gar nicht anders als lokal. Nx.Serving ist von Grund auf verteilungs-transparent. Eine gestartete Serving registriert sich unter ihrem Namen in einer Prozessgruppe, die clusterweit sichtbar ist. batched_run/2 löst den Namen zu einem Serving-Mitglied auf — egal ob lokal oder auf einem anderen Knoten — und routet die Eingabe dorthin. Der aufrufende Code ist in beiden Fällen identisch:

# apps/finance/lib/finance/classifier/bumblebee.ex
#
# Einziger Einstiegspunkt zur Nx.Serving — prependet den E5-Prefix.
# Die Serving kann auf einem entfernten Knoten laufen. Ist dieser nicht
# erreichbar, *exitet* batched_run mit :noproc statt zu raisen. Wir übersetzen
# das in einen getaggten throw, damit classify/1 {:error, :classifier_unavailable}
# melden kann — abgegrenzt von einem echten Fehler.
defp embed(text, prefix) do
Nx.Serving.batched_run(@serving_name, prefix <> text)
catch
:exit, reason -> throw({:classifier_unavailable, reason})
end

@serving_name ist auf beiden Knoten derselbe Atom (...Bumblebee.Serving). Über die Leitung wandert nur der String (die normalisierte Transaktion mit "query: "-Prefix); zurück kommt der Embedding-Tensor. Die ganze Matrix-Multiplikation passiert auf dem Mac.

Auf dem ML-Knoten wird die Serving ganz normal gestartet:

def start_link do
# exla/bumblebee/tokenizers sind runtime: false, werden also nicht
# auto-gestartet. Hier explizit starten — der einzige Code-Pfad, der
# Modell + EXLA-Compiler braucht.
{:ok, _} = Application.ensure_all_started(:exla)
{:ok, _} = Application.ensure_all_started(:bumblebee)

:persistent_term.erase(@label_cache_key)

repo = hf_repo(@model_repo)
{:ok, model_info} = Bumblebee.load_model(repo)
{:ok, tokenizer} = Bumblebee.load_tokenizer(repo)

serving =
Bumblebee.Text.text_embedding(model_info, tokenizer,
output_attribute: :hidden_state,
output_pool: :mean_pooling,
embedding_processor: :l2_norm,
compile: [batch_size: 1, sequence_length: 160],
defn_options: [compiler: EXLA]
)

Nx.Serving.start_link(
serving: serving,
name: @serving_name,
batch_size: 1,
batch_timeout: 100
)
end

Beachte das Application.ensure_all_started(:exla) / (:bumblebee): Weil diese Deps runtime: false sind, startet die VM sie nicht automatisch. Dieser Code-Pfad — der einzige, der das Modell und den EXLA-Compiler überhaupt braucht — startet sie explizit. Auf dem Web-Knoten wird start_link/0 nie aufgerufen, also werden EXLA/Bumblebee dort nie angefasst (und sind, wie gesagt, gar nicht erst im Release).

Der Web-Knoten braucht trotzdem Nx — aber kein EXLA

Der Web-Knoten rechnet die Cosine-Similarities lokal (Nx.dot/2 auf zwei 768-dim-Vektoren ist trivial). Dafür reicht der reine Elixir-Backend; EXLA wäre sinnlos und nicht installiert. Das setzt die runtime.exs:

# config/runtime.exs
if role == :web do
config :nx, :default_backend, Nx.BinaryBackend
end

Knoten verbinden: libcluster, Cookie, EPMD

Damit batched_run den Namen knotenübergreifend auflösen kann, müssen die Knoten ein verbundener Erlang-Cluster sein. Das übernimmt libcluster mit der statischen Epmd-Strategie. Die Peer-Namen kommen als kommaseparierte Liste aus der Umgebung:

# config/runtime.exs
cluster_hosts =
System.get_env("FINANCE_CLUSTER_HOSTS", "")
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.map(&String.to_atom/1)

if cluster_hosts != [] do
config :libcluster, :topologies,
finance: [
strategy: Cluster.Strategy.Epmd,
config: [hosts: cluster_hosts]
]
end

Der Cluster.Supervisor wird nur gestartet, wenn eine Topologie konfiguriert ist — Dev/Test bleiben dadurch single-node:

# application.ex
defp cluster_children do
case Application.get_env(:libcluster, :topologies) do
nil -> []
topologies -> [{Cluster.Supervisor, [topologies, [name: Finance.ClusterSupervisor]]}]
end
end

Die restliche Verteilungs-Plumbing sind Standard-Release-Variablen. Aus dem lokalen Zwei-Knoten-Smoke-Test (beide auf demselben Mac) im Makefile:

COOKIE = secret
WEB_NODE = finance_web@127.0.0.1
ML_NODE = finance_ml@127.0.0.1
CLUSTER_HOSTS = $(WEB_NODE),$(ML_NODE)

# ml-Knoten ZUERST starten, damit die Serving oben ist, bevor der web-Knoten
# klassifiziert. Der ml-Knoten lädt das Modell (~278 MB) und zahlt den
# EXLA-Compile beim ersten Boot.
run_ml:
FINANCE_ROLE=ml \
RELEASE_DISTRIBUTION=name \
RELEASE_NODE=$(ML_NODE) \
RELEASE_COOKIE=$(COOKIE) \
FINANCE_CLUSTER_HOSTS="$(CLUSTER_HOSTS)" \
_build/prod/rel/finance_ml/bin/finance_ml start_iex

run_web:
FINANCE_ROLE=web \
RELEASE_DISTRIBUTION=name \
RELEASE_NODE=$(WEB_NODE) \
RELEASE_COOKIE=$(COOKIE) \
FINANCE_CLUSTER_HOSTS="$(CLUSTER_HOSTS)" \
PHX_HOST=localhost PORT=4000 \
SECRET_KEY_BASE=... \
DATABASE_URL=ecto://postgres:postgres@localhost/finance_dev \
_build/prod/rel/finance_web/bin/finance_web start_iex

Das Wesentliche:

  • Gemeinsamer RELEASE_COOKIE auf beiden Knoten — das Authentifizierungs-Geheimnis des Erlang-Distribution-Protokolls. Ohne identischen Cookie verbinden sich die Knoten nicht.
  • RELEASE_DISTRIBUTION=name + Long-Names (finance_web@127.0.0.1).
  • FINANCE_CLUSTER_HOSTS listet beide Knoten, sodass libcluster sie findet.
  • Boot-Reihenfolge: ML-Knoten zuerst, damit die Serving registriert ist, bevor der Web-Knoten den ersten batched_run absetzt. (Ist sie es nicht, greift der Fallback unten — kein Crash.)

Steht eine Firewall zwischen echten Maschinen, müssen EPMD (Port 4369) und ein fester Distribution-Port offen sein (via -kernel inet_dist_listen_min/max in einer rel/vm.args.eex). Im lokalen Setup ist das nicht nötig.

Failure-Handling: „ehrlich unsicher" auf Infrastruktur-Ebene

Die kritische Frage bei jeder verteilten Inferenz: Was, wenn der andere Knoten weg ist? Eine naive verteilte App würde abstürzen oder hängen. Nx.Serving.batched_run löst hier kein raise aus, sondern einen :exit mit :noproc (kein registrierter Serving-Prozess auffindbar). Genau den fangen wir und übersetzen ihn in einen abgegrenzten Zustand statt in einen Fehler:

def classify(text) when is_binary(text) do
case label_embeddings() do
[] -> {:ok, @fallback_label}
embeddings ->
%{embedding: text_embedding} = embed(text, @query_prefix)
# ... cosine similarity gegen alle Label-Vektoren, bester Score ...
end
rescue
e -> {:error, e}
catch
# Vom embed/2 geworfen, wenn die (evtl. entfernte) Nx.Serving unerreichbar ist.
:throw, {:classifier_unavailable, _reason} -> {:error, :classifier_unavailable}
end

Das Behaviour macht diesen Zustand zu einem Vertrag:

# apps/finance/lib/finance/classifier.ex
# {:error, :classifier_unavailable} ist reserviert für "die Serving war nicht
# erreichbar" (z.B. der finance_ml-Knoten ist down). Caller behandeln das als
# "später erneut versuchen", nicht als Klassifikations-Ergebnis.
@callback classify(text :: String.t()) ::
{:ok, label :: String.t()}
| {:error, :classifier_unavailable}
| {:error, term()}

Die Folge: Ist das MacBook aus, bleibt die Transaktion unklassifiziert liegen, statt falsch einsortiert zu werden. Das ist dasselbe Prinzip wie der Similarity-Schwellwert auf der Modell-Ebene („lieber Sonstiges als selbstbewusst falsch"), nur eine Schicht tiefer: lieber pending als falsch.

Dass die Klassifikation ohnehin asynchron über einen Jido-Agenten läuft (eine Transaktion pro Durchlauf, stößt sich selbst neu an), macht den Fall billig: Ein nicht erreichbarer ML-Knoten blockiert nie einen Web-Request, und sobald der Knoten zurück ist, läuft der nächste Durchlauf einfach durch.

Migrationen im Release

Da mix im Release fehlt, laufen Ecto-Migrationen über eval auf dem Web-Knoten (der ML-Knoten hat keine DB):

bin/finance_web eval "Finance.Release.migrate()"

eval startet die Runtime-Config (also muss DATABASE_URL gesetzt sein), bootet aber nicht den Supervision-Tree — die Migration läuft gegen eine Einmal-Verbindung, die danach abgebaut wird.

Die Bilanz: was das kostet und was nicht

Was dieses Setup an „verteiltem System" kostet, ist überschaubar und steht fast vollständig in diesem Artikel:

  • ein paar Zeilen Rollen-Logik im Supervision-Tree,
  • runtime: false auf drei Deps plus zwei Release-Definitionen,
  • ein catch für den unerreichbaren Knoten,
  • eine Handvoll Env-Variablen (Cookie, Distribution, Cluster-Hosts).

Was es nicht kostet: keine API-Definition, keine Serialisierungsschicht, kein Service-Mesh, keine zweite Sprache, kein zweites Repo. Der aufrufende Code (Nx.Serving.batched_run/2) ändert sich um exakt null Zeilen zwischen „alles lokal" und „Modell auf dem Mac".

Die ehrlichen Grenzen:

  • Cross-Arch-Builds sind manuell. Jedes Release muss auf seiner Ziel-Architektur gebaut werden. Es gibt keine CI-Pipeline, die das abstrahiert — bewusst, für ein Projekt dieser Größe.
  • Statische Topologie. FINANCE_CLUSTER_HOSTS listet feste Knotennamen. Für ein dynamisch skalierendes Cluster (Kubernetes etc.) wäre eine andere libcluster-Strategie (DNS, Gossip) die richtige Wahl — hier wäre sie Ballast.
  • Ein einzelner ML-Knoten. Es gibt kein Load-Balancing über mehrere ML-Knoten. Nx.Serving würde das über die Prozessgruppe von sich aus unterstützen, aber das Setup braucht es (noch) nicht.

Der eigentliche Punkt: Verteilte ML-Inferenz gilt in vielen Stacks als eigenständiges Infrastruktur-Projekt. Auf der BEAM ist „lass das woanders rechnen" ein Baustein der Plattform, der schon da ist, bevor man anfängt — Fehlertoleranz, Verteilung und Nebenläufigkeit inklusive. Man bezahlt die Trennung nicht mit Architektur, sondern mit Konfiguration.

Kommentare

Beliebte Posts aus diesem Blog

Splitting an ML model and a web app across two BEAM nodes — the technical blueprint

Jido in Practice: Agents in Elixir as Composable Actions

A Foundation Model in the BEAM: On-Chain Anomalies with Google TimesFM in Elixir