Zum Inhalt springen
Deutsch

DNS Seed Provider

DnsSeedProvider löst Seeds beim Start via DNS auf. Zwei Modi:

ModusLookupErgebnis
SRV-Records_actor-ts._tcp.example.comService + Port aus Records
A-Recordsactor-ts.example.comNur IPs; Port kommt aus Config

SRV ist die flexiblere Wahl (Service-Discovery-Style); A-Records funktionieren für einfachere Setups.

import { Cluster, ClusterOptions, DnsSeedProvider, DnsSeedProviderOptions } from 'actor-ts';
const dnsSeedProviderOptions = DnsSeedProviderOptions.create()
.withHostname('_actor-ts._tcp.example.com')
.withSystemName('my-app')
.withUseSrv();
const provider = new DnsSeedProvider(
dnsSeedProviderOptions,
);
const seeds = await provider.lookup();
const clusterOptions = ClusterOptions.create()
.withHost(host)
.withPort(port)
.withSeeds(seeds);
await Cluster.join(system, clusterOptions);
type DnsSeedProviderOptionsType = {
hostname: string; // SRV-Record-Name (oder Hostname für A-Record)
systemName: string; // wird auf entdeckte Node-Adressen gestempelt
port: number; // wird im A-Record-Modus mit jeder IP gepaart
useSrv?: boolean; // SRV-Records (die einen Port tragen) A vorziehen
cacheTtlMs?: number; // In-Process-TTL-Cache; Default 60_000 ms, 0 deaktiviert
pinnedAddresses?: readonly string[]; // nur diese akzeptieren; unbesetzt = alle
log?: (message: string, error?: unknown) => void; // meldet ausgefilterte Adressen
// DNS-Funktionen überschreiben (Default: node:dns/promises) — v. a. für Tests:
resolve?: (hostname: string) => Promise<string[]>;
resolveSrv?: (hostname: string) => Promise<Array<{ name: string; port: number }>>;
};
const dnsSeedProviderOptions = DnsSeedProviderOptions.create()
.withHostname('_actor-ts._tcp.my-app.example.com')
.withSystemName('my-app')
.withUseSrv();
new DnsSeedProvider(
dnsSeedProviderOptions,
);

SRV-Records tragen pro Eintrag Host, Port, Weight, Priority. Der Provider gibt für jeden Eintrag, den die DNS-Abfrage zurückgegeben hat, host:port zurück.

Um die SRV-Records anzulegen:

_actor-ts._tcp.my-app.example.com. IN SRV 10 100 2552 node-1.my-app.example.com.
_actor-ts._tcp.my-app.example.com. IN SRV 10 100 2552 node-2.my-app.example.com.
_actor-ts._tcp.my-app.example.com. IN SRV 10 100 2552 node-3.my-app.example.com.

Plus passende A-Records für die Hostnames. Die meisten DNS-SD-Service-Registrierungstools (Consul, Eureka mit DNS-Plugin) publizieren SRV-Records automatisch.

const dnsSeedProviderOptions = DnsSeedProviderOptions.create()
.withHostname('actor-ts.example.com')
.withSystemName('my-app')
.withPort(2552);
new DnsSeedProvider(
dnsSeedProviderOptions,
);

Für Setups ohne SRV — DNS trägt nur IPs. Der Provider fragt A-Records ab und paart jede mit dem konfigurierten Port.

A-Records zeigen typischerweise auf einen Loadbalancer oder eine Round-Robin-DNS-Liste; mehrere A-Einträge bedeuten mehrere Seed-Kandidaten.

Was DNS zurückgibt, ist die Antwort einer Gegenstelle, die der Node nie authentifiziert hat. pinnedAddresses begrenzt, was diese Antwort sagen darf: Adressen außerhalb der Liste werden verworfen, bevor sie dem Cluster als Seeds angeboten werden.

const dnsSeedProviderOptions = DnsSeedProviderOptions.create()
.withHostname('actor-ts.example.com')
.withSystemName('my-app')
.withPort(2552)
.withPinnedAddresses(['10.0.0.0/8'])
.withLog((message) => logger.warn(message));

Die Form der Einträge hängt vom Modus ab, weil die beiden Modi zu Unterschiedlichem auflösen:

ModusLöst auf zuPinnen mit
A-RecordsIP-AdressenCIDRs — '10.0.0.0/8', '2001:db8::/32'
SRV-RecordsZiel-HostnamesHost-Suffixe — 'svc.cluster.local'

Ein Suffix greift auf einer Label-Grenze, also lässt svc.cluster.local pod-1.svc.cluster.local und den Apex selbst zu, aber nicht evilsvc.cluster.local. Ein CIDR wird nie für einen Hostname herangezogen und ein Suffix nie für eine IP — beide Formen in einer Liste zu mischen ist deshalb unproblematisch und praktisch, wenn dieselbe Konfiguration beide Modi bedient.

Eine Liste, die im konfigurierten Modus nichts treffen kann, wird bei der Konstruktion abgelehnt, statt still jeden Record zu verwerfen:

