Aller au contenu
Français

KubernetesLease

Ce contenu n’est pas encore disponible dans votre langue.

KubernetesLease implements the Lease interface against Kubernetes’s built-in Lease resource (the coordination.k8s.io/v1 API). Production-grade: backed by etcd, strongly consistent, RBAC-controlled.

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,
);

The K8s API server’s etcd-backed store provides the single-holder guarantee. Two pods concurrently calling acquire() produce exactly one winner, regardless of pod scheduling, network partition between pods, etc.

type KubernetesLeaseOptionsType = {
// From LeaseOptionsType:
name: string;
owner: string;
ttlMs: number;
renewalIntervalMs?: number;
acquireRetries?: number;
acquireRetryDelayMs?: number;
// K8s-specific:
namespace: string;
apiServerUrl?: string; // all three together, or none of them
authToken?: string; // all three together, or none of them
caCert?: string; // all three together, or none of them
};
K8s fieldDefaultWhat
namespacerequiredK8s namespace where the Lease resource lives.
apiServerUrlin-clusterThe K8s API server URL — https://kubernetes.default.svc when unset. Requires authToken + caCert.
authTokenin-clusterBearer token for the API server — /var/run/secrets/kubernetes.io/serviceaccount/token when unset. Requires apiServerUrl + caCert.
caCertin-clusterPEM-encoded CA cert for the API server’s TLS — /var/run/secrets/kubernetes.io/serviceaccount/ca.crt when unset. Requires apiServerUrl + authToken.

For pods running in-cluster, you only need namespace and name (+ the standard LeaseOptionsType fields). The framework reads the API URL, token and CA cert from the standard locations.

For tests / dev pointing at a local K8s API (kind, minikube), override apiServerUrl + authToken + caCert.

The three connection fields are one credential

Section titled “The three connection fields are one credential”

They are all-or-nothing: supply all three, or none of them. A partial set throws OptionsError at construction.

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

Each field used to fall back to the in-cluster mount on its own, so naming an apiServerUrl and nothing else sent the pod’s own ServiceAccount token to that host. The TLS pin limited the damage — the target still had to present a chain to the cluster CA — but a cluster credential should never travel to an address it was not issued for.

apiServerUrl must use https. The client dials node:https regardless of what the URL says, so an http:// URL never produced a plaintext connection; it produced a confusing one.

Required fields are enforced at construction

Section titled “Required fields are enforced at construction”

name, owner, ttlMs and namespace have no default. The constructor throws OptionsError when one of them is missing, before a single request reaches the API server:

const incompleteOptions = KubernetesLeaseOptions.create()
.withName('my-singleton-lease')
.withNamespace('my-app');
new KubernetesLease(incompleteOptions);
// OptionsError: KubernetesLeaseOptions: owner is required

The check is not cosmetic. Without owner the Lease object is written with no spec.holderIdentity — the undefined key simply drops out of the JSON body — and an unowned lease reads as free to every pod: every acquire() returns true, and the single-holder guarantee is gone without one error being logged. A missing ttlMs produces the same outcome by a different route, since the expiry it computes is NaN and NaN is never later than now.

The pod’s ServiceAccount needs permission to manage Lease resources:

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

Without these, acquire() rejects with 403 (forbidden).

Without delete, release() works but leaves the Lease object behind after release (harmless; the next acquire reuses it).

The first acquire() call creates a Lease object:

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

The framework writes:

  • metadata.name — the lease name.
  • spec.holderIdentity — the owner.
  • spec.acquireTime — when this owner took it.
  • spec.renewTime — last renewal (updated every renewalIntervalMs).
  • spec.leaseDurationSeconds — derived from ttlMs.

Other holders check renewTime + leaseDurationSeconds < now() to decide whether the current holder is stale — but never at face value, because both fields were written by the holder they are being used to judge:

  • leaseDurationSeconds counts for at most 4 × the challenger’s own ttlMs. The factor is deliberately generous rather than a straight clamp to ttlMs: during a rolling upgrade that raises the TTL, a node still running the smaller value would otherwise declare a live holder expired and take the lease from it.
  • A renewTime further ahead than one ttlMs is not credible from a holder with a working clock, and counts as expired. Believing it is what lets one write wedge the lease for good.
  • A missing or unparseable renewTime counts as live. For an owned record with no usable timestamp, “someone holds this” is the safe reading.

So a corrupt or hostile Lease object costs at most 4 × ttlMs of unavailability instead of pinning the lease indefinitely. Configure the same ttlMs on every pod that competes for one lease, and the tolerance never comes into play.

no

yes

this owner already holds

another holder, still fresh

another holder, stale

acquire

GET the lease object

exists?

CREATE with this owner

if 409 conflict — retry

check holder + renewTime

who holds it?

return true — idempotent

return false — contention

CAS — replace owner if

renewTime matches

The atomicity comes from K8s’s optimistic-concurrency CAS via resourceVersion — two simultaneous attempts to claim a stale lease produce one winner.

While holding, the framework re-PUTs the whole Lease object with a bumped spec.renewTime every renewalIntervalMs:

PUT /apis/coordination.k8s.io/v1/namespaces/<ns>/leases/<name>
{
metadata: { resourceVersion: "148302", ... }, // echoed back for the CAS
spec: { holderIdentity: "pod-abc-1", leaseDurationSeconds: 30,
renewTime: "2025-05-13T12:00:00.000Z" }
}

The resourceVersion from the last read turns the write into an optimistic-concurrency compare-and-set — K8s rejects with 409 if anyone else mutated the object since.

If the write fails, renewal gives up immediately and fires onLost — there is no retry budget inside the loop:

  • Transient (5xx, connection refused, timeout) → fire onLost.
  • CAS conflict (409) or 404 → another holder took over, or the object was deleted; fire onLost.

onLost fires when:

  • A renewal PUT returns CAS conflict.
  • The framework observes the lease was modified by someone else (a probe GET before some critical operation).
  • Network partition prevents renewal for longer than ttlMs.

The handler should drop ownership state immediately — see Lease API for the contract.

Each lease holder generates:

  • 1 GET + (potentially) 1 CREATE on acquire.
  • 1 PUT every renewalIntervalMs while holding.
  • 1 DELETE on release.

For a 30-second TTL with 10-second renewal, that’s ~6 API calls per minute per lease. Pennies on any modest K8s deployment.

For clusters with many leases (e.g., one per sharded entity type

  • one per singleton + one per coordinator), the API server load is still negligible — K8s easily handles thousands of Lease writes per second.

For integration tests with a real K8s API (kind, minikube, ephemeral CI clusters):

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();

Use unique lease names per test (random UUID suffix) so parallel tests don’t fight. Tear down with release() + a final delete sweep in test teardown.