Skip to main content
RunBook Academy

← All break/fix scenarios in Kubernetes

advancedkubernetes-etcd-quorum~50 min

etcd quorum loss

Reported symptoms

  • Every kubectl apply and kubectl scale fails after about seven seconds with "etcdserver: request timed out", while kubectl get still answers
  • Customer traffic is completely unaffected - running Pods serve, DNS answers, and the service dashboards are green
  • A rollout that started at 14:02 is stuck at 4 of 12 replicas with no new events, and the HPA has stopped acting on a queue that is visibly growing
  • Every node still reports Ready, but the LastHeartbeatTime on every node condition is frozen at the same minute
  • The change record open in the window says "single hypervisor firmware update, no service impact, all guests are HA-protected"
  • The three control-plane nodes carry three distinct topology.kubernetes.io/zone labels, which is what the HA design review approved two years ago

Evidence

  • · etcdctl endpoint status against all three members: cp-1 answers with IS LEADER false and an "etcdserver: no leader" error, cp-2 and cp-3 time out
  • · etcd_server_has_leader on cp-1 is 0, and has been since 14:07
  • · The etcd container log on cp-1 shows the raft term incrementing every few seconds with no election completing
  • · kubectl describe node on any worker shows conditions unchanged since 14:07
  • · The hypervisor inventory shows cp-2 and cp-3 both running on host esx-14, which is the host in the change record
  • · The migration history shows cp-3 was moved onto esx-14 eleven months ago during a routine host evacuation
  • · The nightly and hourly snapshot jobs are both healthy; the most recent verified snapshot is from 14:00, seven minutes before the loss
  • · Nothing in the Kubernetes API records which hypervisor a control-plane node runs on; the zone labels were set by hand at install time
Diagnosis and resolutionclick to reveal

Root cause

Quorum is not a property of how many members exist. It is a property of the failure domains those members occupy, and this cluster had three members across two hypervisors. When esx-14 rebooted for its firmware update, cp-2 and cp-3 went down together, leaving cp-1 alone against a quorum of two. The cluster stopped committing immediately and correctly: below quorum etcd refuses writes rather than risking divergence, which is why every apply fails and every read still answers, why running workloads are untouched, and why the node heartbeats are frozen rather than failing - nothing can write a heartbeat, so the last one written stands. The collocation was eleven months old and invisible from inside Kubernetes. The zone labels were entered by hand at install time to record the intended placement; they were never derived from anything and nothing reconciled them when the virtualisation platform migrated cp-3 during a host evacuation. So the change was assessed honestly against the information the virtualisation team had - one host, all guests HA-protected, restart elsewhere within minutes - and that assessment was true for every guest on esx-14 except that two of them were two thirds of a consensus cluster. The cluster was a two-member cluster wearing a three-member label for most of a year.

Remediation

Classify the loss before acting, because the two branches have opposite costs. The members here are powered off on a host that is rebooting, not destroyed, so this is a transient loss and the correct action is to wait for the failure domain to heal. Waiting costs minutes of frozen control plane with running workloads unaffected. Forcing or restoring costs every write since the last snapshot and, done wrong, costs the cluster. Make the wait an explicit hold rather than an absence of action: the incident commander owns it, and the deadline comes from the vendor stated reboot time for the firmware plus a margin - here 14:45, roughly twenty-five minutes. Say out loud what happens at the deadline: restore the 14:00 snapshot onto a rebuilt three-member cluster with etcdutl, accepting the loss of everything written after 14:00. While holding, do the work that is free and reversible: confirm from the hypervisor console that cp-2 and cp-3 are powering on, keep the 14:00 snapshot copied somewhere that is not on esx-14, and stop any automation that will retry writes in a tight loop. Do not add --force-new-cluster to the manifest on cp-1. Do not restart cp-1 hoping an election will complete; one member cannot elect itself a leader in a three-member cluster and the restart only costs the warm state.

Verification

