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; // alle drei zusammen, oder keines
authToken?: string; // alle drei zusammen, oder keines
caCert?: string; // alle drei zusammen, oder keines
tokenReloadIntervalMs?: number;
};
K8s-FeldDefaultWas
namespacePflichtK8s-Namespace, in dem die Lease-Ressource liegt.
apiServerUrlin-clusterDie URL des K8s-API-Servers — ohne Angabe https://kubernetes.default.svc. Verlangt authToken + caCert.
authTokenin-clusterBearer-Token für den API-Server — ohne Angabe /var/run/secrets/kubernetes.io/serviceaccount/token. Verlangt apiServerUrl + caCert.
caCertin-clusterPEM-kodiertes CA-Zertifikat für das TLS des API-Servers — ohne Angabe /var/run/secrets/kubernetes.io/serviceaccount/ca.crt. Verlangt apiServerUrl + authToken.
tokenReloadIntervalMs60000Wie 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.

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-nothing

Frü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 required

Die 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/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 — aber nie ungeprüft, denn beide Felder hat genau der Halter geschrieben, über den sie urteilen sollen:

  • leaseDurationSeconds zählt höchstens für 4 × das eigene ttlMs des Herausforderers. Der Faktor ist bewusst großzügig statt eines harten Clamps auf ttlMs: 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 ein ttlMs in 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 renewTime zä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.

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.

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) → onLost feuert.
  • CAS-Conflict (409) oder 404 → das Objekt wird erneut gelesen, bevor der Besitz aufgegeben wird: ein 409 sagt nur, dass sich der resourceVersion bewegt hat, nicht dass jemand anderes den Lease übernommen hat. Was der Re-Read findet, entscheidet:
    • weg → jemand hat das Objekt gelöscht; onLost feuert.
    • eine andere holderIdentity → eine echte Übernahme; onLost feuert.
    • weiterhin dieser Owner → nichts wurde verloren. Der resourceVersion des Servers wird übernommen, damit der CAS des nächsten Takts passt, und die Schleife hält weiter.
  • 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.

onLost feuert, wenn:

  • Ein Renewal-PUT abgelehnt wird und der Re-Read eine andere holderIdentity zeigt — 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 ttlMs verhindert.

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.

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.