Reported symptoms
At 02:14 on 2026-08-27 a rack lost power and took the local storage of all three stacked control-plane nodes with it. The nightly etcd snapshot is in object storage, downloads cleanly, and is byte-identical on the three rebuilt nodes.
The runbookβs restore command prints help text on every node. The team works around that, and by 03:40 all three restores have completed and all three etcd processes are running.
Nothing else works. There is no leader, kubectl times out, and the API server
restarts in a loop. Queried on loopback each member answers, holds the whole
keyspace, and describes a cluster that does not contain the other two.
Evidence provided
Start with what is installed, because the first failure was a version fact.
$ etcd --version; etcdctl versionetcd Version: 3.7.1
Git SHA: 5e7fd0d
etcdctl version: 3.7.1
API version: 3.7The runbookβs first restore line produces this, on each node in turn.
$ etcdctl snapshot status /work/backup.dbManages etcd node snapshots
Usage:
etcdctl snapshot [command]That is help text. On 3.7.1 both status and restore have moved out of
etcdctl and into the etcdutl binary in the same tarball.
$ etcdutl snapshot status /work/backup.db --write-out=tableββββββββββββ¬βββββββββββ¬βββββββββββββ¬βββββββββββββ¬ββββββββββ
β HASH β REVISION β TOTAL KEYS β TOTAL SIZE β VERSION β
ββββββββββββΌβββββββββββΌβββββββββββββΌβββββββββββββΌββββββββββ€
β 8c66d749 β 54 β 53 β 45 kB β 3.7.0 β
ββββββββββββ΄βββββββββββ΄βββββββββββββ΄βββββββββββββ΄ββββββββββEvery restore log holds one line about membership, and it is the line that matters.
$ etcdutl snapshot restore /work/backup.db --data-dir /work/d22026-08-28T13:52:51Z info schema/membership.go:125 Trimming membership information from the backend...
2026-08-28T13:52:51Z info membership/cluster.go:408 added member {"cluster-id": "1c45a069f3a1d796", "local-member-id": "0", "added-peer-id": "b71f75320dc06a6c", "added-peer-peer-urls": ["http://127.0.0.1:2380"], "added-peer-is-learner": false}And the reproduction on a scratch host: the same file, restored twice with the same membership flags and once with different ones.
$ etcdutl snapshot restore /work/backup.db --data-dir /work/d4 --initial-cluster rbdr-b=http://127.0.0.1:2580--- cluster id, original vs restored-with-IDENTICAL-initial-cluster ---
original : 1c45a069f3a1d796
restored : 1c45a069f3a1d796
SAME
--- now restore the SAME snapshot with a DIFFERENT --initial-cluster ---
restored with a different initial-cluster : 80d54574493dd420
DIFFERENT from the originalFinally, what one member alone contains.
$ etcdctl get / --prefix --keys-only | grep -c .keys restored : 53
the secret : S3cretValueWork the evidence before reading on
- The status table matches the hash, revision and key count recorded at 01:00. Which hypothesis does that retire, and which does it leave?
rbdr-cp-3alone is healthy and serves all 53 keys. What does that prove about the file, and what about the procedure?- Three restore logs, three different values in one field, one set of bytes. Where did those values come from?
- Which flags must be identical across three members, and which must differ?
- Write the sentence the runbook needed, and say why βrestore the snapshot on each control-plane nodeβ reads as though it already says it.
Root cause
The alternative worth eliminating first
The natural first suspicion is the backup: a truncated download, a partial upload, a file that is not what the job thought it saved. Two readings retire it.
etcdutl snapshot status reports HASH 8c66d749, REVISION 54, TOTAL KEYS 53
and TOTAL SIZE 45 kB β the four values recorded beside the object when the job
ran. The second is decisive because it is a restore rather than an inspection:
rbdr-cp-3 came up on its own, healthy, with all 53 keys and the secret readable
in the clear. A file that restores into a serving cluster once will do it three
times.
The cluster id is derived at restore time
The measurement is on one file. Restored twice with an identical
--initial-cluster it produced 1c45a069f3a1d796 both times; restored once more
with a different --initial-cluster the same bytes produced 80d54574493dd420.
Identity is computed from the membership configuration handed to the restore, not
carried inside the snapshot, so it is not something a backup can get right for you.
Three engineers worked the three nodes in parallel, and each sourced the membership
list separately. One copied it from the runbook, which still names rbdr-cp-3 at
the peer address it lost when it was rebuilt in March. One read it from the current
Ansible group variables. The third, on a console with no checkout, typed the member
in front of him and nothing else, reasoning that this member was the cluster being
created.
Three strings, three cluster ids, and members with different cluster ids do not accept each other as peers. Nothing in a restore compares its membership list with anyone elseβs, because at restore time there is no one else to ask.
The runbook described actions and no invariants
rbdr-runbook-control-plane-loss says to restore the snapshot on each
control-plane node and shows one example command. Every word is true. It does not
say the three restores must consume the same file, that --initial-cluster must
be byte-identical, or which flags are the per-member ones. A reader who follows it
exactly can still produce this.
It also names a command this version does not have, so it fails at its first line and hands the operator an improvisation task at the worst moment. Its last-reviewed date is 2024-11-05.
Resolution
Stop restoring. Three divergent members are three one-member clusters, and more restart ordering only adds state to unpick. Fix the file first, then the string.
SNAP=/srv/rbdr-restore/rbdr-etcd-2026-08-27.db
etcdutl snapshot status "$SNAP" --write-out=table
sha256sum "$SNAP"
That output is compared with what the backup job recorded, and the same checksum is confirmed on each node after the file is copied there.
Then write the membership string down once, derived from the current peer addresses
and checked against both the inventory and the interfaces the nodes have. It is
identical on all three commands; only --name and the peer URL differ.
SNAP=/srv/rbdr-restore/rbdr-etcd-2026-08-27.db
CLUSTER=$(cat /srv/rbdr-restore/rbdr-initial-cluster.txt)
NAME=$(hostname -s)
etcdutl snapshot restore "$SNAP" \
--name "$NAME" \
--data-dir /var/lib/rbdr-etcd-restored \
--initial-advertise-peer-urls "https://$NAME.rbdr.internal:2380" \
--initial-cluster "$CLUSTER"
Discard the three earlier data directories, restore into fresh ones, and read the cluster id out of the three logs before starting any etcd process.
grep -ho 'cluster-id": "[0-9a-f]*' /srv/rbdr-restore/rbdr-restore-cp-*.log \
| sort -u \
| tee /srv/rbdr-restore/rbdr-cluster-id-check.txt \
| wc -l
One line means the three restores agree. Anything else means the restore is already wrong, and starting etcd will only bury it under a peer error.
Verification
Verify before etcd starts: the check above is one grep per node, and it is the gate that would have ended this at 03:00.
Verify the file against what was recorded at backup time, not against itself β
hash 8c66d749, revision 54, key count 53. A status output that differs from
the recorded one is a different file.
Verify the cluster by membership and leadership together β one member list of
three ids, the same list from all three endpoints, one endpoint reporting
IS LEADER true, an empty ERRORS column. Endpoint status from a single node
cannot tell a healthy member of three from a healthy member of one, which is the
confusion this incident lived in for six hours.
Verify the service last, then enumerate the gap between the snapshot and 02:14 and report it: a control plane restored to yesterday at 01:00 is a working cluster with the wrong contents.
Prevention
Make the membership string an artefact. Generate --initial-cluster from the
inventory into one file, distribute it, and have every restore read it. Three
people typing the same idea from three sources is the entire incident.
One snapshot file, checksummed on arrival at every node. One command, and a whole class of divergence that no error message reports disappears.
Assert on the cluster id before starting anything. The restore prints it, and comparing three strings catches this while it is still free to fix.
Exercise the runbook on the version that is installed. This one named a command that had moved to another binary, and nobody found out until the control plane was down. A drill on disposable nodes turns that into a failed drill.
State invariants, not only steps. βRestore the snapshot on each control-plane nodeβ describes three actions and no constraint. βThe same file, the same membership string, only the name and peer URL differingβ is the sentence that had to be there.