Zum Inhalt springen
Deutsch

Joining und Seeds

Ein Node tritt einem Cluster bei, indem er einen Seed-Node kontaktiert. Der Seed sendet per Gossip seine aktuelle Mitgliedschaftssicht zurück; der Joiner wird als joining hinzugefügt, propagiert per Gossip, und sobald der Leader ihn sieht (plus Konvergenz), wechselt er zu up.

ClusterSeed-Nodesjoining-NodeClusterSeed-Nodesjoining-Nodejoining → weakly-up? → upüber ein paar Gossip-RundenJoin-AnkündigungGossip JoinGossip — aktuelle Sicht

Diese Seite behandelt die Mechanik dieses Handshakes plus die Seed-Discovery-Ebene darüber.

import { ActorSystem } from 'actor-ts';
import { Cluster, ClusterOptions } from 'actor-ts/cluster';
const system = ActorSystem.create('my-app');
const clusterOptions = ClusterOptions.create()
.withHost('10.0.0.5')
.withPort(2552)
.withSeeds(['10.0.0.5:2552', '10.0.0.6:2552', '10.0.0.7:2552']);
const cluster = await Cluster.join(
system,
clusterOptions,
);

Drei Seeds. Der Joiner kontaktiert sie der Reihe nach, bis einer antwortet. Sobald irgendein Seed akzeptiert, propagiert der Gossip des Clusters das neue Mitglied; Konvergenz zu up geschieht innerhalb weniger Sekunden in einem gesunden Netzwerk.

Die Seed-Liste ist nur ein Bootstrap-Hinweis — sobald der Node beigetreten ist, lernt er alle anderen Peers per Gossip kennen. Seeds müssen nach dem Join nicht mehr besonders sein.

type ClusterOptionsType = {
host: string; // Interface zum Binden (Wildcard ok)
advertisedHost?: string; // Adresse, die Peers wählen (nie Wildcard)
port: number; // TCP-Port dieses Nodes
seeds?: string[]; // Peer-Adressen fürs Bootstrap
roles?: string[]; // Rollen-Tags
failureDetector?: Partial<...>;
transport?: Transport;
gossipIntervalMs?: number;
seedRetryIntervalMs?: number; // Retry-Intervall, falls kein Seed antwortet
// ...
};

Die seed-bezogenen Knöpfe:

EinstellungStandardWas
seeds[]Liste von "host:port"-Strings. Leer = “ich bin der erste”.
seedRetryIntervalMs3000Falls kein Seed antwortet, wiederhole die Liste so oft, bis einer antwortet.
selfElection'immediate'Wann dieser Node allein einen Cluster bilden darf: 'immediate' (nur bei leerer Seed-Liste), 'never', oder eine Frist in Millisekunden. Wird vom Cluster-Bootstrap gesetzt.
const clusterOptions = ClusterOptions.create()
.withHost('0.0.0.0')
.withPort(2552)
.withSeeds([]);
const cluster = await Cluster.join(
system,
clusterOptions,
);

0.0.0.0 ist dort die Bind-Adresse. Was dieser Node per Gossip verteilt, wird getrennt aufgelöst und ist nie eine Wildcard — ohne weitere Konfiguration verteilt er 127.0.0.1, was für einen Single-Node-Entwicklungslauf genau richtig ist und der Grund, warum das Paar keine Beachtung braucht, solange es keine zweite Maschine gibt. Siehe Cluster — Überblick.

Eine leere seeds-Liste (oder eine, die komplett unerreichbar ist) bedeutet, dass dieser Node den Cluster selbst bootstrapt. Er befördert sich automatisch zum Leader; künftige Joiner kontaktieren ihn.

Das macht die Single-Node-Entwicklung trivial — keine zu pflegende Seed-Liste. Füge später einen zweiten Node hinzu, indem du ihm die Adresse des ersten als Seed gibst.

Für Produktion bestimme einen Node mit leerer Seed-Liste und gib den übrigen dessen Adresse — oder, besser, nutze den Cluster-Bootstrap, der einen festgelegten ersten Node überflüssig macht.

Es liegt nahe, jedem Node dieselbe Liste mit allen Nodes zu geben. Diese Konfiguration bildet nie einen Cluster:

n3n2n1n3n2n1All three fresh, all given seed list [n1, n2, n3]filters itself out → seeds = [n2, n3] → not emptysame on both — everyone waits to be let inno member is `up`, so there is no leader,so nobody is ever promotedjoin announcementjoin announcement

Cluster entfernt die eigene Adresse aus der Seed-Liste, also bleibt bei einer symmetrischen Liste keinem Node die leere Liste, die selfElection: 'immediate' verlangt. Jeder Node bleibt für immer joining; es gibt keinen Leader, und der Leader ist es, der joining → up befördert.

