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 } from 'actor-ts/coordination';
const lease = new KubernetesLease({
name: 'my-singleton-lease',
owner: process.env.POD_NAME!,
ttlMs: 30_000,
renewalIntervalMs: 10_000,
namespace: process.env.K8S_NAMESPACE!,
});

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.

interface KubernetesLeaseSettings {
// Aus LeaseSettings:
name: string;
owner: string;
ttlMs: number;
renewalIntervalMs?: number;
acquireRetries?: number;
acquireRetryDelayMs?: number;
// K8s-spezifisch:
namespace: string;
apiBaseUrl?: string; // den In-Cluster-Default überschreiben
serviceAccountToken?: string; // den In-Cluster-Default überschreiben
}
K8s-FeldDefaultWas
namespacePflichtK8s-Namespace, in dem die Lease-Ressource liegt.
apiBaseUrlin-clusterDie URL des K8s-API-Servers — Default https://kubernetes.default.svc.
serviceAccountTokenin-clusterDas Service-Account-Token des Pods — Default /var/run/secrets/kubernetes.io/serviceaccount/token.

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

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

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, patcht das Framework spec.renewTime alle renewalIntervalMs:

PATCH /apis/coordination.k8s.io/v1/namespaces/<ns>/leases/<name>
{ spec: { renewTime: "2025-05-13T12:00:00.000Z" } }

Wenn der Patch fehlschlägt:

  • Transient (5xx, connection refused) → retry, loggen, irgendwann aufgeben, wenn ttlMs ohne Erfolg vergeht.
  • CAS-Conflict (409) → ein anderer Halter hat übernommen; onLost feuert.

onLost feuert, wenn:

  • Ein Renewal-Patch 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 PATCH alle renewalIntervalMs, solange gehalten.
  • 1 PATCH (oder 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):

const lease = new KubernetesLease({
name: 'test-lease-' + crypto.randomUUID(),
owner: 'test-runner',
ttlMs: 5_000,
apiBaseUrl: 'https://localhost:8443',
serviceAccountToken: fs.readFileSync('./test-token', 'utf-8'),
namespace: 'test',
});
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.