k8s_controller/leader_election.rs
1use std::error::Error as _;
2use std::future::Future;
3use std::pin::pin;
4use std::time::{Duration, Instant};
5
6use futures::future::{self, Either};
7use k8s_openapi::api::coordination::v1::{Lease, LeaseSpec};
8use k8s_openapi::apimachinery::pkg::apis::meta::v1::MicroTime;
9use k8s_openapi::jiff::Timestamp;
10use kube::Client;
11use kube::api::{Api, ObjectMeta, PostParams};
12use rand::{Rng, rng};
13use tracing::{debug, error, info, warn};
14
15/// Lease-based leader election, allowing multiple replicas of a controller
16/// to run while ensuring that only one of them is reconciling at a time.
17///
18/// This uses a `coordination.k8s.io/v1` [`Lease`] object, following the same
19/// protocol (and using the same default timings) as the Kubernetes client-go
20/// `leaderelection` package: the leader repeatedly renews the lease, and
21/// other candidates take the lease over if the leader fails to renew it for
22/// `lease_duration`. Expiry is determined by observing the lease go
23/// unchanged for `lease_duration`, rather than by comparing timestamps in
24/// the lease against the local clock, so it is robust to clock skew between
25/// candidates. Note that this protocol is cooperative: it guarantees mutual
26/// exclusion only among candidates that respect the lease.
27///
28/// The controller's service account needs `get`, `create`, and `update`
29/// permissions on `leases` in the `coordination.k8s.io` API group for this
30/// to work.
31///
32/// The `identity` must be non-empty and unique among the candidates for a
33/// given lease; the pod name (available in the `HOSTNAME` environment
34/// variable, or via the downward API) is a good choice. Beware that
35/// candidates for the same lease which share an identity will each mistake
36/// the other's renewals for their own and all act as leader simultaneously,
37/// so never create multiple `LeaderElection`s in the same process with the
38/// same lease name and identity. To have one lease guard several
39/// controllers in a process, share a single `LeaderElection` via
40/// [`with_lease`](LeaderElection::with_lease) instead.
41#[derive(Clone)]
42pub struct LeaderElection {
43 api: Api<Lease>,
44 lease_name: String,
45 identity: String,
46 lease_duration: Duration,
47 renew_deadline: Duration,
48 retry_period: Duration,
49}
50
51impl LeaderElection {
52 /// Creates a leader election configuration for the [`Lease`] named
53 /// `lease_name` in the given `namespace`, identifying this instance of
54 /// the controller as `identity`. The timings default to the client-go
55 /// defaults: a lease duration of 15 seconds, a renew deadline of 10
56 /// seconds, and a retry period of 2 seconds.
57 pub fn new(client: Client, namespace: &str, lease_name: &str, identity: &str) -> Self {
58 Self {
59 api: Api::namespaced(client, namespace),
60 lease_name: lease_name.to_owned(),
61 identity: identity.to_owned(),
62 lease_duration: Duration::from_secs(15),
63 renew_deadline: Duration::from_secs(10),
64 retry_period: Duration::from_secs(2),
65 }
66 }
67
68 /// Sets how long a non-leader must wait after the last observed change
69 /// to the lease before forcibly taking it over. Larger values slow down
70 /// failover; smaller values increase the risk that a leader which is
71 /// still running (but partitioned from the API server) has not yet
72 /// stopped reconciling when the new leader starts. Must be greater than
73 /// the renew deadline.
74 pub fn with_lease_duration(mut self, lease_duration: Duration) -> Self {
75 self.lease_duration = lease_duration;
76 self
77 }
78
79 /// Sets how long the leader will keep trying to renew the lease before
80 /// giving up leadership. Must be less than the lease duration (so that
81 /// a leader which cannot reach the API server gives up before another
82 /// candidate can take the lease over) and greater than the retry
83 /// period.
84 pub fn with_renew_deadline(mut self, renew_deadline: Duration) -> Self {
85 self.renew_deadline = renew_deadline;
86 self
87 }
88
89 /// Sets how often candidates poll the lease while waiting to acquire
90 /// it, and how often the leader renews it.
91 pub fn with_retry_period(mut self, retry_period: Duration) -> Self {
92 self.retry_period = retry_period;
93 self
94 }
95
96 /// Wait until this instance holds the lease, then run `fut` while
97 /// renewing the lease in the background.
98 ///
99 /// If leadership is lost (because the lease could not be renewed in
100 /// time, or was taken over by another candidate), `fut` is dropped,
101 /// cancelling its work, and this method returns `None`. The caller
102 /// should then promptly either exit the process (letting Kubernetes
103 /// restart it) or rejoin the election by calling this method again.
104 /// Note that dropping `fut` cancels it cooperatively: work it has
105 /// spawned as separate tasks, or blocking code, is not cancelled. If
106 /// `fut` does such things, prefer exiting the process so that no work
107 /// outlives the lease.
108 ///
109 /// If `fut` completes on its own, the lease is voluntarily released
110 /// (handing leadership over immediately rather than making the other
111 /// candidates wait for it to expire) and its output is returned.
112 ///
113 /// To run a [`Controller`](crate::Controller) under a lease, pass its
114 /// [`run`](crate::Controller::run) future; to have one lease guard
115 /// several controllers (rather than electing a separate leader per
116 /// controller), pass a future that runs all of them, for instance:
117 ///
118 /// ```ignore
119 /// leader_election
120 /// .with_lease(futures::future::join(controller_a.run(), controller_b.run()))
121 /// .await;
122 /// ```
123 ///
124 /// During graceful shutdown (for instance, on receiving a termination
125 /// signal), drop the future returned by this method to stop its work,
126 /// then call [`release`](LeaderElection::release) on a clone of this
127 /// `LeaderElection` to hand leadership over immediately rather than
128 /// making the other replicas wait for the lease to expire.
129 ///
130 /// Panics if the configured timings are inconsistent or the identity is
131 /// empty.
132 pub async fn with_lease<F: Future>(&self, fut: F) -> Option<F::Output> {
133 let acquired_at = self.acquire().await;
134 let fut = pin!(fut);
135 let lost = pin!(self.hold(acquired_at));
136 match future::select(fut, lost).await {
137 Either::Left((output, _)) => {
138 self.release().await;
139 Some(output)
140 }
141 Either::Right(((), _)) => None,
142 }
143 }
144
145 fn validate(&self) {
146 assert!(!self.identity.is_empty(), "identity must not be empty");
147 assert!(
148 self.renew_deadline < self.lease_duration,
149 "renew_deadline must be less than lease_duration"
150 );
151 assert!(
152 self.retry_period < self.renew_deadline,
153 "retry_period must be less than renew_deadline"
154 );
155 assert!(
156 i32::try_from(self.lease_duration.as_secs()).is_ok(),
157 "lease_duration must be at most i32::MAX seconds"
158 );
159 }
160
161 fn lease_duration_seconds(&self) -> i32 {
162 i32::try_from(self.lease_duration.as_secs())
163 .expect("lease_duration must be at most i32::MAX seconds")
164 }
165
166 /// Wait until we hold the lease, returning the instant captured just
167 /// before the request that acquired it was sent (the pessimistic time
168 /// from which renewal deadlines must be measured; see [`hold`]). Panics
169 /// if the configured timings are inconsistent or the identity is empty.
170 ///
171 /// [`hold`]: LeaderElection::hold
172 async fn acquire(&self) -> Instant {
173 self.validate();
174 info!(
175 lease_name = %self.lease_name,
176 identity = %self.identity,
177 "attempting to acquire leadership lease",
178 );
179 let mut observed = None;
180 loop {
181 let started = Instant::now();
182 match self.try_acquire(&mut observed).await {
183 Ok(true) => {
184 info!(
185 lease_name = %self.lease_name,
186 identity = %self.identity,
187 "acquired leadership lease",
188 );
189 return started;
190 }
191 Ok(false) => {
192 debug!(
193 lease_name = %self.lease_name,
194 "leadership lease is held by another candidate",
195 );
196 }
197 // a 401 or 403 will never resolve on its own; it almost
198 // always means the service account is missing RBAC
199 // permissions on leases, so log it more loudly
200 Err(kube::Error::Api(e)) if e.code == 401 || e.code == 403 => {
201 error!(
202 error = %e,
203 lease_name = %self.lease_name,
204 "not permitted to access the leadership lease; the \
205 service account needs get, create, and update \
206 permissions on leases in coordination.k8s.io",
207 );
208 }
209 Err(e) => {
210 warn!(
211 error = %e,
212 source = e.source(),
213 lease_name = %self.lease_name,
214 "error while trying to acquire leadership lease",
215 );
216 }
217 }
218 // jitter the retry period to avoid thundering herds. the rng is
219 // bound separately because holding a `ThreadRng` (which is not
220 // `Send`) across the await point would make this future `!Send`
221 let jitter = rng().random_range(1.0..1.5);
222 tokio::time::sleep(self.retry_period.mul_f64(jitter)).await;
223 }
224 }
225
226 /// Try once to acquire the lease, returning whether we now hold it.
227 /// `observed` tracks the last seen state of the lease and when we saw
228 /// it, so that expiry is measured against our own clock.
229 async fn try_acquire(
230 &self,
231 observed: &mut Option<(LeaseSpec, Instant)>,
232 ) -> Result<bool, kube::Error> {
233 let Some(mut lease) = self.api.get_opt(&self.lease_name).await? else {
234 let lease = Lease {
235 metadata: ObjectMeta {
236 name: Some(self.lease_name.clone()),
237 ..Default::default()
238 },
239 spec: Some(next_spec(
240 &self.identity,
241 self.lease_duration_seconds(),
242 None,
243 Timestamp::now(),
244 )),
245 };
246 return match self.api.create(&PostParams::default(), &lease).await {
247 Ok(_) => Ok(true),
248 // another candidate created the lease first
249 Err(kube::Error::Api(e)) if e.code == 409 => Ok(false),
250 Err(e) => Err(e),
251 };
252 };
253
254 let spec = lease.spec.take().unwrap_or_default();
255 // a missing or empty holder means the lease was voluntarily
256 // released (client-go writes an empty string on release) and can be
257 // taken immediately
258 let holder = spec.holder_identity.as_deref().filter(|h| !h.is_empty());
259 let held_by_us = holder == Some(self.identity.as_str());
260 if !held_by_us && holder.is_some() {
261 if observed.as_ref().is_none_or(|(last, _)| *last != spec) {
262 *observed = Some((spec, Instant::now()));
263 return Ok(false);
264 }
265 let (_, observed_at) = observed.as_ref().unwrap();
266 if observed_at.elapsed() < self.lease_duration {
267 return Ok(false);
268 }
269 // the holder has failed to renew the lease for a full
270 // lease_duration, so we can take it over
271 }
272 lease.spec = Some(next_spec(
273 &self.identity,
274 self.lease_duration_seconds(),
275 Some(&spec),
276 Timestamp::now(),
277 ));
278 // replace (rather than patch) so that the write fails with a
279 // conflict if another candidate updated the lease since we read it
280 match self
281 .api
282 .replace(&self.lease_name, &PostParams::default(), &lease)
283 .await
284 {
285 Ok(_) => Ok(true),
286 Err(kube::Error::Api(e)) if e.code == 409 => Ok(false),
287 Err(e) => Err(e),
288 }
289 }
290
291 /// Renew the lease until we lose it, then return. Only call this while
292 /// holding the lease. `last_renew` must be an instant captured *before*
293 /// the request that last renewed (or acquired) the lease was sent.
294 ///
295 /// Renewals are measured from before their request is sent: other
296 /// candidates start their takeover clocks when they observe the written
297 /// lease, which can happen well before we receive the response, so
298 /// measuring from the response could extend our renew deadline past
299 /// their takeover time.
300 async fn hold(&self, mut last_renew: Instant) {
301 let mut interval = tokio::time::interval(self.retry_period);
302 // the first tick completes immediately, and the lease was just
303 // renewed by acquiring it
304 interval.tick().await;
305 loop {
306 // an interval (rather than a sleep) keeps renewals at a
307 // consistent cadence even when an attempt is slow, rather than
308 // eating into the renew deadline budget between attempts
309 interval.tick().await;
310 // bound each attempt by the time remaining until the renew
311 // deadline, so that a hung request (the kube client's default
312 // read timeout is much longer than the deadline) can't keep us
313 // acting as leader after another candidate may have taken over
314 let remaining = self.renew_deadline.saturating_sub(last_renew.elapsed());
315 let started = Instant::now();
316 match tokio::time::timeout(remaining, self.renew()).await {
317 Ok(Ok(true)) => last_renew = started,
318 Ok(Ok(false)) => {
319 warn!(
320 lease_name = %self.lease_name,
321 identity = %self.identity,
322 "leadership lease was taken by another candidate",
323 );
324 return;
325 }
326 Ok(Err(e)) => {
327 warn!(
328 error = %e,
329 source = e.source(),
330 lease_name = %self.lease_name,
331 "failed to renew leadership lease",
332 );
333 if last_renew.elapsed() >= self.renew_deadline {
334 warn!(
335 lease_name = %self.lease_name,
336 identity = %self.identity,
337 "failed to renew leadership lease within the renew deadline; giving up leadership",
338 );
339 return;
340 }
341 }
342 Err(_) => {
343 warn!(
344 lease_name = %self.lease_name,
345 identity = %self.identity,
346 "leadership lease renewal did not complete within the renew deadline; giving up leadership",
347 );
348 return;
349 }
350 }
351 }
352 }
353
354 /// Try once to renew the lease, returning whether we still hold it.
355 async fn renew(&self) -> Result<bool, kube::Error> {
356 let Some(mut lease) = self.api.get_opt(&self.lease_name).await? else {
357 return Ok(false);
358 };
359 let spec = lease.spec.take().unwrap_or_default();
360 if spec.holder_identity.as_deref() != Some(self.identity.as_str()) {
361 return Ok(false);
362 }
363 lease.spec = Some(next_spec(
364 &self.identity,
365 self.lease_duration_seconds(),
366 Some(&spec),
367 Timestamp::now(),
368 ));
369 self.api
370 .replace(&self.lease_name, &PostParams::default(), &lease)
371 .await?;
372 Ok(true)
373 }
374
375 /// Voluntarily release the lease if we hold it, allowing another
376 /// candidate to take it over immediately rather than waiting for it to
377 /// expire. Call this during graceful shutdown, after the controller has
378 /// stopped reconciling (for instance, after the future returned by
379 /// [`with_lease`](LeaderElection::with_lease) has been dropped in
380 /// response to a termination signal). This is best-effort: errors
381 /// are logged and ignored, since the lease will expire on its own
382 /// regardless.
383 pub async fn release(&self) {
384 match self.try_release().await {
385 Ok(true) => {
386 info!(
387 lease_name = %self.lease_name,
388 identity = %self.identity,
389 "released leadership lease",
390 );
391 }
392 Ok(false) => {
393 debug!(
394 lease_name = %self.lease_name,
395 "leadership lease is not held by us; nothing to release",
396 );
397 }
398 Err(e) => {
399 warn!(
400 error = %e,
401 source = e.source(),
402 lease_name = %self.lease_name,
403 "failed to release leadership lease",
404 );
405 }
406 }
407 }
408
409 /// Try once to release the lease, returning whether we released it.
410 async fn try_release(&self) -> Result<bool, kube::Error> {
411 let Some(mut lease) = self.api.get_opt(&self.lease_name).await? else {
412 return Ok(false);
413 };
414 let spec = lease.spec.take().unwrap_or_default();
415 if spec.holder_identity.as_deref() != Some(self.identity.as_str()) {
416 return Ok(false);
417 }
418 lease.spec = Some(released_spec(&spec));
419 match self
420 .api
421 .replace(&self.lease_name, &PostParams::default(), &lease)
422 .await
423 {
424 Ok(_) => Ok(true),
425 // another candidate already took the lease over
426 Err(kube::Error::Api(e)) if e.code == 409 => Ok(false),
427 Err(e) => Err(e),
428 }
429 }
430}
431
432/// Compute the lease spec that makes `identity` the holder, given the
433/// previous spec (if the lease already existed).
434fn next_spec(
435 identity: &str,
436 lease_duration_seconds: i32,
437 prev: Option<&LeaseSpec>,
438 now: Timestamp,
439) -> LeaseSpec {
440 let now = MicroTime(now);
441 let held_by_us = prev.is_some_and(|s| s.holder_identity.as_deref() == Some(identity));
442 LeaseSpec {
443 holder_identity: Some(identity.to_owned()),
444 lease_duration_seconds: Some(lease_duration_seconds),
445 acquire_time: if held_by_us {
446 prev.and_then(|s| s.acquire_time.clone())
447 } else {
448 Some(now.clone())
449 },
450 renew_time: Some(now),
451 lease_transitions: match prev {
452 None => Some(0),
453 Some(s) if held_by_us => s.lease_transitions,
454 Some(s) => Some(s.lease_transitions.unwrap_or(0).saturating_add(1)),
455 },
456 ..Default::default()
457 }
458}
459
460/// Compute the lease spec that marks the lease as no longer held,
461/// preserving the transition count for the next holder to increment.
462fn released_spec(prev: &LeaseSpec) -> LeaseSpec {
463 LeaseSpec {
464 holder_identity: None,
465 lease_transitions: prev.lease_transitions,
466 ..Default::default()
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 fn now() -> Timestamp {
475 "2026-07-22T00:00:00Z".parse().unwrap()
476 }
477
478 #[test]
479 fn fresh_acquire() {
480 let spec = next_spec("us", 15, None, now());
481 assert_eq!(spec.holder_identity.as_deref(), Some("us"));
482 assert_eq!(spec.lease_duration_seconds, Some(15));
483 assert_eq!(spec.acquire_time, Some(MicroTime(now())));
484 assert_eq!(spec.renew_time, Some(MicroTime(now())));
485 assert_eq!(spec.lease_transitions, Some(0));
486 }
487
488 #[test]
489 fn renewal_preserves_acquire_time_and_transitions() {
490 let acquired: Timestamp = "2026-07-21T00:00:00Z".parse().unwrap();
491 let prev = LeaseSpec {
492 holder_identity: Some("us".to_owned()),
493 lease_duration_seconds: Some(15),
494 acquire_time: Some(MicroTime(acquired)),
495 renew_time: Some(MicroTime(acquired)),
496 lease_transitions: Some(3),
497 ..Default::default()
498 };
499 let spec = next_spec("us", 15, Some(&prev), now());
500 assert_eq!(spec.holder_identity.as_deref(), Some("us"));
501 assert_eq!(spec.acquire_time, Some(MicroTime(acquired)));
502 assert_eq!(spec.renew_time, Some(MicroTime(now())));
503 assert_eq!(spec.lease_transitions, Some(3));
504 }
505
506 #[test]
507 fn takeover_increments_transitions() {
508 let prev = LeaseSpec {
509 holder_identity: Some("them".to_owned()),
510 lease_duration_seconds: Some(15),
511 acquire_time: Some(MicroTime(now())),
512 renew_time: Some(MicroTime(now())),
513 lease_transitions: Some(3),
514 ..Default::default()
515 };
516 let spec = next_spec("us", 15, Some(&prev), now());
517 assert_eq!(spec.holder_identity.as_deref(), Some("us"));
518 assert_eq!(spec.acquire_time, Some(MicroTime(now())));
519 assert_eq!(spec.lease_transitions, Some(4));
520 }
521
522 #[test]
523 fn release_clears_holder_and_preserves_transitions() {
524 let prev = LeaseSpec {
525 holder_identity: Some("us".to_owned()),
526 lease_duration_seconds: Some(15),
527 acquire_time: Some(MicroTime(now())),
528 renew_time: Some(MicroTime(now())),
529 lease_transitions: Some(3),
530 ..Default::default()
531 };
532 let spec = released_spec(&prev);
533 assert_eq!(spec.holder_identity, None);
534 assert_eq!(spec.lease_transitions, Some(3));
535 assert_eq!(spec.renew_time, None);
536 }
537}