KubernetesLease
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.
Configuration
Section titled “Configuration”type KubernetesLeaseOptionsType = { // From LeaseOptionsType: name: string; owner: string; ttlMs: number; renewalIntervalMs?: number; acquireRetries?: number; acquireRetryDelayMs?: number;
// K8s-specific: namespace: string; apiServerUrl?: string; // override the in-cluster default authToken?: string; // override the in-cluster default caCert?: string; // override the in-cluster default};| K8s field | Default | What |
|---|---|---|
namespace | required | K8s namespace where the Lease resource lives. |
apiServerUrl | in-cluster | The K8s API server URL — defaults to https://kubernetes.default.svc. |
authToken | in-cluster | The pod’s service account token — defaults to /var/run/secrets/kubernetes.io/serviceaccount/token. |
caCert | in-cluster | PEM-encoded CA cert for the API server’s TLS — defaults to /var/run/secrets/kubernetes.io/serviceaccount/ca.crt. |
For pods running in-cluster, you only need namespace and name
(+ the standard LeaseOptionsType fields). The framework reads the
API URL and token from the standard locations.
For tests / dev pointing at a local K8s API (kind, minikube),
override apiServerUrl + authToken + caCert.
The pod’s ServiceAccount needs permission to manage Lease
resources:
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.ioWithout these, acquire() rejects with 403 (forbidden).
Without delete, release() works but leaves the Lease object
behind after release (harmless; the next acquire reuses it).
What gets created
Section titled “What gets created”The first acquire() call creates a Lease object:
$ kubectl get lease -n my-appNAME HOLDER AGEmy-singleton-lease pod-abc-1 30sThe framework writes:
metadata.name— the lease name.spec.holderIdentity— the owner.spec.acquireTime— when this owner took it.spec.renewTime— last renewal (updated everyrenewalIntervalMs).spec.leaseDurationSeconds— derived fromttlMs.
Other holders check renewTime + leaseDurationSeconds < now()
to decide whether the current holder is stale.
Acquire flow
Section titled “Acquire flow”The atomicity comes from K8s’s optimistic-concurrency CAS via
resourceVersion — two simultaneous attempts to claim a stale
lease produce one winner.
Renewal
Section titled “Renewal”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.
Loss detection
Section titled “Loss detection”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
renewalIntervalMswhile 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.
When NOT to use it
Section titled “When NOT to use it”Tests against a real K8s
Section titled “Tests against a real K8s”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.
Where to next
Section titled “Where to next”- Coordination overview — the bigger picture.
- Lease API — the
contract
KubernetesLeaseimplements. - InMemoryLease — the dev/test alternative.
- Kubernetes deployment — the broader K8s recipe.
- Singleton with lease — the main consumer.