A leader exists and the members agree. etcdctl endpoint status against all three shows exactly one member with IS LEADER true, and the raft term and applied index match across the three rather than one trailing; etcd_server_has_leader is 1 on all three. Confirm the cluster identity is unchanged, which is the check that catches a recovery gone wrong: all three members report the same cluster ID as before the incident, and none reports a new one. Then prove a write, because everything above can be true while the API server is still unhappy - create a scratch ConfigMap, read it back, and delete it. Confirm the frozen symptoms are moving again: node LastHeartbeatTime advances on every node, and the rollout that was stuck at 4 of 12 completes on its own without being nudged. Take a fresh snapshot and run etcdutl snapshot status on it, both because the cluster has changed and because a snapshot that succeeds is evidence the write path is genuinely healthy. Finally, confirm the fault cannot recur while you are still in the incident: cp-2 and cp-3 must not both be on one host again after the hypervisor finishes rebalancing.

Prevention

Make the failure domain a control instead of a label. Anti-affinity has to be enforced at the layer that can move the guests - a hypervisor anti-affinity rule, a placement group, or separate physical hosts - because a topology.kubernetes.io/zone label is a string that Kubernetes will never check and the virtualisation platform will never read. Where the label is all you have, audit it: reconcile the control-plane placement against the hypervisor inventory on a schedule and alert on drift, so eleven months cannot pass unnoticed. Alert on the quorum margin rather than on member health alone, because a cluster with one member down is one event away from this page and looks completely healthy from every application dashboard; etcd_server_has_leader going to zero is the alert that would have paged before the first ticket was filed. Get the control plane into the virtualisation team change model, so that "single host, guests are HA-protected" is evaluated against what those guests are rather than how many of them there are. Write the transient-versus-permanent decision into the runbook in advance, with its deadline and its stated data-loss cost, because it is not a decision anyone should be making for the first time at 14:10. Then run a restore drill quarterly on a separate host, which is the only way the deadline in that runbook can be an honest number.

Reported symptoms

At 14:07 the platform on-call is paged by a deploy pipeline, not by monitoring.

Every kubectl apply hangs for about seven seconds and returns etcdserver: request timed out. kubectl scale does the same. kubectl get answers immediately and looks normal.

Nothing customer-facing is wrong: running Pods serve traffic, DNS answers, and every service dashboard is green. The only application symptom anyone can find is a rollout started at 14:02 stuck at 4 of 12 replicas with no new events, and an HPA that has stopped reacting to a visibly growing queue.

The odd detail arrives when someone checks the nodes. Every node reports Ready, and every node condition carries a LastHeartbeatTime of 14:07 - still 14:07 four minutes later, on every node in the cluster.

There is a change record open. The virtualisation team is applying a firmware update to a single hypervisor, esx-14. The record says: single host, no service impact, all guests are HA-protected and will restart elsewhere.

The HA design review for this cluster, approved two years ago, specified three control-plane nodes in three separate failure domains. The three nodes carry three distinct topology.kubernetes.io/zone labels, and they always have.

Evidence provided

Read-only / Safeone member answering, and it says there is no leader
$ kubectl -n kube-system exec etcd-cp-1 -- etcdctl --cacert=... --cert=... --key=... --endpoints=https://10.30.0.11:2379,https://10.30.0.12:2379,https://10.30.0.13:2379 endpoint status -w table
+--------------------------+------------------+-----------+------------------------+
|         ENDPOINT         |        ID        | IS LEADER |         ERRORS         |
+--------------------------+------------------+-----------+------------------------+
| https://10.30.0.11:2379  | 3a1f0b7c9d2e4a55 |   false   | etcdserver: no leader  |
| https://10.30.0.12:2379  |                  |           | context deadline exc.  |
| https://10.30.0.13:2379  |                  |           | context deadline exc.  |
+--------------------------+------------------+-----------+------------------------+

Illustrative output

Read-only / Safethe survivor is telling you the arithmetic directly
$ ssh cp-1 'curl -s http://127.0.0.1:2381/metrics | grep -E "^etcd_server_has_leader|^etcd_server_leader_changes_seen_total"'
etcd_server_has_leader 0
etcd_server_leader_changes_seen_total 4418

Illustrative output

