Who picks the tool
-->
Direkt zum Hauptbereich
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.
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:
| Rolle | Knoten | Startet | FINANCE_ROLE |
|---|---|---|---|
:web | Linux | Phoenix, Postgres, Jido, Klassifikations-Logik | web |
:ml | MacBook | nur die Nx.Serving (Modell + EXLA) | ml |
:all | Dev/Test | alles 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:
Nx.Serving — wie der Inferenz-Aufruf knotenübergreifend geroutet wird, ohne dass der Aufrufer es merkt.runtime: falseDas 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:
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.
Die Release-Definitionen in der Umbrella-mix.exs ziehen die Trennlinie:
Zwei Dinge sind hier subtil:
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.bumblebee, exla und tokenizers explizit in applications: listen, holen wir sie trotz runtime: false zurück in genau dieses Release.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.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:
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:
Die drei Helfer machen die Trennung explizit:
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.
Nx.Serving — Location-TransparenzJetzt 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:
@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:
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).
Nx — aber kein EXLADer 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:
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:
Der Cluster.Supervisor wird nur gestartet, wenn eine Topologie konfiguriert ist — Dev/Test bleiben dadurch single-node:
Die restliche Verteilungs-Plumbing sind Standard-Release-Variablen. Aus dem lokalen Zwei-Knoten-Smoke-Test (beide auf demselben Mac) im Makefile:
Das Wesentliche:
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.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.
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:
Das Behaviour macht diesen Zustand zu einem Vertrag:
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.
Da mix im Release fehlt, laufen Ecto-Migrationen über eval auf dem Web-Knoten (der ML-Knoten hat keine DB):
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.
Was dieses Setup an „verteiltem System" kostet, ist überschaubar und steht fast vollständig in diesem Artikel:
runtime: false auf drei Deps plus zwei Release-Definitionen,catch für den unerreichbaren Knoten,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:
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.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