Zum Inhalt springen
Deutsch

KubernetesLease

KubernetesLease implementiert das Lease-Interface gegen die eingebaute Lease-Ressource von Kubernetes (die coordination.k8s.io/v1-API). Produktionstauglich: backed durch etcd, stark konsistent, RBAC-kontrolliert.

import { KubernetesLease, KubernetesLeaseOptions } from 'actor-ts/coordination';
const kubernetesLeaseOptions = KubernetesLeaseOptions.create()
.withName('my-singleton-lease')
.withOwner(process.env.POD_NAME!)
.withTtlMs(30_000)
.withRenewalIntervalMs(10_000)
.withNamespace(process.env.K8S_NAMESPACE!);
const lease = new KubernetesLease(
kubernetesLeaseOptions,
);

Der etcd-backed Store des K8s-API-Servers liefert die Single-Holder-Garantie. Zwei Pods, die nebenläufig acquire() aufrufen, produzieren exakt einen Gewinner — unabhängig von Pod-Scheduling, Netzwerk-Partition zwischen Pods etc.

type KubernetesLeaseOptionsType = {
// Aus LeaseOptionsType:
name: string;
owner: string;
ttlMs: number;
renewalIntervalMs?: number;
acquireRetries?: number;
acquireRetryDelayMs?: number;
// K8s-spezifisch:
namespace: string;
apiServerUrl?: string; // den In-Cluster-Default überschreiben
authToken?: string; // den In-Cluster-Default überschreiben
caCert?: string; // den In-Cluster-Default überschreiben
};
K8s-FeldDefaultWas
namespacePflichtK8s-Namespace, in dem die Lease-Ressource liegt.
apiServerUrlin-clusterDie URL des K8s-API-Servers — Default https://kubernetes.default.svc.
authTokenin-clusterDas Service-Account-Token des Pods — Default /var/run/secrets/kubernetes.io/serviceaccount/token.
caCertin-clusterPEM-kodiertes CA-Zertifikat für das TLS des API-Servers — Default /var/run/secrets/kubernetes.io/serviceaccount/ca.crt.

Für Pods, die in-cluster laufen, brauchst du nur namespace und name (+ die Standard-LeaseOptionsType-Felder). Das Framework liest API-URL und Token von den Standard-Locations.

Für Tests / Dev gegen eine lokale K8s-API (kind, minikube) überschreibe apiServerUrl + authToken + caCert.

Das ServiceAccount des Pods braucht Rechte, um Lease-Ressourcen zu verwalten:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: actor-ts-lease-holder
namespace: my-app
rules:
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["get", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: actor-ts-lease-holder
namespace: my-app
subjects:
- kind: ServiceAccount
name: actor-ts
roleBinding:
kind: Role
name: actor-ts-lease-holder
apiGroup: rbac.authorization.k8s.io

Ohne diese rejectet acquire() mit 403 (Forbidden).

Ohne delete funktioniert release(), aber das Lease-Objekt bleibt nach dem Release stehen (harmlos; das nächste Acquire nutzt es weiter).

Der erste acquire()-Aufruf erzeugt ein Lease-Objekt:

Terminal-Fenster
$ kubectl get lease -n my-app
NAME HOLDER AGE
my-singleton-lease pod-abc-1 30s

Das Framework schreibt:

  • metadata.name — den Lease-Namen.
  • spec.holderIdentity — den Owner.
  • spec.acquireTime — wann dieser Owner ihn übernommen hat.
  • spec.renewTime — letztes Renewal (wird alle renewalIntervalMs aktualisiert).
  • spec.leaseDurationSeconds — abgeleitet aus ttlMs.

Andere Halter prüfen renewTime + leaseDurationSeconds < now(), um zu entscheiden, ob der aktuelle Halter stale ist.

nein

ja

dieser Owner hält ihn bereits

anderer Halter, noch frisch

anderer Halter, stale

acquire

Lease-Objekt GET-en

existiert?

CREATE mit diesem Owner

bei 409 Conflict — retry

Halter + renewTime prüfen

wer hält ihn?

true zurückgeben — idempotent

false zurückgeben — Contention

CAS — Owner ersetzen, wenn

renewTime passt

Die Atomarität kommt vom optimistic-concurrency CAS via resourceVersion von K8s — zwei gleichzeitige Versuche, einen stalen Lease zu beanspruchen, produzieren einen Gewinner.

Während gehalten, PUTtet das Framework alle renewalIntervalMs das gesamte Lease-Objekt neu — mit hochgesetztem spec.renewTime:

PUT /apis/coordination.k8s.io/v1/namespaces/<ns>/leases/<name>
{
metadata: { resourceVersion: "148302", ... }, // für den CAS zurückgeschickt
spec: { holderIdentity: "pod-abc-1", leaseDurationSeconds: 30,
renewTime: "2025-05-13T12:00:00.000Z" }
}

Der resourceVersion aus dem letzten Read macht den Write zu einem optimistic-concurrency Compare-and-Set — K8s rejectet mit 409, wenn seither jemand anderes das Objekt modifiziert hat.

Wenn der Write fehlschlägt, gibt das Renewal sofort auf und feuert onLost — es gibt kein Retry-Budget innerhalb der Schleife:

  • Transient (5xx, connection refused, Timeout)onLost feuert.
  • CAS-Conflict (409) oder 404 → ein anderer Halter hat übernommen, oder das Objekt wurde gelöscht; onLost feuert.

onLost feuert, wenn:

  • Ein Renewal-PUT einen CAS-Conflict zurückgibt.
  • Das Framework feststellt, dass der Lease von jemandem anderem modifiziert wurde (ein Probe-GET vor einer kritischen Operation).
  • Netzwerk-Partition Renewals länger als ttlMs verhindert.

Der Handler sollte den eigentumsabhängigen State sofort fallen lassen — siehe Lease-API für den Vertrag.

Jeder Lease-Halter erzeugt:

  • 1 GET + (potenziell) 1 CREATE beim Acquire.
  • 1 PUT alle renewalIntervalMs, solange gehalten.
  • 1 DELETE beim Release.

Für eine 30-Sekunden-TTL mit 10-Sekunden-Renewal sind das ~6 API-Calls pro Minute pro Lease. Centbeträge in jedem moderaten K8s-Deployment.

Für Cluster mit vielen Leases (z. B. einer pro Sharded Entity Type + einer pro Singleton + einer pro Koordinator) ist die Last auf dem API-Server immer noch vernachlässigbar — K8s schafft locker tausende Lease-Writes pro Sekunde.

Für Integrationstests gegen eine echte K8s-API (kind, minikube, ephemere CI-Cluster):

import { randomUuid } from 'actor-ts';
const kubernetesLeaseOptions = KubernetesLeaseOptions.create()
.withName('test-lease-' + randomUuid())
.withOwner('test-runner')
.withTtlMs(5_000)
.withApiServerUrl('https://localhost:8443')
.withAuthToken(fs.readFileSync('./test-token', 'utf-8'))
.withCaCert(fs.readFileSync('./test-ca.crt', 'utf-8'))
.withNamespace('test');
const lease = new KubernetesLease(
kubernetesLeaseOptions,
);
await lease.acquire();
expect(lease.checkAlive()).toBe(true);
await lease.release();

Nimm pro Test eindeutige Lease-Namen (Zufalls-UUID-Suffix), damit parallele Tests sich nicht in die Quere kommen. Aufräumen mit release() + einer finalen Delete-Runde im Test-Teardown.