Zwei Auswege:

  • Ein festgelegter erster Node mit seeds: [], der Rest zeigt auf ihn. Einfach, aber dieser Node ist beim Start etwas Besonderes — was Container und Autoscaler unhandlich machen.
  • Cluster-Bootstrap — jeder Node bekommt dieselbe Konfiguration, und das Framework wählt genau einen aus, der den Gleichstand bricht. Das ist die Wahl, sobald Nodes gleichzeitig starten.

Die Retry-Schleife über seedRetryIntervalMs bleibt wichtig, löst aber ein anderes Problem: einen Seed, der irgendwann erreichbar ist. Ein erstes Mitglied kann sie nicht herbeizaubern.

Sie trägt aber die Diagnose. Ein Node, der nach ein paar Kontaktrunden immer noch joining ist, kein up-Mitglied kennt und auf keine Selbstwahl mehr wartet, loggt eine WARN-Zeile, die benennt, was er sieht und was zu ändern ist — die unbeantworteten Seed-Adressen, wenn niemand geantwortet hat, oder die Kombination aus Seed-Liste und selfElection von oben, wenn alle Peers da sind und alle warten. Sie erscheint einmal, nicht pro Runde.

Diese Zeile ist deshalb wichtig, weil das Symptom weit von der Ursache entfernt auftritt: einem so blockierten Cluster fehlt jedes up-Mitglied, ein Cluster-Singleton wählt seinen Host aus den up-Mitgliedern, und das Erste, was die meisten Anwendungen bemerken, ist deshalb ein AskTimeoutError von einem Singleton-Proxy.

Seed-Discovery — jenseits einer statischen Liste

Abschnitt betitelt „Seed-Discovery — jenseits einer statischen Liste“

Eine hartcodierte Seed-Liste reicht für Tests und kleine Cluster. Für Produktion, in der Nodes dynamische IPs haben (Container, K8s Pods), nutze einen Seed-Provider:

ProviderWann
ConfigStatische Liste (der Fall oben).
DNSLöst _actor-ts._tcp.example.com SRV-Records auf.
Kubernetes APIListet Pods, die einem Label-Selector entsprechen.
AggregateFällt durch mehrere Provider durch (z. B. K8s, dann DNS).
import { KubernetesApiSeedProvider, KubernetesApiSeedProviderOptions } from 'actor-ts/discovery';
const kubernetesApiSeedProviderOptions = KubernetesApiSeedProviderOptions.create()
.withNamespace('default')
.withServiceName('actor-ts')
.withPort(2552);
const seedProvider = new KubernetesApiSeedProvider(
kubernetesApiSeedProviderOptions,
);
const seeds = await seedProvider.discover();
const clusterOptions = ClusterOptions.create()
.withHost(process.env.POD_IP!)
.withPort(2552)
.withSeeds(seeds);
const cluster = await Cluster.join(
system,
clusterOptions,
);

Der Provider liefert eine Momentaufnahme von Seed-Adressen; das Framework nutzt sie, um den Join zu bootstrappen. Siehe Discovery-Überblick für das Seed-Provider-Modell.

Zuerst das Ein-Aufruf-Gate — awaitReady löst auf, sobald dieser Node up ist und, mit minimumMembers, sobald das Cluster seine erwartete Größe erreicht hat; isReady ist die synchrone Probe:

await cluster.awaitReady({ minimumMembers: 3, timeoutMs: 30_000 });
cluster.isReady(); // probe the same predicate without waiting
cluster.selfMember(); // this node's own record — status included

Mit gesetztem timeoutMs lehnt es mit ClusterReadyTimeoutError ab, statt raten zu lassen; ohne wartet es unbegrenzt. Siehe Cluster-Bootstrap → Auf Readiness warten dafür, wie Cluster.bootstrap dasselbe Warten standardmäßig anwendet.

Für Logik, die auf einzelne Übergänge reagiert, die Events abonnieren:

import { SelfUp, MemberUp } from 'actor-ts/cluster';
cluster.subscribe((evt) => {
if (evt instanceof SelfUp) {
console.log(`dieser Node ist jetzt Up`);
} else if (evt instanceof MemberUp) {
console.log(`Peer ${evt.member.address} hat Up erreicht`);
}
});

Zwei zentrale Events:

  • SelfUp feuert einmal, wenn dieser Node auf up übergeht.
  • MemberUp feuert jedes Mal, wenn irgendein Mitglied up erreicht.

MemberUps von Hand zu zählen, um “sind mindestens 3 Nodes up?” zu beantworten, ist genau das, was awaitReady({ minimumMembers: 3 }) ersetzt — die Subscription ist zum Reagieren auf Änderungen da, nicht als Startup-Gate.