Troubleshooting
Wenn in Produktion etwas schiefläuft, ist diese Seite der Startpunkt. Jeder Abschnitt ist ein Symptom, die wahrscheinliche(n) Ursache(n) und was zu prüfen / loggen / messen ist, um die Diagnose zu bestätigen.
Für die Diagnose-Werkzeuge selbst:
- Logging — strukturierte Logs.
- Stock-Metriken — eingebaute Actor- / Cluster-Metriken.
- Tracing — Per-Request-Flow.
Cluster
Abschnitt betitelt „Cluster“Symptom: Cluster bildet sich nicht
Abschnitt betitelt „Symptom: Cluster bildet sich nicht“Pods starten, erreichen aber nie Up.
Ursachen zum Prüfen (in dieser Reihenfolge):
- Seeds unerreichbar — falsche Adressen / DNS / RBAC.
- Cluster-Port via Firewall blockiert — Pods können auf 2552 nicht reden.
- Unterschiedliche System-Namen —
ActorSystem.create('app-a')auf einem Node,'app-b'auf einem anderen. - TLS-Fehlkonfiguration — Handshake scheitert lautlos.
Diagnostik:
cluster.subscribe((evt) => { if (evt instanceof MemberJoined) { system.log.info(`saw join: ${evt.member.address}`); } else if (evt instanceof SelfUp) { system.log.info('self up'); }});Prüfe, ob SelfUp feuert. Wenn nicht, hat der lokale Node nicht
einmal gebootstrappt. Schau dir die Ausgabe des Seed-Providers an:
kubectl logs pod-1 | grep -i seed# → "discovered N seeds: ..." ← darf nicht leer seinWenn leer: Selector / DNS / Config des Seed-Providers ist falsch.
Symptom: Cluster flappt
Abschnitt betitelt „Symptom: Cluster flappt“Mitglieder schwanken zwischen reachable und unreachable.
Ursachen:
- Zu enge Failure-Detector-Schwellen für den Jitter des Netzwerks. Siehe Failure-Detector-Tuning.
- Netzwerk-Partitionen (echte — diagnostiziere auf Infrastruktur-Ebene).
- GC-Pausen länger als die Unreachable-Schwelle.
Diagnostik:
rate(cluster_unreachable_duration_ms_count[5m]) > 0.1# Häufige ÜbergängeIn Logs:
[INFO ] cluster — node-X marked unreachable[INFO ] cluster — node-X marked reachable[INFO ] cluster — node-X marked unreachableAnhaltendes Flapping signalisiert Schwellen-Tuning.
Sharding
Abschnitt betitelt „Sharding“Symptom: Sharded Entities spawnen nicht
Abschnitt betitelt „Symptom: Sharded Entities spawnen nicht“Nachrichten an eine Sharding-Region erreichen keine Entities.
Ursachen:
- Coordinator noch nicht gestartet — Sharding ist asynchron; vor Coordinator-Bereitschaft gesendete Nachrichten werden gepuffert.
- Keine Nodes matchen die
role— leeres Up-Member-Set, also werden keine Shards allokiert. - extractEntityId liefert undefined — das Message-Routing hat nichts zum Hashen.
Diagnostik:
sharding_shards_hosted{type="entity-type"}# Sollte numShards (gesamt) clusterweit entsprechenLogs:
kubectl logs pod-1 | grep -i shard# → "coordinator: allocating shard X to node-Y"Siehst du “no candidates”, lehnt dein role-Filter jeden Node ab.
Prüfe Role-Tags in Cluster.join.
Symptom: Rebalance-Storm
Abschnitt betitelt „Symptom: Rebalance-Storm“Sharding-Metriken zeigen ständige Rebalances; Entities flackern.
Ursachen:
- Aggressive Allocation-Strategy —
LeastShardAllocationStrategymitrebalanceThreshold: 1und häufigen Mitgliedschaftsänderungen. - Flappender Cluster (siehe oben) — jede Änderung triggert Rebalance.
Fix:
new LeastShardAllocationStrategy(/* threshold */ 5, /* max */ 3);Höhere Schwelle = weniger Empfindlichkeit gegenüber kleinen Ungleichgewichten.
Persistence
Abschnitt betitelt „Persistence“Symptom: Actor braucht 30 Sekunden zum Starten
Abschnitt betitelt „Symptom: Actor braucht 30 Sekunden zum Starten“preStart braucht lange; das erste onReceive des Actors
verzögert sich.
Ursache: tiefes Journal ohne Snapshots. Recovery liest jedes jemals gespeicherte Event.
Diagnostik:
histogram_quantile(0.99, persistence_recovery_duration_ms_bucket)Wenn P99-Recovery > 1 Sekunde ist, setze eine Snapshot-Policy:
override snapshotPolicy() { return everyNEvents(100); }Siehe Snapshots.
Symptom: der State einer Entity hängt davon ab, welcher Knoten antwortet
Abschnitt betitelt „Symptom: der State einer Entity hängt davon ab, welcher Knoten antwortet“State springt nach einem Rebalance oder Deploy zurück; Remembered Entities verschwinden nach einem Coordinator-Failover; eine Projektion verarbeitet Events doppelt.
Ursache: jeder Knoten liest seine eigene Datenbank. Pro-Knoten-Storage (je eine SQLite-Datei, getrennte In-Memory-Stores, zwei Knoten mit je ihrem eigenen Postgres) heißt: eine verschobene Entity replayt, was ihr neuer Knoten gerade hält. Nichts schlägt fehl — der optimistische Append-Check läuft gegen die lokale Datenbank und kann über Knoten hinweg strukturell nicht feuern.
Diagnose — zwei Log-Nadeln, beide einmal pro Knoten:
node-local storage # der Store kann nie geteilt sein (#1356)storage identity differs # teilbar, aber zwei Instanzen (#1358)Die zweite fängt auch Fehlkonfiguration, die man dem Backend-Namen nicht ansieht: veraltete Connection-Strings, ein zurückgespieltes Backup, zwei „identische” Datenbank-Container.
Fix: alle Knoten auf dieselbe Datenbank-Instanz zeigen lassen —
siehe Storage-Lokalität &
Identität
— oder Replicated Event
Sourcing
nutzen, wo Pro-Knoten-Journale das gewollte Design sind. Für
bereits divergierte Historien gibt es keine automatische
Reparatur: pro Persistence-Id die überlebende Datenbank wählen,
die anderen daraus neu befüllen (migrateBetweenJournals) und die
Verdrahtung vor dem Neustart fixen.
Symptom: Events wiederhergestellt, aber State ist falsch
Abschnitt betitelt „Symptom: Events wiederhergestellt, aber State ist falsch“Nach Restart entspricht der State des Actors nicht dem, was er laut Journal sein sollte.
Ursachen:
onEventhat einen Seiteneffekt — läuft während des Replays und verändert irgendwie den State-Pfad.onEventnutztDate.now()oder Zufall — nicht-deterministisch; jeder Replay produziert anderen State.- Schema-Änderung ohne Adapter — alte Events haben eine andere
Form als
onEventerwartet.
Diagnostik: Vergleiche den State des Actors nach Recovery mit dem Inhalt des Journals. Replaye manuell in einem Test, um die Ursache zu isolieren.
Speicher + Performance
Abschnitt betitelt „Speicher + Performance“Symptom: Speicher wächst unbegrenzt
Abschnitt betitelt „Symptom: Speicher wächst unbegrenzt“Heap wächst linear mit Uptime; irgendwann OOM.
Ursachen (häufigste zuerst):
- Eine Mailbox mit langsamem Consumer — Mailboxes sind per
Default unbounded, ein Producer, der seinen Consumer überholt, lässt
also den Heap wachsen, bis einer von beiden aufgibt.
actor_mailbox_sizezeigt welcher Actor, und ab 10 000 Nachrichten steht eine Backlog-Warnung im Log. - Subscriber-Set-Leaks — Actors registrieren sich am Event-Stream oder DistributedPubSub, aber unsubscriben sich beim Stop nicht.
- DistributedData-Keys sammeln sich —
LWWMapmit Millionen Keys. - Persistente Puffer — Stash-Puffer, Ask-Reply-To-Refs.
Diagnostik:
actor_mailbox_size{class=...}# Finde Actors mit dauerhaft großen Queues
histogram_quantile(0.99, rate(actor_mailbox_wait_seconds_bucket[5m]))# …und wie lange Nachrichten warten, bis jemand sie annimmt.# Eine Serie entsteht erst ab 10 000 wartenden Nachrichten — dies# ist also das Signal, das sich zuerst bewegt: ein Rückstau zeigt# sich als Wartezeit, lange bevor er sich als Größe zeigt.
histogram_quantile(0.99, rate(actor_mailbox_depth_bucket[5m]))# Wie tief die Queues tatsächlich wurden. Anders als das Gauge hat# dies keine Untergrenze — ein Ausschlag von einigen Tausend zeigt# sich hier und nirgends sonst. Und anders als das Gauge ist es eine# Verteilung: ein Ausschlag zwischen zwei 2-Sekunden-Samples wird# trotzdem festgehalten.
histogram_quantile(0.99, rate(actor_dispatcher_queue_delay_seconds_bucket[5m]))# Eine Ebene höher: Züge, die auf den Dispatcher warten, statt# Nachrichten, die auf den Actor warten. Hoch hier und niedrig oben# heißt, die Actors sind in Ordnung und das Scheduling ist die Queue.# Heap-Dump über Runtime-Tools:node --inspect / Buns ProfilerPrüfe Mailbox-Sizing + die Leak-Muster in Event-Stream.
Symptom: HTTP-Latenz unter Last hoch
Abschnitt betitelt „Symptom: HTTP-Latenz unter Last hoch“Actor-Arbeit ist okay; HTTP-Antworten sind langsam.
Ursache: Ein Actor monopolisiert die Event Loop und hungert HTTP-Handler aus.
Fix: Per-Actor ThroughputDispatcher auf den schweren Actor.
Tests + Dev
Abschnitt betitelt „Tests + Dev“Symptom: Tests hängen am Ende
Abschnitt betitelt „Symptom: Tests hängen am Ende“Bun- / Vitest-Testprozess beendet sich nicht.
Ursache: await system.terminate() wurde im Fixture-Teardown nicht
aufgerufen. Geleakte Scheduler / Actor-Cells halten die Event Loop
am Leben.
Fix:
afterEach(async () => { await tk.shutdown();});Siehe TestKit.
Symptom: Shutdown hat meine Nachrichten verworfen
Abschnitt betitelt „Symptom: Shutdown hat meine Nachrichten verworfen“Arbeit, die beim Aufruf von terminate() in der Queue stand, lief
nie und taucht stattdessen im Dead-Letter-Stream auf.
terminate() leert zuerst die Actors unter /user, ein einfacher
Rückstau wird also verarbeitet. Auf vier Dinge wartet es nicht:
- Das Drain-Budget ist abgelaufen. Es ist
actor-ts.system.shutdown-drain-timeout, Default 2 s. Ein Actor, der beim Leeren immer neue Arbeit erzeugt — eine Self-Tell-Schleife, ein Ballwechsel zwischen zwei Actors — wird nie ruhig, nur das Budget beendet ihn. Erhöhe das Budget, und mit ihmactor-ts.coordinated-shutdown.default-phase-timeout, wenn du über die Pipeline herunterfährst. - Die Mailbox war geparkt.
context.throttle(...)und eine vom Supervisor suspendierte Mailbox gelten beide als ruhig, weil keine von beiden sich in einem Tempo leert, auf das ein Shutdown warten kann. Brich den Throttle vor dem Herunterfahren ab, wenn der Rückstau zählt. - Die Arbeit lag noch in keiner Mailbox. Ein noch nicht gefeuerter
context.timers-Tick oder eintellaus einem Promise, das ein Handler ohneawaitgestartet hat, kommt an, nachdem der Baum ruhig aussieht. Awaite es oder schicke es stattdessen als Nachricht. - Sie war an
/systemadressiert. Framework-Actors sind konstruktionsbedingt nie ruhig und daher nicht Teil des Drains.
Um herauszufinden, welcher Fall vorliegt, abonniere vor dem
Herunterfahren DeadLetter auf dem Event-Stream — der Dead Letter
nennt Nachricht und Empfänger. Das Abonnieren muss vorher
passieren, denn Publishen ist standardmäßig alles, was passiert:
nichts bewahrt ein Dead Letter auf, es gibt also nachträglich nichts
nachzulesen. Setze
actor-ts.dead-letters.store = "persistent", wenn du die
Aufzeichnung lieber hättest, ohne vorher zu wissen, dass du sie
brauchen wirst — siehe
Dead Letters.
Symptom: Flaky timing-sensitive Tests
Abschnitt betitelt „Symptom: Flaky timing-sensitive Tests“Tests sind lokal grün, fallen aber in CI um.
Die übliche Ursache ist Real-Clock-Timing: ein fester Sleep, der auf einer unbeschäftigten Maschine lang genug ist und auf einem ausgelasteten CI-Runner zu kurz. Nutze ManualScheduler, um Zeit deterministisch zu steuern, oder warte auf den beobachtbaren Zustand statt auf eine Dauer.
Das ist nicht die einzige Ursache, und zwischen ihnen zu raten kostet einen Nachmittag. Test-Flakes diagnostizieren hat das Repeat-Run-Harness, das einen Flake von einem kaputten Test trennt, dazu die katalogisierten Ursachen — ein Timer-Quantum, das zu früh feuert, ein Dispatcher-Hop, den kein Poll-Intervall gewinnt, und die drei Multi-Node-Suites, die CI gar nicht ausführt.
Womit anfangen, wenn nichts offensichtlich ist
Abschnitt betitelt „Womit anfangen, wenn nichts offensichtlich ist“1. Logs auf ERROR-Level-Einträge prüfen. Nach Zeitfenster um das Problem filtern.2. Stock-Metriken prüfen — was hat sich verändert? (Restart-Rate, Mailbox-Tiefe, Member-Anzahl).3. Cluster-Events am Event-Stream prüfen.4. Wenn ein Request scheitert, der Trace-ID durch Logs / Trace-Backend folgen.5. Wenn möglich, in einem Multi-Node-Spec-Test reproduzieren.Wohin als nächstes
Abschnitt betitelt „Wohin als nächstes“- Operations-Überblick — die breitere Produktions-Checkliste.
- FAQ — häufige Fragen und Stolperfallen.
- Stock-Metriken — was zu lesen ist, wenn Symptome auftauchen.
- Logging — wie Logs tatsächlich nützlich werden.
- Tracing — Per-Request-Flow, wenn Logs nicht reichen.
