Skip to main content
RunBook Academy

← All runbooks in Kubernetes

critical riskcluster affecting~120 min

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 etcdutl is installed on every control-plane node at the cluster etcd minor: etcdutl version. The registry.k8s.io/etcd image kubeadm runs ships etcd and etcdctl only, so etcdutl must 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.

  1. 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-cluster string all three restores will share
  2. 2Stage the snapshot on every control-plane node and verify it with etcdutl snapshot status
  3. 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
  4. 4Set the existing etcd data directory aside on every node: mv /var/lib/etcd /var/lib/etcd.broken-<timestamp>
  5. 5Restore on each node with etcdutl snapshot restore into the new data directory /var/lib/etcd-restore, using the local --name and --initial-advertise-peer-urls for that node and the shared --initial-cluster and --initial-cluster-token
  6. 6Repoint the etcd-data hostPath in the staged etcd.yaml from /var/lib/etcd to /var/lib/etcd-restore on every node
  7. 7Return only etcd.yaml to /etc/kubernetes/manifests/ on every node so the three members form the restored cluster before anything writes to it
  8. 8Verify the restored cluster: etcdctl member list, etcdctl endpoint health --cluster, etcdctl endpoint status --cluster, etcdctl alarm list
  9. 9Return the remaining three manifests (kube-apiserver, kube-controller-manager, kube-scheduler) on every node
  10. 10Verify the API server: /livez, /readyz, and kubectl get nodes
  11. 11Verify add-ons and workloads: CoreDNS, CNI, kube-proxy, then spot-check application workloads
  12. 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 table lists exactly three members with the expected names and peer URLs, and no unexpected fourth member
  • etcdctl endpoint health --cluster -w table reports every endpoint healthy
  • etcdctl endpoint status --cluster -w table shows exactly one leader and the same revision on all three members
  • etcdctl alarm list returns no alarms
  • kubectl get --raw /readyz?verbose reports every check ok
  • kubectl get nodes returns every node Ready
  • kubectl get pods -A returns 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 pods returns yes (RBAC survived the restore)
  • kubectl get clusterrolebindings,rolebindings -A --no-headers | wc -l matches 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 the etcd-data hostPath 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 --name and --initial-advertise-peer-urls match 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-plane for a cluster rebuild path
  • · A member joins with a different cluster ID: one node restored with a different --initial-cluster-token or 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 the etcd-data hostPath in /etc/kubernetes/manifests/etcd.yaml
  • · API server up but /readyz fails: read kubectl get --raw /readyz?verbose and 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.

Clusteretcd shipped by kubeadmRestore command
Kubernetes 1.343.6.xetcdutl snapshot restoreetcdctl snapshot restore is removed
Kubernetes 1.31 to 1.333.5.xetcdutl snapshot restoreetcdctl 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.

Read-only / SafeInstall and confirm etcdutl on every control-plane node

# 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.

Read-only / SafeRead each member identity from its own manifest

# 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.

NodeMember namePeer URL
cp-1cp-1https://10.0.1.10:2380
cp-2cp-2https://10.0.1.11:2380
cp-3cp-3https://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.

Read-only / SafeStage and verify the snapshot

# 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.

Cluster-wide riskStop every control-plane static Pod on every node

# 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.

DestructiveMove the live etcd data directory aside on every node

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)

Data-loss riskRestore on cp-1

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)

Data-loss riskRestore on cp-2

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)

Data-loss riskRestore on cp-3

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:

FlagSame on all nodes?Why it is here
--nameNoThis member’s name, and it must match --name in this node’s own etcd.yaml
--initial-advertise-peer-urlsNoThe peer URL this member advertises, and it must match this node’s own manifest
--initial-clusterYesThe full three-member topology, written into the restored data directory
--initial-cluster-tokenYesA token unique to this restore, so a surviving member of the old cluster cannot join the new one
--bump-revision and --mark-compactedYesKeeps the revision from going backwards and terminates stale watches
--data-dirYesThe 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.

Read-only / SafeConfirm the restored data directory on each node

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.

Configuration changeRepoint the etcd-data hostPath on every node

# 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.

Cluster-wide riskReturn only the etcd manifest, on every node

# 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.

Read-only / SafeVerify membership, health and revision

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 list shows exactly three members, with the three names and the three peer URLs from the table in step 1, and no fourth member.
  • endpoint health reports every endpoint healthy.
  • endpoint status shows exactly one member with IS LEADER true, and the same RAFT TERM and revision on all three.
  • alarm list is empty. A NOSPACE alarm 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

Service impact possibleReturn the remaining manifests, on every node

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

Read-only / SafeVerify 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

Read-only / SafeVerify add-ons, then 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

SymptomCauseAction
etcdctl snapshot restore reports unknown commandThe cluster is on etcd 3.6, where the subcommand was removedUse etcdutl snapshot restore
etcdutl: command not found inside the etcd containerThe registry.k8s.io/etcd image ships etcd and etcdctl onlyInstall etcdutl on the host from the matching etcd release tarball
Restore fails with data-dir ... not emptyThe target directory already existsConfirm 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 clusterThe restore ran without --initial-clusterRe-run step 5 on every node with the full membership; the manifest cannot fix this
Members start but never elect a leaderA node was restored with the wrong --initial-advertise-peer-urls, or with a peer URL its neighbours do not expectCompare each node’s restore flags against its own etcd.yaml
One member reports a cluster ID mismatchThat node used a different --initial-cluster-token or --initial-clusterRe-run step 5 on that node with the recorded values
etcd starts but the data is the old, broken dataThe etcd-data hostPath still points at /var/lib/etcdRe-check the diff from step 6
Controllers behave inconsistently after a healthy restoreInformer caches still hold post-snapshot revisionsRestore with --bump-revision and --mark-compacted
Workloads missing after restoreThe snapshot pre-dates themExpected; reconcile from Git, or back out and select a later snapshot
Nodes NotReady after restoreCertificates rotated after the snapshot was takenSee 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.

References

  1. etcd 3.6 — Disaster recovery
  2. etcd — etcdutl command reference
  3. etcd — Announcing etcd v3.6.0 (etcdctl snapshot restore removed)
  4. Kubernetes documentation — Restoring an etcd cluster
  5. Kubernetes documentation — Understanding etcdctl and etcdutl