Skip to main content

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}