Leader Election
KubeOps supports different leader election mechanisms through the LeaderElectionType setting. This allows you to control how multiple operator instances coordinate in a cluster.
Leader Election Types
public enum LeaderElectionType
{
None = 0, // No leader election - all instances process events
Single = 1, // Single leader election - only one instance processes events
Custom = 2, // Escape hatch - user replaces watcher and queue consumer entirely
Scoped = 3 // Scoped leadership - responsibility partitioned across instances
}
Configuration
builder.Services
.AddKubernetesOperator(settings => settings
.WithLeaderElection(LeaderElectionType.Single)
.WithLeaderElectionTimings(
leaseDuration: TimeSpan.FromSeconds(15),
renewDeadline: TimeSpan.FromSeconds(10),
retryPeriod: TimeSpan.FromSeconds(2)));
Behavior on Leadership Loss
With LeaderElectionType.Single, both the resource watcher and the reconciliation queue are gated on leadership. When an instance loses its lease it:
- suspends the queue's intake — any further enqueue is dropped, so a still-running reconciler's
RequeueAfterand error retries cannot leave work behind; - cancels the queue processing loop and any in-flight reconciliation; and
- clears the queue — pending and scheduled entries accumulated by the former leader are discarded.
While the instance is not the leader the intake stays closed. On re-acquiring leadership the intake reopens and the watcher re-lists the current state, so processing resumes from a clean slate.
What this does and does not guarantee. The gate protects the queue side: no stale entry, timed requeue, or error retry from a former leader survives a leadership transition. It does not terminate the process (unlike controller-runtime), so it cannot stop a non-cooperative reconciler that ignores its CancellationToken from performing external side effects while it was the leader. Therefore:
- Reconcilers must honor cancellation and return promptly once their token is cancelled.
- Reconcilers must be idempotent. As a second line of defense, concurrent writes to the same object are serialized by the API server via
resourceVersion; a stale write fails with HTTP 409 Conflict.
See the Kubernetes leases documentation for leader-election semantics.
Scoped Leader Election
LeaderElectionType.Scoped partitions responsibility across multiple operator instances instead
of electing a single global leader. The default watcher and queue pipeline (including multiple
controllers per entity and shared watchers) runs on every instance; before an event is enqueued
and before a queued entry is reconciled, the SDK asks a user-provided ILeadershipScope whether
this instance is responsible for the entity. The partitioning dimension (namespace, labels,
name hashing, region annotations, ...) and the coordination mechanism (per-namespace Lease
objects, a wildcard lease, an external assignment service, ...) are entirely up to the scope
implementation.
Step 1: Implement the leadership scope
For the common namespace partitioning, derive from NamespacedLeadershipScope:
using KubeOps.Abstractions.LeaderElection;
public sealed class LeaseBasedNamespaceScope : NamespacedLeadershipScope
{
protected override ValueTask<bool> IsResponsibleForNamespaceAsync(
string? @namespace, CancellationToken cancellationToken)
{
// Answer from cached state - this is called for every watch event and every
// dequeued entry. Coordinate however fits your deployment, e.g. per-namespace
// leases, a wildcard lease, or consistent hashing.
return ValueTask.FromResult(/* ... */ true);
}
// Call OnScopeChanged() whenever the assignment changes, e.g. from a background
// service that renews and observes the leases.
}
For any other partitioning dimension, implement ILeadershipScope directly - the decision is
made per entity:
public sealed class RegionLeadershipScope : ILeadershipScope
{
public event Action? ScopeChanged;
public ValueTask<bool> IsResponsibleForAsync(
IKubernetesObject<V1ObjectMeta> entity, CancellationToken cancellationToken) =>
ValueTask.FromResult(entity.GetAnnotation("example.com/region") == myRegion);
}
Step 2: Register the scope
builder.Services
.AddKubernetesOperator(settings => settings
.WithLeaderElection(LeaderElectionType.Scoped))
.RegisterComponents();
builder.Services.AddSingleton<ILeadershipScope, LeaseBasedNamespaceScope>();
The SDK wires a ScopeAwareResourceWatcher and a ScopeAwareEntityQueueBackgroundService per
entity; no elector, custom watcher, or custom queue consumer is required.
Semantics
- The watch stream runs permanently on every instance; events for entities the instance is not responsible for are skipped before deduplication and enqueueing.
- Every dequeued entry is re-checked, so entries that were pending when responsibility moved to another instance are skipped instead of reconciled twice.
- When
ScopeChangedis raised, this entity type's deduplication cache is dropped and the watched entities are re-listed (applying the configured namespace, label selector, and field selector) and run through the regular event path, so creations and modifications that happened while another instance was (or nobody was) responsible are picked up. Clearing the cache first guarantees that an entity handed back to this instance is reconciled even when itsgeneration/resourceVersionis unchanged since the previous ownership term — the takeover reconcile would otherwise be suppressed as a duplicate. Reconcilers must therefore be idempotent, since every in-scope entity is reconciled once per scope change. - Deletions are not recovered by the resync. The resync is a
Listof objects that still exist; it cannot reconstruct a tombstone. If an object without a blocking finalizer is deleted during a window in which no instance was responsible (or before the acquiring instance's watch observed theDeletedevent), theDeletedevent is dropped at the responsibility gate andDeletedAsyncruns on no instance. This mirrors standard Kubernetes semantics, whereDeletedcallbacks are inherently best-effort (an operator that is down during a deletion misses them too). For cleanup that must not be lost across scope handovers, use a finalizer: a blocking finalizer keeps the object alive (withDeletionTimestampset) until it is processed, so the acquiring instance sees it in the resyncListas a modification and runs finalization. - Losing responsibility needs no action: pending entries are skipped by the per-entry check. A
reconciliation already in flight is not cancelled - reconcilers must be idempotent, and stale
writes are rejected by the API server via
metadata.resourceVersion(HTTP 409). - Unlike
Custom,Scopedkeeps the full default pipeline: multiple controllers per entity, selectors, and shared watchers keep working.
Custom Leader Election
The Custom leader election type is the escape hatch for owning the pipeline yourself. It is the
right choice only when the decision hook of Scoped is not enough because you must replace
watcher or consumer:
- Own watcher mechanics: events from another source, custom re-list/backoff behavior, or
custom deduplication instead of the built-in
ResourceWatcher<TEntity>mechanics. - Leadership-driven watch lifecycle:
Scopedkeeps a full watch stream open on every instance and filters client-side; if watch streams must start and stop with responsibility (e.g. per-namespace watches bound to leases, to cut API-server event volume across many shards), the watcher itself has to implement it. - Own queue consumption: prioritization, custom parallelism or gating semantics, durable consumption.
For partitioning responsibility across instances, use Scoped instead - it keeps the entire
default pipeline (multiple controllers, selectors, shared watchers) and only delegates the
responsibility decision.
With Custom, the SDK registers no watcher and no queue consumer - with custom
coordination it cannot know how leadership should drive either. AddController registers the
reconciler and the entity queue, and you add your own watcher (e.g. derived from
ResourceWatcher<TEntity>) plus a queue consumer (e.g. derived from
EntityQueueBackgroundService<TEntity>) as hosted services. A watcher without a consumer would
enqueue events that nothing ever drains, so the consumer is required.
Enabling WithRegistrationValidation() makes host startup fail fast with an
InvalidRegistrationException if a piece is missing, instead of starting an operator that
silently processes nothing.
builder.Services
.AddKubernetesOperator(settings => settings
.WithLeaderElection(LeaderElectionType.Custom)
.WithRegistrationValidation())
.AddController<V1DemoEntityController, V1DemoEntity>();
builder.Services
.AddHostedService<MyCustomResourceWatcher<V1DemoEntity>>()
.AddHostedService<EntityQueueBackgroundService<V1DemoEntity>>();
Best Practices
- Start with
Singlefor simple deployments - Use
Scopedto shard responsibility by namespace across instances - Use
Customonly when you need to own the whole watcher/queue pipeline - Test failover scenarios to ensure seamless transitions
- Monitor leader election status in your logs
- Set appropriate lease durations based on your workload
Troubleshooting
- Verify RBAC permissions for lease resources
- Check network connectivity between instances
- Review lease duration settings
- Monitor logs for leader election events
- Ensure cluster time and local time are synchronized (time drift can cause lease issues)