Zum Inhalt springen
Deutsch

Cluster-Bootstrap

Ein Kaltstart stellt jedem Node im selben Moment dieselbe Frage — „gibt es schon einen Cluster?” — und Discovery beantwortet sie auf jedem anders. DNS ist noch nicht propagiert; ein Pod ist Ready, bevor seine IP im Headless Service steht; die Kubernetes-API liefert eine unvollständige Pod-Liste. Wer auf die erste Antwort hin handelt, bekommt Nodes, die je einen Cluster aus der Teilmenge bilden, die sie zufällig gesehen haben.

Gossip repariert das nie. Das Ergebnis sind getrennte Cluster mit demselben Namen — jeder mit eigenem Leader, eigenen Singletons und eigener Shard-Verteilung.

Cluster-Bootstrap ist die Phase, die vor Cluster.join läuft und dafür sorgt, dass aus einem gleichzeitigen Start höchstens ein Cluster entsteht.

clusternodediscoveryclusternodediscoveryset changed? restart the marginloop[until unchanged for stableMargin]order by address —lowest is the initial seedinitial seed self-elects,everyone else stays joiningalt[a cluster already exists][nothing answers within the grace]lookup()contact pointsCluster.join(seeds = every other contact point)leader promotes this node to up

1 — Stable Observation. Den Seed-Provider im Takt von pollIntervalMs pollen und verlangen, dass die zurückgegebene Menge (plus dieser Node) für stableMarginMs exakt identisch bleibt, bevor darauf gehandelt wird. Jede Änderung startet die Margin neu. Ein fehlgeschlagener Lookup ist keine leere Menge — er zählt gar nicht als Beobachtung, damit ein DNS-Ausfall nie mit „ich bin allein” verwechselt werden kann.

2 — Verzögerte Selbstwahl. Die stabile Menge nach Adresse sortieren. Die niedrigste ist der Initial Seed — aber sie bildet nicht sofort einen Cluster. Jeder Node, auch der Gewinner, joint mit den übrigen Contact Points als Seeds; nur der Gewinner bekommt zusätzlich eine Frist (selfElectionGraceMs), nach der er einen Cluster bildet, falls ihn bis dahin niemand befördert hat.

Der Einzeiler — bootstrapCluster führt die Phase für dich aus:

import { bootstrapCluster, ClusterBootstrapOptions } from 'actor-ts/cluster';
const bootstrapOptions = ClusterBootstrapOptions.create('my-app')
.withHost(process.env.POD_IP!)
.withPort(2552)
.withDiscovery('kubernetes')
.withStableObservation(true);
const { cluster, shutdown } = await bootstrapCluster(bootstrapOptions);

Statt true ein Objekt übergeben, um die Zeiten zu überschreiben:

const bootstrapOptions = ClusterBootstrapOptions.create('my-app')
.withHost(process.env.POD_IP!)
.withDiscovery('kubernetes')
.withStableObservation({ requiredContactPoints: 3, stableMarginMs: 8_000 });

Wer Cluster.join direkt aufruft, führt die Beobachtung aus und gibt beide Ergebnisse in die Optionen — die Seed-Liste und die selfElection-Policy:

import {
Cluster,
ClusterOptions,
NodeAddress,
StableObservation,
StableObservationOptions,
} from 'actor-ts/cluster';
const selfAddress = new NodeAddress('my-app', process.env.POD_IP!, 2552);
const observationOptions = StableObservationOptions.create()
.withSeedProvider(seedProvider)
.withSelfAddress(selfAddress)
.withRequiredContactPoints(3);
const observation = new StableObservation(observationOptions);
const targets = await observation.resolveJoinTargets();
const clusterOptions = ClusterOptions.create()
.withHost(selfAddress.host)
.withPort(selfAddress.port)
.withSeeds(targets.seeds.map((address) => address.toString()))
.withSelfElection(targets.selfElection);
const cluster = await Cluster.join(system, clusterOptions);

resolveJoinTargets() liefert die stabile Menge, ob dieser Node gewonnen hat (isInitialSeed) und den selfElection-Wert zum Weiterreichen. Diesen Wert selbst herzuleiten ist der eine Fehler, der das Split-Brain zurückbringt — deshalb macht die Beobachtung es für dich.

EinstellungDefaultBedeutung
stableMarginMs5000Wie lange die Contact-Point-Menge unverändert bleiben muss.
pollIntervalMs1000Wie oft der Seed-Provider gepollt wird.
maxWaitMs60000Gesamtbudget; wird es überschritten, wirft die Phase.
requiredContactPoints1Wie viele Contact Points eine stabile Beobachtung mindestens enthalten muss.
selfElectionGraceMs10000Wie lange der gewählte Node wartet, bevor er einen Cluster bildet.

Dieselben Schlüssel unter actor-ts.cluster.bootstrap.*, mit der üblichen Präzedenz — explizite Optionen > HOCON > eingebaute Defaults:

actor-ts.cluster.bootstrap {
stable-margin = 5s
poll-interval = 1s
max-wait = 60s
required-contact-points = 3
self-election-grace = 10s
}

