Zum Inhalt springen
Deutsch

Kubernetes-API Seed Provider

KubernetesApiSeedProvider liest die Endpoints eines benannten Service aus der K8s-API und gibt die IPs der bereiten Pods als Seeds zurück. Funktioniert ohne DNS, ohne SRV, ohne manuelle Seed-Listen-Pflege — der Service ist dein Vertrag.

import { Cluster, ClusterOptions, KubernetesApiSeedProvider, KubernetesApiSeedProviderOptions } from 'actor-ts';
const kubernetesApiSeedProviderOptions = KubernetesApiSeedProviderOptions.create()
.withNamespace(process.env.K8S_NAMESPACE!)
.withServiceName('actor-ts')
.withSystemName('my-app')
.withPort(2552);
const provider = new KubernetesApiSeedProvider(
kubernetesApiSeedProviderOptions,
);
const seeds = await provider.lookup();
const clusterOptions = ClusterOptions.create()
.withHost(process.env.POD_IP!)
.withPort(2552)
.withSeeds(seeds);
await Cluster.join(
system,
clusterOptions,
);

Für jeden bereiten Pod hinter dem Service actor-ts im Namespace gibt der Provider <pod-ip>:2552 zurück.

type KubernetesApiSeedProviderOptionsType = {
namespace: string;
serviceName: string;
systemName: string;
port: number;
fetchEndpoints?: () => Promise<string[]>; // In-Cluster-API überschreiben
pinnedAddresses?: readonly string[]; // CIDRs, in die die Pod-IPs fallen müssen
log?: (message: string, error?: unknown) => void;
};
FeldWas
namespaceK8s-Namespace, der abgefragt wird — typischerweise dein App-Namespace.
serviceNameDer Service- (oder Endpoints-)Name, dessen bereite Pods den Cluster bilden.
systemNameDer Actor-System-Name, der auf jede entdeckte Adresse gestempelt wird.
portDer Cluster-Transport-Port auf jedem Backing-Pod.
fetchEndpointsÜberschreibt die Endpoints-Fetch-Funktion — Default ist die In-Cluster-API.
pinnedAddressesCIDRs, in die die entdeckten Pod-IPs fallen müssen. Unbesetzt heißt kein Pinning.
logMeldet Adressen, die pinnedAddresses verworfen hat. Default: No-op.

Für Pods, die in-cluster laufen, sind nur die letzten drei Felder optional — das Framework liest die Endpoints vom Standard-API unter https://kubernetes.default.svc mit dem gemounteten ServiceAccount-Token. Die anderen vier Felder sind Pflicht.

Ein Endpoints-Objekt darf jede beliebige IP nennen, auch eine, die keinem Pod im Cluster gehört. pinnedAddresses begrenzt, wohin ein Schreibzugriff auf dieses Objekt den Bootstrap umlenken kann:

const kubernetesApiSeedProviderOptions = KubernetesApiSeedProviderOptions.create()
.withNamespace(process.env.K8S_NAMESPACE!)
.withServiceName('actor-ts')
.withSystemName('my-app')
.withPort(2552)
.withPinnedAddresses(['10.244.0.0/16'])
.withLog((message) => logger.warn(message));

Einträge sind hier ausschließlich CIDRs — Endpoints lösen immer zu IPs auf, ein Host-Suffix könnte also nie greifen und wird bei der Konstruktion abgelehnt, ebenso eine leere Liste und ein fehlerhaftes CIDR. Adressen außerhalb der Liste werden herausgefiltert und über log gemeldet, eine Meldung pro verworfener Adresse.

Dieser Provider ist ohnehin der besser verteidigte der beiden: der Default-Fetcher pinnt TLS auf die ServiceAccount-CA und erbt das Vertrauensproblem von DNS deshalb nicht so wie der DNS-Provider. RBAC, das Endpoints schreiben darf, ist trotzdem ein sehr viel billigerer Fund als ein CA-Schlüssel — und genau das kostet dieser Pin einen Angreifer.

Das ServiceAccount des Pods braucht Read-Zugriff auf die Endpoints des Service im Namespace:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: actor-ts-endpoints-reader
namespace: my-app
rules:
- apiGroups: [""]
resources: ["endpoints"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: actor-ts-endpoints-reader
namespace: my-app
subjects:
- kind: ServiceAccount
name: actor-ts
roleRef:
kind: Role
name: actor-ts-endpoints-reader
apiGroup: rbac.authorization.k8s.io

Ohne diese bekommt das Lookup einen 403; Cluster.join retried endlos.

Aus dem Endpoints-Objekt des Service:

  • Bereite Pods in subsets[].addresses[] → eingeschlossen als <podIP>:<port>.
  • Nicht bereite / pending Pods (in notReadyAddresses) → übersprungen (noch keine bereite IP).
  • Dieser Pod selbst → kann inkludiert sein oder nicht, je nach Readiness-Timing; der Cluster behandelt Self-as-Seed korrekt.

Die Membership wird durch den spec.selector des Service selbst definiert — der Provider braucht nur den Service-Namen:

# Ein Service vor jedem Cluster-Pod:
apiVersion: v1
kind: Service
metadata:
name: actor-ts
spec:
clusterIP: None # headless — Endpoints tragen die Pod-IPs
selector:
app: actor-ts
ports:
- port: 2552

Zeig den Provider mit .withServiceName('actor-ts') auf diesen Service. Für die meisten Deployments ein Service pro Cluster — jeder Pod, den der Service selektiert, bootstrappt in einen Cluster.

Für rollenbasierte asymmetrische Cluster (Worker vs. HTTP-Gateways) genügt ein einzelner Service, dessen Selector (app=actor-ts) beide Rollen abdeckt; das Rollen-Tag innerhalb des Clusters ist getrennt von der Pod-Selektion des Service.

ja

nein, erster Pod

Pod startet → Cluster.join aufgerufen

KubernetesApiSeedProvider.lookup

GET /api/v1/namespaces/<ns>/

endpoints/<serviceName>

subsets[].addresses[].ip lesen

[...podIp:port] zurückgeben

Cluster.join probiert jeden Seed

existierender Cluster?

erfolgreich joinen

selbst bootstrappen

Wenn du der erste Pod hochkommst, gibt die K8s-API nur diesen Pod zurück. Die Self-Bootstrap-Logik des Frameworks behandelt das: Cluster.join mit Seeds, die nur sich selbst enthalten, befördert sich selbst zum Leader.

Nachfolgende Pods sehen die existierenden Pods und joinen über sie.

Service-LayoutEffekt
Ein Service, Selector app=actor-tsEin Cluster über den Namespace.
Zwei Services, Selektoren env=prod / env=stagingSeparate Prod- und Staging-Cluster in einem Namespace.
Ein Service pro benanntem ClusterExpliziter Cluster-Name; mehrere Cluster in einem Namespace.

Stabiles Service-Design ist wichtig — die Identität des Clusters ist der Service, auf den du zeigst. Zu ändern, welche Pods der Service selektiert (oder den Service-Namen), splittet den Cluster.