const dnsSeedProviderOptions = DnsSeedProviderOptions.create()
.withHostname('_actor-ts._tcp.example.com')
.withSystemName('my-app')
.withUseSrv()
.withPinnedAddresses(['10.0.0.0/8']);
// Wirft OptionsError — SRV-Ziele sind Hostnames, diese Liste würde
// jeden Record verwerfen und wie eine leere DNS-Antwort aussehen.
new DnsSeedProvider(dnsSeedProviderOptions);

Dieselbe Prüfung lehnt ein bloßes '10.0.0.1' ab (schreibe '10.0.0.1/32'), ebenso eine leere Liste und ein fehlerhaftes CIDR.

Adressen werden nur in kanonischer Form verglichen

Abschnitt betitelt „Adressen werden nur in kanonischer Form verglichen“

Beide Seiten des Vergleichs — die CIDRs, die du pinnst, und die Adressen, die der Resolver zurückgibt — müssen kanonisch sein: Dezimal-Oktette ohne führende Null, ohne 0x, ohne Exponent, ohne Whitespace, und eine schlichte dezimale Präfixlänge.

Das ist keine Pedanterie. 1e1.0.0.1, 010.0.0.1 und 0x0a.0.0.1 lesen sich für einen großzügigen Zahlen-Parser alle als 10.0.0.1, aber die Socket-Schicht wertet sie als keine IP und löst sie stattdessen über DNS auf — eine Antwort in einer dieser Schreibweisen käme also an einem 10.0.0.0/8-Pin vorbei und verbände sich dann dorthin, wohin der Resolver des Angreifers zeigt. Alles Nicht-Kanonische gilt jetzt als Hostname, und ein Hostname wird nie gegen einen CIDR-Pin geprüft.

Zwei Konsequenzen, die beim Upgrade zählen:

  • Ein nicht-kanonisch geschriebener Pin-Eintrag ('010.0.0.0/8') wirft bei der Konstruktion, statt ein anderes Netz zu pinnen als dort steht.
  • Ein Tippfehler mit abschließendem Slash ('10.0.0.0/') wirft ebenfalls. Bisher wurde er als /0 gelesen — ein Pin, der ein Netz zulassen sollte und den gesamten Adressraum zuließ.

Ein rein numerisches Host-Suffix ('0.1') wird ebenfalls abgelehnt: Suffix-Matching ist Stringvergleich, ein solcher Eintrag würde also das Ende unbeteiligter Adressen treffen. Pinne stattdessen ein CIDR.

Verworfene Adressen sind gefiltert, nicht fatal — ein veralteter Record kostet diesen Record, nicht den gesamten Bootstrap. Jeder Wurf geht an log, und das lohnt sich zu verdrahten: eine zu enge Pin-Liste und eine leere DNS-Antwort sehen von außen identisch aus, und eine leere Seed-Liste heißt, dass der Node seinen eigenen Single-Node-Cluster bildet.

UmgebungVerwendung
Consul-verwaltete DeploymentsSRV-Modus — Consul schreibt sie automatisch.
Eureka mit DNS-PluginSRV-Modus.
Manuelle Deployments mit DNS-ServerBeide Modi; SRV bevorzugt.
Cloud mit DNS-basiertem Service DiscoverySRV-Modus.
K8sNimm KubernetesApiSeedProvider — K8s’ Headless Services produzieren zwar A-Records, aber der K8s-API-Provider ist zuverlässiger.

lookup() ist kein Einmal-Aufruf — die Bootstrap-Schicht ruft es beim Start auf, und übergeordnete Retry-Logik kann es erneut aufrufen. Damit wiederholte Aufrufe DNS nicht überlasten, hält der Provider einen In-Memory-TTL-Cache pro Instanz:

const dnsSeedProviderOptions = DnsSeedProviderOptions.create()
.withHostname('_actor-ts._tcp.example.com')
.withSystemName('my-app')
.withUseSrv()
.withCacheTtlMs(30_000); // Default 60_000 ms; 0 deaktiviert den Cache
  • Aufrufe innerhalb des cacheTtlMs-Fensters (Default 60 000 ms) werden aus dem Cache bedient, ohne DNS zu berühren.
  • Der erste Aufruf nach Ablauf der TTL fragt DNS erneut ab.
  • Ein fehlgeschlagener Lookup wird nicht gecacht — er wird beim nächsten Aufruf wiederholt.

Sobald Cluster.join fertig ist, übernimmt die Gossip-Schicht des Clusters das Membership-Tracking, sodass DNS nicht mehr auf dem heißen Pfad liegt. Das heißt:

  • Ein Node, der nach dem Start hinzugefügt wird, ist für existierende Peers nicht via DNS sichtbar. Er joint über seinen eigenen DNS-Lookup, kontaktiert einen existierenden Seed, Gossip propagiert weiter.
  • DNS-Record-TTLs spielen nur eine Rolle, wenn ein Lookup tatsächlich DNS trifft (Start und Re-Queries nach Ablauf), nicht für die Steady-State-Laufzeit des Clusters.