Runbook: Restore etcd
1 · Prerequisites
Confirm every item is in place before any state change.
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · Confirm this procedure has been rehearsed end to end on a disposable cluster. It is written from the upstream etcd and kubeadm documentation, not from a production execution
- · Confirm the cluster etcd version:
grep -- image: /etc/kubernetes/manifests/etcd.yaml. kubeadm 1.34 ships etcd 3.6.5-0 - · Confirm
etcdutlis installed on every control-plane node at the cluster etcd minor:etcdutl version. The registry.k8s.io/etcd image kubeadm runs shipsetcdandetcdctlonly, soetcdutlmust come from the etcd release tarball - · Record the member name, peer URL and data directory of every control-plane node from
/etc/kubernetes/manifests/etcd.yaml - · Confirm the snapshot exists off-host:
restic -r sftp:backup@backup.internal:/srv/etcd snapshots - · Confirm the snapshot integrity on every node that will restore it:
etcdutl snapshot status <snap> -w table - · Confirm the snapshot revision and timestamp match the intended recovery point, and that the resulting data-loss window is accepted in writing
- · Confirm every control-plane node etcd TLS files exist:
ls /etc/kubernetes/pki/etcd/{ca.crt,server.crt,server.key} - · Confirm the restore decision has been communicated: cluster users have been told workloads will be unavailable
- · Confirm a current change ticket is open and the restore point is documented
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Record the member name, peer URL and data directory for every control-plane node from
/etc/kubernetes/manifests/etcd.yaml, and build the single--initial-clusterstring all three restores will share - 2Stage the snapshot on every control-plane node and verify it with
etcdutl snapshot status - 3Stop the whole control plane on every node by moving all four static-Pod manifests out of
/etc/kubernetes/manifests/and confirming the containers are gone - 4Set the existing etcd data directory aside on every node:
mv /var/lib/etcd /var/lib/etcd.broken-<timestamp> - 5Restore on each node with
etcdutl snapshot restoreinto the new data directory/var/lib/etcd-restore, using the local--nameand--initial-advertise-peer-urlsfor that node and the shared--initial-clusterand--initial-cluster-token - 6Repoint the
etcd-datahostPath in the stagedetcd.yamlfrom/var/lib/etcdto/var/lib/etcd-restoreon every node - 7Return only
etcd.yamlto/etc/kubernetes/manifests/on every node so the three members form the restored cluster before anything writes to it - 8Verify the restored cluster:
etcdctl member list,etcdctl endpoint health --cluster,etcdctl endpoint status --cluster,etcdctl alarm list - 9Return the remaining three manifests (kube-apiserver, kube-controller-manager, kube-scheduler) on every node
- 10Verify the API server:
/livez,/readyz, andkubectl get nodes - 11Verify add-ons and workloads: CoreDNS, CNI, kube-proxy, then spot-check application workloads
- 12Record the recovery point, the data-loss window and the disposition of the
/var/lib/etcd.broken-<timestamp>directories in the change ticket
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓
etcdctl member list -w tablelists exactly three members with the expected names and peer URLs, and no unexpected fourth member - ✓
etcdctl endpoint health --cluster -w tablereports every endpoint healthy - ✓
etcdctl endpoint status --cluster -w tableshows exactly one leader and the same revision on all three members - ✓
etcdctl alarm listreturns no alarms - ✓
kubectl get --raw /readyz?verbosereports every check ok - ✓
kubectl get nodesreturns every node Ready - ✓
kubectl get pods -Areturns the expected set of Pods (workloads and system) - ✓A test workload can be created and deleted:
kubectl run restore-check --image=registry.k8s.io/pause:3.10.1 --restart=Never; kubectl delete pod restore-check - ✓
kubectl auth can-i get podsreturnsyes(RBAC survived the restore) - ✓
kubectl get clusterrolebindings,rolebindings -A --no-headers | wc -lmatches the count recorded before the restore - ✓Workload smoke tests pass: a known public endpoint returns 200
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶The restore is reversible until the old data directories are deleted. To back out: move all four manifests out of
/etc/kubernetes/manifests/again on every node, repoint theetcd-datahostPath back to/var/lib/etcd,mv /var/lib/etcd.broken-<timestamp> /var/lib/etcd, and return the manifests etcd first - ↶Backing out only restores the pre-restore data directories. If quorum was already lost, backing out returns the cluster to the quorum-lost state, not to a working one
- ↶If a single member fails to join the restored cluster, do not re-run the restore on the healthy members. Re-run it on the failed member only, checking that its
--nameand--initial-advertise-peer-urlsmatch its own manifest - ↶If the restore succeeded but the API server cannot authenticate, the snapshot pre-dates a credential rotation; back out and select the next-most-recent snapshot
- ↶If the restored cluster is missing workloads, the snapshot pre-dates them; back out and select the next snapshot, or accept the loss and re-apply from Git
- ↶Capture every step of a failed restore in the change ticket before attempting anything else. A second restore attempted without a diagnosis usually makes the situation worse
6 · Escalation
When the runbook isn't enough, contact:
- · Quorum loss and no valid snapshot: see
kubernetes-rb-recover-failed-control-planefor a cluster rebuild path - · A member joins with a different cluster ID: one node restored with a different
--initial-cluster-tokenor a different--initial-cluster; re-restore that node with the recorded values - · etcd fails to start after restore: check the container logs with
crictl logs, then check the TLS paths and theetcd-datahostPath in/etc/kubernetes/manifests/etcd.yaml - · API server up but
/readyzfails: readkubectl get --raw /readyz?verboseand treat the first failing check; restart the kube-apiserver static Pod only after the failing check is understood - · Restored cluster is healthy but Nodes show NotReady: certificates rotated after the snapshot was taken; see
kubernetes-rb-renew-cluster-certs
An etcd restore returns the cluster to the state at the snapshot timestamp. Every write since then is gone. That is the point of the procedure, and it is why a restore is a decision rather than a recovery action: the data-loss window is chosen when the snapshot is chosen.
Which tool restores the snapshot
etcdctl is the network client; etcdutl is the offline utility that
operates directly on data files. etcd v3.6.0 removed
etcdctl snapshot restore and etcdctl snapshot status in favour of
the etcdutl equivalents, and the etcd documentation has shown
etcdutl snapshot restore since v3.5. kubeadm 1.34 ships etcd
3.6.5-0, so on a 1.34 cluster etcdutl is the only option.
| Cluster | etcd shipped by kubeadm | Restore command |
|---|---|---|
| Kubernetes 1.34 | 3.6.x | etcdutl snapshot restore — etcdctl snapshot restore is removed |
| Kubernetes 1.31 to 1.33 | 3.5.x | etcdutl snapshot restore — etcdctl snapshot restore still exists but is deprecated |
Taking the snapshot is unchanged: etcdctl snapshot save is a network
call against a live member and stays on etcdctl.
# Match the version to the image kubeadm runs on this cluster.
sudo grep -- 'image:' /etc/kubernetes/manifests/etcd.yaml
ETCD_VER=v3.6.5
curl -fsSL "https://github.com/etcd-io/etcd/releases/download/$ETCD_VER/etcd-$ETCD_VER-linux-amd64.tar.gz" -o /tmp/etcd.tar.gz
sudo tar xzf /tmp/etcd.tar.gz -C /usr/local/bin --strip-components=1 --no-same-owner etcd-$ETCD_VER-linux-amd64/etcdutl
etcdutl version
1. Record the cluster facts
Every flag the restore needs is already in the etcd manifest on each node. Read them before anything is stopped, and write them into the change ticket: after step 3 the API server is gone and this is the only copy.
# Run on each control-plane node in turn.
sudo grep -E -- '--(name|initial-advertise-peer-urls|initial-cluster|data-dir)=' \
/etc/kubernetes/manifests/etcd.yaml
# The hostPath backing the data directory.
sudo grep -B 3 -- 'name: etcd-data' /etc/kubernetes/manifests/etcd.yaml
# While the API server is still up, record what the restore must reproduce.
kubectl get nodes -o wide
kubectl get clusterrolebindings,rolebindings -A --no-headers | wc -l
kubectl get pods -A --no-headers | wc -l
kubeadm names each etcd member after its node and derives the peer URL from that node’s advertise address, so a three-node cluster reads back as three distinct names and three distinct URLs. This runbook uses these values throughout; substitute your own.
| Node | Member name | Peer URL |
|---|---|---|
| cp-1 | cp-1 | https://10.0.1.10:2380 |
| cp-2 | cp-2 | https://10.0.1.11:2380 |
| cp-3 | cp-3 | https://10.0.1.12:2380 |
All three restores share one --initial-cluster string built from
that table:
cp-1=https://10.0.1.10:2380,cp-2=https://10.0.1.11:2380,cp-3=https://10.0.1.12:2380
2. Stage and verify the snapshot on every node
The restore is offline and local: each node reads the snapshot from its own filesystem. Copy the same file to all three nodes and verify it on all three before stopping anything.
# Pull and decrypt on one node, then copy the plaintext .db to the others.
restic -r sftp:backup@backup.internal:/srv/etcd restore "$SNAP_ID" --target /tmp/etcd-snapshot
sha256sum /tmp/etcd-snapshot/snap.age | tee /tmp/etcd-snapshot/snap.age.sha256
age -d -i /etc/backup/age-key < /tmp/etcd-snapshot/snap.age > /tmp/etcd-snapshot/snap.db
# Verify on every node that will run the restore.
etcdutl snapshot status /tmp/etcd-snapshot/snap.db -w table
sha256sum /tmp/etcd-snapshot/snap.db
Compare the sha256sum across all three nodes: the restore is only
consistent if every member starts from byte-identical input. Compare
the reported REVISION against the recovery point in the change ticket.
Do not proceed if the hashes differ between nodes, if
etcdutl snapshot status reports an error, or if the revision is not
the one that was agreed.
A snapshot copied straight out of a data directory rather than taken
with etcdctl snapshot save carries no integrity hash and will only
restore with --skip-hash-check. If you need that flag, record why in
the ticket: it means the snapshot was never verified.
3. Stop the control plane on every node
kubeadm runs the control plane as static Pods, so there are no systemd
units to stop and crictl stop alone is useless: the kubelet restarts
anything whose manifest is still in the watched directory. Moving the
manifests out is what actually stops them.
# Run on every control-plane node.
sudo mkdir -p /etc/kubernetes/manifests.stopped
sudo mv /etc/kubernetes/manifests/kube-apiserver.yaml \
/etc/kubernetes/manifests/kube-controller-manager.yaml \
/etc/kubernetes/manifests/kube-scheduler.yaml \
/etc/kubernetes/manifests/etcd.yaml \
/etc/kubernetes/manifests.stopped/
# The kubelet removes the Pods within about 30 seconds.
sleep 45
sudo crictl ps --state running | grep -E 'kube-apiserver|kube-scheduler|kube-controller-manager|etcd' \
&& echo 'STILL RUNNING - do not continue' \
|| echo 'all control-plane containers stopped on this node'
Repeat on every control-plane node and confirm the “all stopped” line on each before continuing. This is the step that takes the cluster down.
4. Set the existing etcd data directories aside
etcdutl snapshot restore refuses to write into a data directory that
exists and is not empty, which is a useful guard. Moving the live
directory aside preserves it for the post-mortem and for the rollback
path described at the end of this runbook.
TS=$(date -u +%Y%m%dT%H%M%SZ)
# Run on every control-plane node. Do not delete these directories today.
sudo mv /var/lib/etcd "/var/lib/etcd.broken-$TS"
sudo ls -ld /var/lib/etcd.broken-*
sudo du -sh /var/lib/etcd.broken-*
5. Restore on each node, with that node’s own identity
Each node runs its own command. The snapshot file, the
--initial-cluster string and the --initial-cluster-token are the
same everywhere; --name and --initial-advertise-peer-urls are that
node’s own. The restore writes a fresh data directory at
/var/lib/etcd-restore rather than over /var/lib/etcd, matching
kubernetes-lxix-04-snapshot-restore-args.
Node cp-1 (10.0.1.10)
sudo etcdutl snapshot restore /tmp/etcd-snapshot/snap.db \
--name cp-1 \
--initial-advertise-peer-urls https://10.0.1.10:2380 \
--initial-cluster cp-1=https://10.0.1.10:2380,cp-2=https://10.0.1.11:2380,cp-3=https://10.0.1.12:2380 \
--initial-cluster-token k8s-restore-20260818 \
--bump-revision 1000000000 \
--mark-compacted \
--data-dir /var/lib/etcd-restore
Node cp-2 (10.0.1.11)
sudo etcdutl snapshot restore /tmp/etcd-snapshot/snap.db \
--name cp-2 \
--initial-advertise-peer-urls https://10.0.1.11:2380 \
--initial-cluster cp-1=https://10.0.1.10:2380,cp-2=https://10.0.1.11:2380,cp-3=https://10.0.1.12:2380 \
--initial-cluster-token k8s-restore-20260818 \
--bump-revision 1000000000 \
--mark-compacted \
--data-dir /var/lib/etcd-restore
Node cp-3 (10.0.1.12)
sudo etcdutl snapshot restore /tmp/etcd-snapshot/snap.db \
--name cp-3 \
--initial-advertise-peer-urls https://10.0.1.12:2380 \
--initial-cluster cp-1=https://10.0.1.10:2380,cp-2=https://10.0.1.11:2380,cp-3=https://10.0.1.12:2380 \
--initial-cluster-token k8s-restore-20260818 \
--bump-revision 1000000000 \
--mark-compacted \
--data-dir /var/lib/etcd-restore
What each flag is doing:
| Flag | Same on all nodes? | Why it is here |
|---|---|---|
--name | No | This member’s name, and it must match --name in this node’s own etcd.yaml |
--initial-advertise-peer-urls | No | The peer URL this member advertises, and it must match this node’s own manifest |
--initial-cluster | Yes | The full three-member topology, written into the restored data directory |
--initial-cluster-token | Yes | A token unique to this restore, so a surviving member of the old cluster cannot join the new one |
--bump-revision and --mark-compacted | Yes | Keeps the revision from going backwards and terminates stale watches |
--data-dir | Yes | The new directory; the restore fails if it exists and is not empty |
Confirm on each node that the restore produced a data directory and
that its ownership matches the directory kubeadm created. kubeadm sets no
runAsUser on the etcd static Pod, so the container runs as root and
a root-owned directory is what it expects; running etcdutl under
sudo gives that.
sudo ls -l /var/lib/etcd-restore/member/
sudo stat -c '%U:%G %a %n' /var/lib/etcd-restore /var/lib/etcd.broken-*
6. Repoint the etcd manifest at the restored data directory
kubeadm mounts the host data directory into the container at the same
path and passes that path as --data-dir, so changing the etcd-data
hostPath alone is enough: the container still sees /var/lib/etcd,
now backed by the restored directory. Leave --data-dir and the
volumeMount alone.
# The manifest is still staged out of the watched directory from step 3.
sudo cp /etc/kubernetes/manifests.stopped/etcd.yaml /root/etcd.yaml.pre-restore
sudo sed -i 's|path: /var/lib/etcd$|path: /var/lib/etcd-restore|' \
/etc/kubernetes/manifests.stopped/etcd.yaml
# Exactly one line should differ, and it must be the etcd-data volume.
sudo diff /root/etcd.yaml.pre-restore /etc/kubernetes/manifests.stopped/etcd.yaml
sudo grep -B 3 -- 'name: etcd-data' /etc/kubernetes/manifests.stopped/etcd.yaml
If diff reports more than the single path: line, or if the changed
line sits under etcd-certs instead of etcd-data, restore
/root/etcd.yaml.pre-restore and edit by hand.
The alternative is to move the restored directory into place with
mv /var/lib/etcd-restore /var/lib/etcd and leave the manifest
untouched, as kubernetes-lxix-04-snapshot-restore-args shows. Both
reach the same state. Repointing the hostPath keeps the restored and
pre-restore directories visibly distinct on disk, which is why it is
used here; whichever you pick, use it on all three nodes.
7. Start etcd, and only etcd
Return etcd.yaml alone. The three members must form a cluster before
any API server is allowed to write to it.
# Run on every control-plane node, then wait.
sudo mv /etc/kubernetes/manifests.stopped/etcd.yaml /etc/kubernetes/manifests/
sleep 60
sudo crictl ps --name etcd
sudo crictl logs "$(sudo crictl ps -q --name etcd)" 2>&1 | tail -30
Expect each member to log that it has published its peer URL and that
a leader has been elected. A member logging a cluster ID mismatch was
restored with a different --initial-cluster or
--initial-cluster-token than its peers; re-run step 5 on that node
alone with the recorded values.
8. Verify the restored etcd cluster
Run these from any control-plane node. etcdctl is the right tool
here: these are live API calls, not data-file operations.
export ETCDCTL_ENDPOINTS=https://10.0.1.10:2379,https://10.0.1.11:2379,https://10.0.1.12:2379
export ETCDCTL_CACERT=/etc/kubernetes/pki/etcd/ca.crt
export ETCDCTL_CERT=/etc/kubernetes/pki/etcd/server.crt
export ETCDCTL_KEY=/etc/kubernetes/pki/etcd/server.key
sudo -E etcdctl member list -w table
sudo -E etcdctl endpoint health --cluster -w table
sudo -E etcdctl endpoint status --cluster -w table
sudo -E etcdctl alarm list
Four things must hold before step 9:
member listshows exactly three members, with the three names and the three peer URLs from the table in step 1, and no fourth member.endpoint healthreports every endpoint healthy.endpoint statusshows exactly one member withIS LEADERtrue, and the sameRAFT TERMand revision on all three.alarm listis empty. ANOSPACEalarm here means the restored database is already over the quota, and the API server will fail to write.
Do not continue while any member is missing, unhealthy or reporting a different revision. Starting the API servers against a half-formed cluster turns a recoverable state into a second incident.
9. Start the rest of the control plane
sudo mv /etc/kubernetes/manifests.stopped/kube-apiserver.yaml \
/etc/kubernetes/manifests.stopped/kube-controller-manager.yaml \
/etc/kubernetes/manifests.stopped/kube-scheduler.yaml \
/etc/kubernetes/manifests/
sleep 60
sudo crictl ps --state running | grep -E 'kube-apiserver|kube-scheduler|kube-controller-manager|etcd'
sudo rmdir /etc/kubernetes/manifests.stopped
rmdir refuses to remove the staging directory unless it is empty,
which is a cheap check that no manifest was left behind.
10. Verify the API server
kubectl get --raw /livez?verbose
kubectl get --raw /readyz?verbose
kubectl get nodes -o wide
kubectl get pods -A --no-headers | wc -l
kubectl get clusterrolebindings,rolebindings -A --no-headers | wc -l
kubectl auth can-i get pods
Compare the counts against the values recorded in step 1. They should match the cluster as it was at the snapshot revision, not as it was immediately before the restore; a difference is the data-loss window made visible, and it belongs in the ticket.
11. Verify add-ons and workloads
kubectl -n kube-system get pods -l k8s-app=kube-dns
kubectl -n kube-system get pods -l k8s-app=kube-proxy
kubectl -n kube-system get daemonset
# DNS and scheduling both work end to end.
kubectl run restore-check --image=registry.k8s.io/pause:3.10.1 --restart=Never
kubectl wait --for=condition=Ready pod/restore-check --timeout=60s
kubectl delete pod restore-check
# Spot-check application workloads.
kubectl get deploy,sts -A
kubectl describe deploy/web -n prod | head -30
curl -fsS https://app.prod.example/healthz
12. Record the restore
Write the record while the detail is fresh, and keep the disposition of the broken data directories in it — they are the rollback path until someone deletes them.
# etcd restore record
Change ticket: <ticket>
Restored by: <operator>
Approved by: <approver>
Started / finished: <UTC> / <UTC>
Snapshot file: <path>
Snapshot sha256: <hash>
Snapshot revision: <revision from etcdutl snapshot status>
Snapshot taken at: <UTC>
Data-loss window: <snapshot timestamp> to <incident timestamp>
Restore parameters:
initial-cluster: <the shared string>
initial-cluster-token: <token>
bump-revision: <n>
restored data dir: /var/lib/etcd-restore
preserved data dirs: /var/lib/etcd.broken-<timestamp> on each node
Post-restore verification:
- [ ] member list shows three members with the expected names and peer URLs
- [ ] endpoint health healthy on all three
- [ ] endpoint status: one leader, matching revision
- [ ] alarm list empty
- [ ] /readyz all checks ok
- [ ] every node Ready
- [ ] object counts reconciled against the pre-restore capture
- [ ] workload smoke tests pass
Follow-up:
- [ ] Post-mortem scheduled
- [ ] Root cause identified
- [ ] Backup validation pipeline exercised against this snapshot
- [ ] /var/lib/etcd.broken-<timestamp> deleted on <date> by <operator>
Common pitfalls
| Symptom | Cause | Action |
|---|---|---|
etcdctl snapshot restore reports unknown command | The cluster is on etcd 3.6, where the subcommand was removed | Use etcdutl snapshot restore |
etcdutl: command not found inside the etcd container | The registry.k8s.io/etcd image ships etcd and etcdctl only | Install etcdutl on the host from the matching etcd release tarball |
Restore fails with data-dir ... not empty | The target directory already exists | Confirm you are pointing at the new directory and that step 4 moved the old one aside |
member list shows one member per node instead of one cluster | The restore ran without --initial-cluster | Re-run step 5 on every node with the full membership; the manifest cannot fix this |
| Members start but never elect a leader | A node was restored with the wrong --initial-advertise-peer-urls, or with a peer URL its neighbours do not expect | Compare each node’s restore flags against its own etcd.yaml |
| One member reports a cluster ID mismatch | That node used a different --initial-cluster-token or --initial-cluster | Re-run step 5 on that node with the recorded values |
| etcd starts but the data is the old, broken data | The etcd-data hostPath still points at /var/lib/etcd | Re-check the diff from step 6 |
| Controllers behave inconsistently after a healthy restore | Informer caches still hold post-snapshot revisions | Restore with --bump-revision and --mark-compacted |
| Workloads missing after restore | The snapshot pre-dates them | Expected; reconcile from Git, or back out and select a later snapshot |
| Nodes NotReady after restore | Certificates rotated after the snapshot was taken | See kubernetes-rb-renew-cluster-certs |
An etcd restore is a controlled detonation. The recovery point is chosen deliberately, every node restores with its own identity and the same membership, the old data directories survive until someone decides otherwise, and the restored state is validated through etcd and through the API server before a single workload is readmitted.