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';
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';
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.

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.