Zum Inhalt springen
Deutsch

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:

Pods starten, erreichen aber nie Up.

Ursachen zum Prüfen (in dieser Reihenfolge):

  1. Seeds unerreichbar — falsche Adressen / DNS / RBAC.
  2. Cluster-Port via Firewall blockiert — Pods können auf 2552 nicht reden.
  3. Unterschiedliche System-Namen — ActorSystem.create('app-a') auf einem Node, 'app-b' auf einem anderen.
  4. 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:

Terminal-Fenster
kubectl logs pod-1 | grep -i seed
# → "discovered N seeds: ..." ← darf nicht leer sein

Wenn leer: Selector / DNS / Config des Seed-Providers ist falsch.

Mitglieder schwanken zwischen reachable und unreachable.

Ursachen:

  1. Zu enge Failure-Detector-Schwellen für den Jitter des Netzwerks. Siehe Failure-Detector-Tuning.
  2. Netzwerk-Partitionen (echte — diagnostiziere auf Infrastruktur-Ebene).
  3. GC-Pausen länger als die Unreachable-Schwelle.

Diagnostik:

rate(cluster_unreachable_duration_ms_count[5m]) > 0.1
# Häufige Übergänge

In Logs:

[INFO ] cluster — node-X marked unreachable
[INFO ] cluster — node-X marked reachable
[INFO ] cluster — node-X marked unreachable

Anhaltendes Flapping signalisiert Schwellen-Tuning.

Nachrichten an eine Sharding-Region erreichen keine Entities.

Ursachen:

  1. Coordinator noch nicht gestartet — Sharding ist asynchron; vor Coordinator-Bereitschaft gesendete Nachrichten werden gepuffert.
  2. Keine Nodes matchen die role — leeres Up-Member-Set, also werden keine Shards allokiert.
  3. extractEntityId liefert undefined — das Message-Routing hat nichts zum Hashen.

Diagnostik:

sharding_shards_hosted{type="entity-type"}
# Sollte numShards (gesamt) clusterweit entsprechen

Logs:

Terminal-Fenster
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.

Sharding-Metriken zeigen ständige Rebalances; Entities flackern.

Ursachen:

  1. Aggressive Allocation-Strategy — LeastShardAllocationStrategy mit rebalanceThreshold: 1 und häufigen Mitgliedschaftsänderungen.
  2. Flappender Cluster (siehe oben) — jede Änderung triggert Rebalance.

Fix:

new LeastShardAllocationStrategy(/* threshold */ 5, /* max */ 3);

Höhere Schwelle = weniger Empfindlichkeit gegenüber kleinen Ungleichgewichten.

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:

  1. onEvent hat einen Seiteneffekt — läuft während des Replays und verändert irgendwie den State-Pfad.
  2. onEvent nutzt Date.now() oder Zufall — nicht-deterministisch; jeder Replay produziert anderen State.
  3. Schema-Änderung ohne Adapter — alte Events haben eine andere Form als onEvent erwartet.

Diagnostik: Vergleiche den State des Actors nach Recovery mit dem Inhalt des Journals. Replaye manuell in einem Test, um die Ursache zu isolieren.

Heap wächst linear mit Uptime; irgendwann OOM.

Ursachen (häufigste zuerst):

  1. 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_size zeigt welcher Actor, und ab 10 000 Nachrichten steht eine Backlog-Warnung im Log.
  2. Subscriber-Set-Leaks — Actors registrieren sich am Event-Stream oder DistributedPubSub, aber unsubscriben sich beim Stop nicht.
  3. DistributedData-Keys sammeln sich — LWWMap mit Millionen Keys.
  4. 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.
Terminal-Fenster
# Heap-Dump über Runtime-Tools:
node --inspect / Buns Profiler

Prüfe Mailbox-Sizing + die Leak-Muster in Event-Stream.

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.

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.

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 ihm actor-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 ein tell aus einem Promise, das ein Handler ohne await gestartet hat, kommt an, nachdem der Baum ruhig aussieht. Awaite es oder schicke es stattdessen als Nachricht.
  • Sie war an /system adressiert. 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.

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.

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.
  • 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.