Ir al contenido
Español

Cluster bootstrap

Esta página aún no está disponible en tu idioma.

A cold start asks every node the same question at the same moment — “is there a cluster yet?” — and discovery answers it differently on each of them. DNS has not fully propagated; a pod is Ready before its IP is in the headless service; the Kubernetes API returns a partial pod list. Act on the first answer and each node forms a cluster out of the subset it happened to see.

Gossip never repairs that. The results are separate clusters with the same name, each with its own leader, its own singletons and its own shard allocations.

Cluster bootstrap is the phase that runs before Cluster.join and makes sure at most one cluster comes out of a simultaneous start.

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. Poll the seed provider on pollIntervalMs and require the returned set (plus this node) to be byte-identical for stableMarginMs before acting on it. Any change restarts the margin. A failed lookup is not an empty set — it does not count as an observation at all, so a DNS outage can never be mistaken for “I am alone”.

2 — Deferred self-election. Order the settled set by address. The lowest is the initial seed — but it does not form a cluster on the spot. Every node, winner included, joins with the other contact points as its seeds; only the winner also gets a deadline (selfElectionGraceMs) after which it will form a cluster if nobody has promoted it.

The one-line path — bootstrapCluster runs the phase for you:

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);

Pass an object instead of true to override the timings:

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

If you call Cluster.join directly, run the observation and feed both of its outputs into the options — the seed list and the 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() returns the settled set, whether this node won (isInitialSeed), and the selfElection value to pass on. Deriving that value yourself is the one mistake that reintroduces the split brain, so the observation does it for you.

SettingDefaultWhat
stableMarginMs5000How long the contact-point set must stay unchanged.
pollIntervalMs1000How often the seed provider is polled.
maxWaitMs60000Total budget; exceeding it throws.
requiredContactPoints1Fewest contact points a settled observation may contain.
selfElectionGraceMs10000How long the elected node waits before forming a cluster.

Same keys under actor-ts.cluster.bootstrap.*, with the usual precedence — explicit options > HOCON > built-in defaults:

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

requiredContactPoints is the one worth changing. The margin catches discovery that is slow; only a required count catches discovery that is stably wrong — a resolver that consistently returns two of your three pods will settle happily on the wrong set. The default of 1 exists so single-node development needs no configuration; in production set it to the replica count you expect.

SituationUse
Fixed, known addresses; one designated first nodePlain seed join.
Local development, single nodePlain seed join — the empty seed list already means “I am first”.
Nodes start simultaneously with dynamic addresses (K8s Deployment, autoscaling group)Bootstrap.
Every node is given the same seed listBootstrap — see below.

That last row is easy to miss. Cluster filters this node out of its own seed list, so if every node lists every node then no node is left with the empty list that ordinary self-election needs. Nobody becomes up, nobody becomes leader, and nobody is ever promoted — the cluster deadlocks in joining. The election is what breaks the symmetry.

  • Startup latency. At least stableMarginMs, plus selfElectionGraceMs on a genuine cold start (paid once, by one node). Joining an existing cluster is unaffected — the grace never fires.
  • Discovery load. One lookup() per pollIntervalMs per node until the set settles. The poll cadence is deliberately constant rather than backing off: a growing interval would sample the stable margin at drifting points, and the load it saves — at most a few dozen lookups per node — is not worth weakening the guarantee.