Read-only / Safecampaigning every few seconds and never winning
$ ssh cp-1 'crictl logs --tail 20 $(crictl ps -a --name etcd -q | head -1)'
3a1f0b7c9d2e4a55 is starting a new election at term 4418
3a1f0b7c9d2e4a55 became candidate at term 4419
3a1f0b7c9d2e4a55 received MsgVoteResp from 3a1f0b7c9d2e4a55 at term 4419
3a1f0b7c9d2e4a55 [logterm: 12, index: 90418122] sent MsgVote request to 7c4e2d8a1b6f0933
3a1f0b7c9d2e4a55 [logterm: 12, index: 90418122] sent MsgVote request to c9b5e3f7a0d81244

Illustrative output

Read-only / SafeReady, and frozen - it is 14:11
$ kubectl describe node worker-07 | grep -A3 'Conditions:'
Conditions:
Type    Status  LastHeartbeatTime                 Reason
----    ------  -----------------                 ------
Ready   True    Tue, 14:07:12 +0000               KubeletReady

Illustrative output

Read-only / Safewhat Kubernetes believes about the topology
$ kubectl get nodes -l node-role.kubernetes.io/control-plane -o custom-columns='NODE:.metadata.name,ZONE:.metadata.labels.topology\.kubernetes\.io/zone'
NODE   ZONE
cp-1   dc-a
cp-2   dc-b
cp-3   dc-c

Illustrative output

Read-only / Safewhat the hypervisor knows, and nobody had asked it
$ govc vm.info -json cp-1 cp-2 cp-3 | jq -r '.virtualMachines[] | [.name, .runtime.host.value] | @tsv'
cp-1   host-2201
cp-2   host-2214
cp-3   host-2214

Illustrative output

Read-only / Safea verified snapshot from seven minutes before the loss
$ ls -l /backup/etcd/ | tail -2 ; etcdutl snapshot status /backup/etcd/snapshot-1400.db -w table
-rw------- 1 root root 402653184 Aug 18 13:00 snapshot-1300.db
-rw------- 1 root root 403701760 Aug 18 14:00 snapshot-1400.db
+----------+----------+------------+------------+
|   HASH   | REVISION | TOTAL KEYS | TOTAL SIZE |
+----------+----------+------------+------------+
| 8f2a19c4 | 90417806 |     241883 |     385 MB |
+----------+----------+------------+------------+

Illustrative output

Work the evidence before reading on

The cluster is refusing writes and every one of its own reports is accurate.

  1. Three members exist and one is answering. What is the quorum of a three-member cluster, and what does a lone member do when it campaigns?
  2. kubectl get works and kubectl apply does not, and the node heartbeats are frozen rather than failing. What single property of the failure explains all three at once?
  3. The zone labels say three domains and the hypervisor says two hosts. Which of those two statements was ever capable of being wrong, and which one was ever capable of being enforced?
  4. The change record says one host and no impact. Given what the virtualisation team could see, was it wrong?

Before continuing: the members are powered off on a host that is rebooting, not destroyed. What does that fact change about the correct next action, and what is the cost of getting that classification wrong in each direction?

Root cause

1. Quorum counts failure domains, not members

A three-member cluster has a quorum of two and tolerates one loss. That arithmetic is worth something only if no single event can remove two members.

cp-2 and cp-3 were both guests on esx-14. Rebooting that host is one event and it removed two members, leaving cp-1 alone against a quorum of two. What followed is etcd working as designed: below quorum it refuses to commit rather than risk two halves diverging, and it refuses for as long as the condition lasts. There is no partial mode and no degraded write path.

In practice this was a two-domain cluster - tolerating zero losses of the domain holding two members - for eleven months.

2. Why the symptoms look like four different problems

Every symptom falls out of “writes fail, reads mostly answer”.

kubectl get works because a read does not need a commit; kubectl apply fails because it does. Running workloads are untouched because their Pods are already scheduled, their kube-proxy rules already programmed and their DNS records already in place - the data plane does not consult etcd to keep serving. The rollout is stuck at 4 of 12 because the Deployment controller cannot record its next decision, and the HPA has stopped for the same reason.

The frozen heartbeats are the most instructive of the four. A node condition is not failing; it is unchanged, because the kubelet’s update cannot be written. The cluster is reporting the last thing it managed to write down, which is the shape every observability signal takes when the store behind it stops accepting writes. A dashboard that has gone still and a dashboard that is green look identical.

3. The label was a claim and never a control

The topology.kubernetes.io/zone labels were typed in at install time to record the intended placement. Nothing derived them from anything, Kubernetes never checks them against reality, and the virtualisation platform has never read them.

