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.
Konfiguration
Abschnitt betitelt „Konfiguration“type KubernetesLeaseOptionsType = { // Aus LeaseOptionsType: name: string; owner: string; ttlMs: number; renewalIntervalMs?: number; acquireRetries?: number; acquireRetryDelayMs?: number;
// K8s-spezifisch: namespace: string; apiServerUrl?: string; // alle drei zusammen, oder keines authToken?: string; // alle drei zusammen, oder keines caCert?: string; // alle drei zusammen, oder keines tokenReloadIntervalMs?: number;};| K8s-Feld | Default | Was |
|---|---|---|
namespace | Pflicht | K8s-Namespace, in dem die Lease-Ressource liegt. |
apiServerUrl | in-cluster | Die URL des K8s-API-Servers — ohne Angabe https://kubernetes.default.svc. Verlangt authToken + caCert. |
authToken | in-cluster | Bearer-Token für den API-Server — ohne Angabe /var/run/secrets/kubernetes.io/serviceaccount/token. Verlangt apiServerUrl + caCert. |
caCert | in-cluster | PEM-kodiertes CA-Zertifikat für das TLS des API-Servers — ohne Angabe /var/run/secrets/kubernetes.io/serviceaccount/ca.crt. Verlangt apiServerUrl + authToken. |
tokenReloadIntervalMs | 60000 | Wie lange ein vom ServiceAccount-Mount gelesenes Credential wiederverwendet wird, bevor die Token-Datei erneut geprüft wird. Ohne Wirkung auf ein explizit gesetztes authToken. |
Für Pods, die in-cluster laufen, brauchst du nur namespace und
name (+ die Standard-LeaseOptionsType-Felder). Das Framework
liest API-URL, Token und CA-Zertifikat von den Standard-Locations.
Für Tests / Dev gegen eine lokale K8s-API (kind, minikube)
überschreibe apiServerUrl + authToken + caCert.
Die drei Verbindungsfelder sind ein Credential
Abschnitt betitelt „Die drei Verbindungsfelder sind ein Credential“Sie gelten nur zusammen: entweder alle drei oder keines davon. Ein
Teilsatz wirft OptionsError bei der Konstruktion.
const partialOptions = KubernetesLeaseOptions.create() .withName('my-singleton-lease') .withOwner(process.env.POD_NAME!) .withTtlMs(30_000) .withNamespace('my-app') .withApiServerUrl('https://k8s.example.internal');new KubernetesLease(partialOptions);// OptionsError: KubernetesLeaseOptions: authToken + caCert must be supplied// together with apiServerUrl — explicit API-server credentials are// all-or-nothingFrüher fiel jedes Feld für sich auf den In-Cluster-Mount zurück —
eine apiServerUrl und sonst nichts anzugeben schickte also das
eigene ServiceAccount-Token des Pods an genau diesen Host. Der
TLS-Pin begrenzte den Schaden — das Ziel musste weiterhin eine Kette
zur Cluster-CA vorweisen —, aber ein Cluster-Credential hat an einer
Adresse, für die es nicht ausgestellt wurde, nichts verloren.
apiServerUrl muss https verwenden. Der Client baut ohnehin einen
node:https-Request, egal was in der URL steht; eine http://-URL
ergab also nie eine Klartext-Verbindung, sondern nur eine
verwirrende.
Die Lebensdauer eines Credentials hängt von seiner Quelle ab
Abschnitt betitelt „Die Lebensdauer eines Credentials hängt von seiner Quelle ab“Die beiden Quellen werden auch nach dem Lesen unterschiedlich behandelt — denn nur eine von beiden kann veralten.
Ein explizit gesetztes authToken wird einmal gelesen und für die
Lebensdauer des Prozesses wiederverwendet. Es gibt nichts, woraus es
neu gelesen werden könnte — es kam aus dem Konstruktor —, ein
Ablaufen ließe es also nur durch sich selbst ersetzen.
Das gemountete ServiceAccount-Token des Pods ist dagegen befristet:
das projizierte Token trägt ein Ablaufdatum, und das Kubelet schreibt
die Datei bei jeder Rotation neu. Ein gemountetes Credential wird
deshalb höchstens tokenReloadIntervalMs lang wiederverwendet;
danach entscheidet die mtime der Token-Datei zwischen einem weiteren
Intervall auf denselben Bytes und einem frischen Read — im
Normalfall kostet das also einen stat pro Intervall und Lease statt
drei Datei-Reads.
const kubernetesLeaseOptions = KubernetesLeaseOptions.create() .withName('my-singleton-lease') .withOwner(process.env.POD_NAME!) .withTtlMs(30_000) .withNamespace('my-app') .withTokenReloadIntervalMs(30_000);Zusätzlich zum Intervall invalidiert ein 401 oder 403 vom API-Server das gecachte Credential: der Mount wird erneut gelesen und der Request genau einmal wiederholt, bevor überhaupt etwas als Lease-Verlust gemeldet wird. Ein explizites Token wird so nie wiederholt — es erneut zu schicken würde den Traffic auf einem ohnehin scheiternden Pfad nur verdoppeln.
Genau das ersetzt das frühere Memoisieren des gemounteten Tokens über
die gesamte Prozesslebensdauer, und dessen Fehlerbild war alles andere
als subtil. Der erste 401 feuerte onLost, ClusterSingleton
stoppte das Kind und versuchte alle fünf Sekunden ein Re-Acquire auf
derselben Lease-Instanz — und jeder Versuch schickte dasselbe tote
Bearer-Token erneut. Der Singleton blieb unten, bis der Pod neu
gestartet wurde; und jedes Replikat, das lange genug lief, steckte im
selben Zustand.
Pflichtfelder werden bei der Konstruktion erzwungen
Abschnitt betitelt „Pflichtfelder werden bei der Konstruktion erzwungen“name, owner, ttlMs und namespace haben keinen Default. Der
Konstruktor wirft OptionsError, sobald eines davon fehlt — bevor
auch nur ein Request den API-Server erreicht:
const incompleteOptions = KubernetesLeaseOptions.create() .withName('my-singleton-lease') .withNamespace('my-app');new KubernetesLease(incompleteOptions);// OptionsError: KubernetesLeaseOptions: owner is requiredDie Prüfung ist nicht kosmetisch. Ohne owner wird das
Lease-Objekt ohne spec.holderIdentity geschrieben — der
undefinierte Key fällt schlicht aus dem JSON-Body heraus — und ein
Lease ohne Holder liest sich für jeden Pod als frei: jedes
acquire() liefert true, und die Single-Holder-Garantie ist weg,
ohne dass ein einziger Fehler geloggt würde. Ein fehlendes ttlMs
führt über einen anderen Weg zum selben Ergebnis, denn die daraus
berechnete Ablaufzeit ist NaN — und NaN liegt nie nach jetzt.
Das ServiceAccount des Pods braucht Rechte, um Lease-Ressourcen
zu verwalten:
apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: name: actor-ts-lease-holder namespace: my-apprules: - apiGroups: ["coordination.k8s.io"] resources: ["leases"] verbs: ["get", "create", "update", "patch", "delete"]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata: name: actor-ts-lease-holder namespace: my-appsubjects: - kind: ServiceAccount name: actor-tsroleBinding: kind: Role name: actor-ts-lease-holder apiGroup: rbac.authorization.k8s.ioOhne 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).
Was erzeugt wird
Abschnitt betitelt „Was erzeugt wird“Der erste acquire()-Aufruf erzeugt ein Lease-Objekt:
$ kubectl get lease -n my-appNAME HOLDER AGEmy-singleton-lease pod-abc-1 30sDas Framework schreibt:
metadata.name— den Lease-Namen.spec.holderIdentity— den Owner.spec.acquireTime— wann dieser Owner ihn übernommen hat.spec.renewTime— letztes Renewal (wird allerenewalIntervalMsaktualisiert).spec.leaseDurationSeconds— abgeleitet austtlMs.
Andere Halter prüfen renewTime + leaseDurationSeconds < now(),
um zu entscheiden, ob der aktuelle Halter stale ist — aber nie
ungeprüft, denn beide Felder hat genau der Halter geschrieben, über
den sie urteilen sollen:
leaseDurationSecondszählt höchstens für 4 × das eigenettlMsdes Herausforderers. Der Faktor ist bewusst großzügig statt eines harten Clamps aufttlMs: bei einem Rolling Upgrade, das die TTL anhebt, würde ein Node mit dem noch kleineren Wert sonst einen lebenden Halter für abgelaufen erklären und ihm den Lease wegnehmen.- Eine
renewTime, die weiter als einttlMsin der Zukunft liegt, ist von einem Halter mit funktionierender Uhr nicht glaubwürdig und zählt als abgelaufen. Ihr zu glauben ist genau das, was einen einzelnen Write den Lease dauerhaft blockieren lässt. - Eine fehlende oder nicht parsebare
renewTimezählt als lebend. Für einen Record mit Owner, aber ohne brauchbaren Zeitstempel ist „irgendwer hält ihn“ die sichere Lesart.
Ein korruptes oder feindliches Lease-Objekt kostet damit höchstens
4 × ttlMs an Nichtverfügbarkeit, statt den Lease unbegrenzt zu
blockieren. Konfiguriere auf allen Pods, die um einen Lease
konkurrieren, dasselbe ttlMs, dann greift die Toleranz nie.
Acquire-Ablauf
Abschnitt betitelt „Acquire-Ablauf“Die Atomarität kommt vom optimistic-concurrency CAS via
resourceVersion von K8s — zwei gleichzeitige Versuche, einen
stalen Lease zu beanspruchen, produzieren einen Gewinner.
Renewal
Abschnitt betitelt „Renewal“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.
Höchstens ein Renewal-PUT ist jemals unterwegs. renewalIntervalMs
ist eine Wall-Clock-Taktung, kein Budget pro Request: der Default ist
ttlMs / 3 — 5 s bei der oben empfohlenen TTL von 15 s — während der
HTTP-Client selbst einen Timeout von 10 s hat, ein einzelner Request
also legitim über zwei Takte laufen kann. Ein Takt, der einen noch
offenen Request vorfindet, wird übersprungen, nicht eingereiht: ein
Renewal trägt nichts, was der nächste nicht auch trägt. Ohne diesen
Schutz werden beide Writes aus demselben Snapshot gebaut und tragen
denselben resourceVersion — der API-Server rejectet dann einen der
eigenen Writes des Halters, siehe unten (#761).
Wenn der Write fehlschlägt, gibt das Renewal sofort auf und feuert
onLost — es gibt kein Retry-Budget innerhalb der Schleife, mit zwei
Ausnahmen:
- Transient (5xx, connection refused, Timeout) →
onLostfeuert. - CAS-Conflict (409) oder 404 → das Objekt wird erneut gelesen,
bevor der Besitz aufgegeben wird: ein 409 sagt nur, dass sich der
resourceVersionbewegt hat, nicht dass jemand anderes den Lease übernommen hat. Was der Re-Read findet, entscheidet:- weg → jemand hat das Objekt gelöscht;
onLostfeuert. - eine andere
holderIdentity→ eine echte Übernahme;onLostfeuert. - weiterhin dieser Owner → nichts wurde verloren. Der
resourceVersiondes Servers wird übernommen, damit der CAS des nächsten Takts passt, und die Schleife hält weiter.
- weg → jemand hat das Objekt gelöscht;
- 401 / 403 → abgelehnt wurde das Credential, nicht der Lease.
Bei einem gemounteten ServiceAccount-Token wird der Mount erneut
gelesen und der PUT genau einmal wiederholt; erst eine zweite
Ablehnung feuert
onLost. Siehe Die Lebensdauer eines Credentials hängt von seiner Quelle ab weiter oben.
Ein Re-Read, der selbst fehlschlägt, fällt in den transienten Fall und
feuert onLost: Besitz, der nicht bestätigt werden kann, darf nicht
angenommen werden.
Verlust-Erkennung
Abschnitt betitelt „Verlust-Erkennung“onLost feuert, wenn:
- Ein Renewal-PUT abgelehnt wird und der Re-Read eine andere
holderIdentityzeigt — oder gar kein Lease-Objekt mehr. - Das Framework feststellt, dass der Lease von jemandem anderem modifiziert wurde (ein Probe-GET vor einer kritischen Operation).
- Netzwerk-Partition Renewals länger als
ttlMsverhindert.
Nicht feuert es bei einem CAS-Conflict, dessen Re-Read weiterhin
diesen Owner nennt. Dann kollidiert der Halter mit einem seiner
eigenen Writes — ein anderer Controller, der das Objekt anfasst, oder
ein re-acquire(), das mit einem Renewal rennt — und der Record gehört
weiterhin uns.
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 — plus 1 zusätzlicher GET bei dem seltenen Takt, dessen PUT abgelehnt wird, um herauszufinden, ob der Lease wirklich verloren ist. - 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.
Wann du es NICHT einsetzt
Abschnitt betitelt „Wann du es NICHT einsetzt“Tests gegen ein echtes K8s
Abschnitt betitelt „Tests gegen ein echtes K8s“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.
Wohin als Nächstes
Abschnitt betitelt „Wohin als Nächstes“- Koordination im Überblick — das Gesamtbild.
- Lease-API — der Vertrag,
den
KubernetesLeaseimplementiert. - InMemoryLease — die Dev-/Test-Alternative.
- Kubernetes-Deployment — das breitere K8s-Rezept.
- Singleton mit Lease — der Haupt-Consumer.