requiredContactPoints ist der Wert, den es lohnt anzufassen. Die Margin fängt Discovery ab, die langsam ist; nur eine Mindestanzahl fängt Discovery ab, die stabil falsch ist — ein Resolver, der konsequent zwei von drei Pods liefert, wird sich bereitwillig auf die falsche Menge einpendeln. Der Default 1 existiert, damit Einzelknoten-Entwicklung ohne Konfiguration läuft; in Produktion auf die erwartete Replica-Anzahl setzen.

Ein aufgelöstes bootstrapCluster bedeutet ein bereites Cluster: Dieser Knoten ist volles Mitglied (up), und mindestens minimum-members Mitglieder sind up. Wird das innerhalb des Budgets nicht erreicht, fährt der Bootstrap die Coordinated-Shutdown-Pipeline und lehnt mit einem ClusterReadyTimeoutError ab, der Self-Status, Up-Zähler und die Messlatte nennt — er löst nie für einen Knoten auf, der noch joining ist.

const bootstrapOptions = ClusterBootstrapOptions.create('my-app')
.withHost(process.env.POD_IP!)
.withDiscovery('kubernetes')
.withStableObservation(true)
.withAwaitReady({ minimumMembers: 3, timeoutMs: 30_000 });
const node = await bootstrapCluster(bootstrapOptions);
node.formedNewCluster; // false — joined an existing cluster rather than founding one

awaitReady nimmt true (Default — mit dem berechneten Budget warten), false oder 0 (nicht warten), eine Zahl (das Budget in ms) oder das Options-Objekt oben. Ungesetzte Felder fallen auf HOCON und dann auf das berechnete Budget durch:

FeldDefaultWas
minimumMembers1Wenigste up-Mitglieder — self eingeschlossen —, bevor das Cluster als bereit gilt. Wie requiredContactPoints dimensionieren: auf die Replica-Anzahl.
timeoutMsabgeleitetawait-ready, wenn gesetzt; sonst self-election-grace + 5 s hinter Stable Observation — das Budget, an dem die Readiness jedes Knotens tatsächlich hängt, Gewinner oder nicht — sonst flache 5 s.
actor-ts.cluster.bootstrap {
minimum-members = 3
# await-ready = 30s # unset selects the grace-aware computed default
}

Dasselbe Warten gibt es eigenständig — nach einem von Hand verdrahteten Cluster.join oder später im Prozess, vor einem Rebalancing-empfindlichen Schritt:

await cluster.awaitReady({ minimumMembers: 3, timeoutMs: 30_000 });
cluster.isReady(); // synchronous probe of the same predicate
cluster.selfMember(); // this node's own record, tombstone included
cluster.selfElected; // true — this node founded its cluster

Ohne timeoutMs wartet das Promise unbegrenzt, wie system.whenTerminated() — überall dort mit einer Deadline paaren, wo das Cluster legitim ausbleiben kann; ein Knoten, der das Cluster verlassen hat oder entfernt wurde, wird nie bereit. Readiness heißt hier bewusst Mitgliedschaft: App-registrierte Readiness-Checks (das Aggregat des /ready-Endpoints) können von Initialisierung abhängen, die erst nach dem Bootstrap läuft — auf sie zu warten könnte genau den Aufruf verklemmen, der zuerst kommt. /ready bleibt die Sicht des Load Balancers; awaitReady ist die prozessinterne.

Das alte Fire-and-forget-Verhalten liefert awaitReady: false plus ein eigenes cluster.awaitReady().catch(…).

SituationWahl
Feste, bekannte Adressen; ein festgelegter erster NodeEinfacher Seed-Join.
Lokale Entwicklung, ein NodeEinfacher Seed-Join — die leere Seed-Liste heißt bereits „ich bin der erste”.
Nodes starten gleichzeitig mit dynamischen Adressen (K8s Deployment, Autoscaling-Gruppe)Bootstrap.
Jeder Node bekommt dieselbe Seed-ListeBootstrap — siehe unten.

Die letzte Zeile übersieht man leicht. Cluster filtert diesen Node aus seiner eigenen Seed-Liste, also bleibt — wenn jeder Node jeden Node listet — keinem Node die leere Liste, die die gewöhnliche Selbstwahl braucht. Niemand wird up, niemand wird Leader, niemand wird je befördert: der Cluster steht in joining fest. Die Wahl ist das, was die Symmetrie bricht.

  • Startlatenz. Mindestens stableMarginMs, bei einem echten Kaltstart zusätzlich selfElectionGraceMs (einmalig, von einem Node). Der Beitritt zu einem bestehenden Cluster ist nicht betroffen — die Frist läuft dort nie ab.
  • Discovery-Last. Ein lookup() pro pollIntervalMs pro Node, bis die Menge stabil ist. Der Takt ist bewusst konstant statt mit Backoff: ein wachsendes Intervall würde die Stable Margin an driftenden Punkten abtasten, und die eingesparte Last — höchstens ein paar Dutzend Lookups pro Node — wiegt die schwächere Garantie nicht auf.