So when a routine host evacuation moved cp-3 onto esx-14 eleven months ago, the platform did what it was asked, the labels went on saying dc-b and dc-c, and every design review, runbook and dashboard downstream of them kept describing a cluster that had stopped existing.

The change record was assessed honestly. One host, guests HA-protected, restart elsewhere within minutes is true of every guest on esx-14, including these two. It is simply insufficient when two of the guests are two thirds of a consensus cluster, and nothing in the change process could have noticed.

Resolution

  1. Classify the loss before doing anything. Members powered off on a host that is rebooting are a transient loss; members whose disks are gone are a permanent one. The hypervisor console shows esx-14 posting through firmware, so this is transient and the correct action is to wait.
  2. Turn the wait into an explicit hold with an owner and a deadline. The incident commander owns it; the deadline is the vendor stated firmware reboot time plus a margin, 14:45 here. Announce what happens at the deadline rather than deciding it there: restore the 14:00 snapshot onto a rebuilt three-member cluster with etcdutl snapshot restore, losing everything written after 14:00.
  3. Copy the 14:00 snapshot somewhere that is neither esx-14 nor a control-plane host, and verify it again at the destination with etcdutl snapshot status. This is free, reversible, and it is the entire fallback plan.
  4. Stop automation that retries writes in a tight loop. A GitOps controller hammering a cluster below quorum adds load to the one member that has to win an election the moment its peers return.
  5. Do not add --force-new-cluster, and do not restart cp-1. One member cannot elect itself leader in a three-member cluster, so a restart costs the warm state and buys nothing.
  6. Watch for the members returning rather than for the API server recovering. etcdctl endpoint status against all three is the signal; the API server following is a consequence.
  7. When quorum returns, let the backlog drain before touching the stuck rollout. The Deployment controller resumes on its own, and a manual nudge during catch-up is how a recovered cluster acquires a second incident.
  8. Before closing, confirm the hypervisor has not put cp-2 and cp-3 back onto one host, and raise the placement fix as a change rather than leaving it as a finding.

Verification

  1. A leader exists and the members agree: etcdctl endpoint status -w table against all three shows exactly one IS LEADER true, with raft term and applied index matching rather than one trailing.
  2. etcd_server_has_leader is 1 on all three members, read from each member rather than from one.
  3. The cluster identity is unchanged. All three members report the cluster ID they had before 14:07. A new cluster ID means somebody forced, and that is a different incident with a different runbook.
  4. A write round-trips end to end: create a scratch ConfigMap, read it back, delete it. Everything above can be true while the API server is still unhappy.
  5. The frozen symptoms are moving: node LastHeartbeatTime advances on every node, and the rollout stuck at 4 of 12 completes on its own.
  6. A fresh snapshot succeeds and etcdutl snapshot status reports a hash and revision. A successful snapshot is also evidence that the write path is genuinely healthy.
  7. The placement is fixed and enforced, checked from the hypervisor inventory rather than from the Kubernetes labels - which is the check that was never run in eleven months.

Prevention

  • Enforce the failure domain at the layer that can move the guests. A hypervisor anti-affinity rule or a placement group is a control. A topology.kubernetes.io/zone label is a string that Kubernetes will never verify and the virtualisation platform will never read.
  • Where the label is all you have, audit it. Reconcile control-plane placement against the hypervisor inventory on a schedule and alert on drift, so that eleven months cannot pass unnoticed.
  • Alert on quorum margin, not only on member health. A three-member cluster with one member down is one event away from this page and looks perfect from every application dashboard:
min(etcd_server_has_leader) == 0
count(up{job="etcd"} == 1) <= floor(count(up{job="etcd"}) / 2)
  • Get the Kubernetes control plane into the virtualisation team’s change-impact model. “Single host, guests are HA-protected” is a true statement that evaluates the number of guests rather than what they are.
  • Write the transient-versus-permanent decision into the runbook in advance, with the deadline and the stated data-loss cost of the fallback. It is not a decision anyone should make for the first time at 14:10 with a pipeline failing.
  • Run a restore drill quarterly on a separate host. The deadline in that runbook is an honest number only if somebody has measured how long a restore actually takes here.