Lesson 67 of 72 · 24 min read · Updated

Backing Up and Restoring etcd

Every object you have ever created in a Kubernetes cluster lives in one place: etcd. Deployments, Services, ConfigMaps, Secrets, RBAC rules, CRDs and their custom resources are all keys in a small, consistent key-value store on the control plane. The worker nodes, kubelets and controllers only hold copies of what etcd says. Lose etcd, and the cluster forgets what it is supposed to run.

That makes an etcd snapshot the most basic backup of a self-managed cluster. This lesson shows what is inside etcd, takes a snapshot from a kind control plane, causes a disaster by deleting a namespace that holds a .NET 10 API with its ConfigMap and Secret, and restores the whole cluster state from the snapshot. You will also see what a snapshot does not protect, why changes made after the snapshot disappear, and how to schedule snapshots with a CronJob.

We will cover the following topics:

  1. What Kubernetes stores in etcd and how the keys are organised
  2. What an etcd snapshot contains and what it does not: no volume data, no container images
  3. etcdctl versus etcdutl, and the certificates a client needs
  4. Taking a snapshot from the etcd static Pod, checking it with etcdutl snapshot status, and copying it off the node
  5. Restoring a snapshot into a new data directory and pointing the etcd static Pod at it
  6. What happens after a restore: revision bumps, controllers reconciling, caches catching up
  7. Restore caveats for multi-member clusters and encryption at rest
  8. Scheduling snapshots with a CronJob on the control plane node
  9. When to use Velero instead

Technical Requirements

  1. A disposable kind cluster created with the kind CLI, not Docker Desktop's built-in cluster used in most other lessons of this series. Restoring etcd rewinds the entire cluster to the moment of the snapshot and requires stopping the API server, which would disrupt everything else you run on your everyday cluster. Docker Desktop also manages its control plane itself. The examples use kind v0.33.0 and kindest/node:v1.36.4 with one control-plane node and one worker. The lesson Setting Up a Local Multi-Node Cluster with kind and Docker Desktop explains the kind CLI.
  2. Docker, kubectl (v1.36.1 in the examples) and the .NET 10 SDK to build the sample API.
  3. No local etcd tools are needed. The etcd container image that kubeadm already runs on the control plane contains both etcdctl and etcdutl in version 3.6.8.
  4. The lesson Cluster Maintenance — Cordon, Drain, Certificates and Upgrades explains static Pods and kubeadm certificates, which this lesson uses.

Core Concepts

What Lives in etcd

The API server is the only component that talks to etcd. Every object it accepts is stored under a key that follows the pattern /registry/<resource>/<namespace>/<name>:

KeyObject
/registry/deployments/pricing/pricing-apiThe Deployment pricing-api in namespace pricing
/registry/configmaps/pricing/pricing-configA ConfigMap
/registry/secrets/pricing/partner-credentialsA Secret
/registry/pods/pricing/pricing-api-777d9fc7b7-hz7xsA Pod, including its status and the node it is bound to
/registry/namespaces/pricingA cluster-scoped object has no namespace segment
/registry/apiextensions.k8s.io/customresourcedefinitions/...CRDs, and below other prefixes their custom resources

Values are stored as Kubernetes protobuf with a k8s magic prefix, not as JSON. Secrets are not encrypted unless you configure encryption at rest on the API server. Anyone who can read etcd or a snapshot file can read every Secret in the cluster. The Step-by-Step section proves that in one command.

What a Snapshot Contains, and What It Does Not

An etcd snapshot is a consistent copy of the whole key space at one revision. It is all or nothing: you restore the entire cluster state, not a single namespace.

In the snapshotNot in the snapshot
Every API object: workloads, Services, ConfigMaps, Secrets, RBAC, CRDs and custom resourcesData inside PersistentVolumes. A PVC object comes back, but the database files on the disk behind it do not.
PersistentVolume and PersistentVolumeClaim objectsContainer images. Registries must still have them.
Leases, Events (only for their one-hour lifetime), node objectsCertificates and keys in /etc/kubernetes/pki, kubeconfig files and static Pod manifests on the control plane nodes
Status fields, such as which node a Pod was bound toThe encryption-at-rest configuration and its keys
Anything outside the cluster: DNS records, cloud load balancers, external databases

So a complete recovery plan for a self-managed control plane needs two things next to the snapshot: a copy of /etc/kubernetes/pki (at least the CA keys), and whatever protects the volume data, such as storage snapshots or Velero.

etcdctl and etcdutl

etcd 3.6 ships two command line tools with different jobs:

ToolTalks toUsed for
etcdctlA running etcd over the network (gRPC on port 2379)snapshot save, endpoint status, member list, get, put, defragmentation
etcdutlFiles on disk, no server neededsnapshot status, snapshot restore, offline defragmentation, migrations

etcdctl snapshot status and etcdctl snapshot restore were deprecated in etcd 3.5 and removed in 3.6: in the 3.6.8 image, etcdctl snapshot has a single subcommand, save. Inspecting and restoring snapshots is always an etcdutl job now.

Certificates for etcd Clients

In a kubeadm cluster etcd accepts only clients with a certificate signed by the etcd CA. All the files are in /etc/kubernetes/pki/etcd on the control plane:

FilePurpose
ca.crtThe etcd CA. Clients use it to verify the server (--cacert).
server.crt / server.keyetcd's serving certificate. It is also a valid client certificate, so it works for quick manual commands.
healthcheck-client.crt / healthcheck-client.keyA client-only certificate kubeadm creates for health checks. A good choice for backup jobs.
peer.crt / peer.keyUsed between etcd members. Not for clients.

Every etcdctl call therefore needs --endpoints, --cacert, --cert and --key. The Hands-On section wraps them in a shell function.

The Restore Procedure

A restore does not load data into a running etcd. It builds a new data directory from the snapshot file and starts etcd on it as if it were a new cluster that happens to contain all the old data:

StepWhy
1. Stop the API server and etcdNothing may write to the old data while you switch. On kubeadm, moving the static Pod manifests out of /etc/kubernetes/manifests stops them.
2. etcdutl snapshot restore into a new directoryCreates a fresh member with a new cluster ID. The old data directory stays untouched as a fallback.
3. Point etcd at the new directoryChange the hostPath of the etcd-data volume in etcd.yaml, or swap the directories.
4. Start the control plane againMove the manifests back. The kubelet recreates the static Pods.
5. Restart components that cache statekubelets, kube-controller-manager, kube-scheduler and other controllers such as CoreDNS hold watches and caches built before the restore.

Revisions, Watches and Why to Bump Them

etcd numbers every change with a global revision. Kubernetes exposes it as metadata.resourceVersion, and every controller, kubelet and informer remembers the last revision it has seen so it can resume a watch from there.

A restore rewinds the revision to the snapshot. Clients that saw newer revisions before the disaster would then wait for revisions that already happened in their eyes, and miss changes. etcdutl solves this with two flags that the Kubernetes documentation recommends for every restore:

  1. --bump-revision 1000000000 adds a large number to the revision, so the restored store is ahead of anything a client has seen.
  2. --mark-compacted marks all old revisions as compacted, so clients that try to resume an old watch receive an error and do a fresh list instead.

Multi-Member Clusters and Encryption at Rest

  1. Multi-member etcd. Production control planes run three or five etcd members. A snapshot is taken from one member, but the restore must recreate every member from the same snapshot file, each with its own --name and --initial-advertise-peer-urls and the full --initial-cluster list. Restoring only one member and letting it rejoin the old ones does not work, because the restored cluster has a new cluster ID. Stop all API servers first and start them only when all members are back.
  2. Encryption at rest. If the API server encrypts Secrets with an EncryptionConfiguration, the snapshot holds only ciphertext. Restoring it is useless without the same configuration file and keys. Back them up separately and never next to the snapshot, or the encryption gains you nothing.
  3. Version. Restore with an etcdutl of the same minor version as the etcd that will run on the data, and restore into a cluster of the same Kubernetes minor version.

When etcd Snapshots Are the Wrong Tool

NeedBetter tool
Recover the whole control plane after losing etcdetcd snapshot
Restore one namespace or one application, leaving the rest of the cluster as it isVelero
Restore volume data along with the objectsVelero with volume snapshots or file-system backup, or the storage system's own snapshots
Move workloads to a different cluster or another Kubernetes versionVelero, or GitOps from the lesson GitOps with Argo CD
A managed cluster (AKS, EKS, GKE)Velero. You have no access to etcd there; the provider protects the control plane.

The next lesson, Backup and Disaster Recovery with Velero, covers the application-level side.

Hands-On: Kubernetes Commands

A shell function for etcdctl

Runs etcdctl inside the etcd static Pod with the right certificates. Works in bash and zsh.

etcdctl() {
kubectl -n kube-system exec etcd-etcd-lab-control-plane -- etcdctl \
--endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key "$@"
}

Check etcd health, size and revision

etcdctl endpoint status -w table
etcdctl member list -w table

List the keys of one namespace

etcdctl get /registry/ --prefix --keys-only | grep '/pricing/'

Take a snapshot

The file lands on the control plane node, inside the directory mounted into the etcd Pod.

etcdctl snapshot save /var/lib/etcd/snapshot-$(date -u +%Y%m%d-%H%M%S).db

Inspect a snapshot file with etcdutl

ctr on the node starts the etcd image already present in containerd, so this works even when the API server is down.

docker exec etcd-lab-control-plane ctr -n k8s.io run --rm \
--mount type=bind,src=/var/backups/etcd,dst=/backup,options=rbind:ro \
registry.k8s.io/etcd:3.6.8-0 etcdutl-status \
etcdutl snapshot status /backup/<file>.db -w table

Find the etcd flags a restore must match

docker exec etcd-lab-control-plane grep -E 'initial-cluster=|initial-advertise-peer-urls|name=' /etc/kubernetes/manifests/etcd.yaml

Check whether Secrets are encrypted at rest

A count of 0 means no encryption provider is configured.

docker exec etcd-lab-control-plane grep -c encryption-provider-config /etc/kubernetes/manifests/kube-apiserver.yaml

Step-by-Step Example

The Scenario

A pricing team runs a .NET 10 API that reads its currency and discount from a ConfigMap and a partner API key from a Secret. You will take an etcd snapshot, change the configuration, and then an operator deletes the wrong namespace. The task is to bring the API, its ConfigMap and its Secret back from the snapshot, and to understand exactly which state you get back.

Step 1 — The Pricing API

dotnet new web -n PricingApi -o app --framework net10.0

Replace Program.cs. ASP.NET Core maps environment variables such as Pricing__Currency to the configuration key Pricing:Currency, so the API needs no Kubernetes-specific code:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Values come from a ConfigMap (Pricing__*) and a Secret (Partner__ApiKey).
var currency = app.Configuration["Pricing:Currency"] ?? "USD";
var discount = app.Configuration.GetValue<decimal>("Pricing:DiscountPercent");
var configVersion = app.Configuration["Pricing:ConfigVersion"] ?? "none";
var apiKey = app.Configuration["Partner:ApiKey"] ?? "";

app.MapGet("/healthz", () => Results.Ok("ok"));

app.MapGet("/api/prices/{sku}", (string sku) =>
{
var listPrice = 100m + sku.Sum(c => c) % 50;
return new
{
sku,
currency,
listPrice,
finalPrice = Math.Round(listPrice * (1 - discount / 100m), 2),
configVersion,
partnerKeyConfigured = apiKey.Length > 0
};
});

app.Run();

The list price is derived from the characters of the SKU, so every replica returns the same price for the same SKU. The Dockerfile:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY PricingApi.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "PricingApi.dll"]
docker build -t pricing-api:1.0 app

Step 2 — A Disposable Cluster

Save as etcd-lab.yaml:

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: etcd-lab
nodes:
- role: control-plane
image: kindest/node:v1.36.4@sha256:099e049362a1526b2db71494e1947aae99bd16290d7c895f2b7ea312e3cbfaed
- role: worker
image: kindest/node:v1.36.4@sha256:099e049362a1526b2db71494e1947aae99bd16290d7c895f2b7ea312e3cbfaed
kind create cluster --config etcd-lab.yaml --kubeconfig ./kubeconfig
export KUBECONFIG=$PWD/kubeconfig
kind load docker-image pricing-api:1.0 --name etcd-lab
kubectl get nodes
NAME STATUS ROLES AGE VERSION
etcd-lab-control-plane Ready control-plane 28s v1.36.4
etcd-lab-worker Ready <none> 14s v1.36.4

Look at how kubeadm runs etcd. It is a static Pod with its data in /var/lib/etcd on the node:

kubectl -n kube-system get pod etcd-etcd-lab-control-plane -o jsonpath='{.spec.containers[0].image}{"\n"}'
kubectl -n kube-system exec etcd-etcd-lab-control-plane -- etcdctl version
kubectl -n kube-system exec etcd-etcd-lab-control-plane -- etcdutl version
registry.k8s.io/etcd:3.6.8-0
etcdctl version: 3.6.8
API version: 3.6
etcdutl version: 3.6.8
API version: 3.6

Step 3 — Deploy the API With Its Configuration

Save as pricing.yaml:

apiVersion: v1
kind: Namespace
metadata:
name: pricing
---
apiVersion: v1
kind: ConfigMap
metadata:
name: pricing-config
namespace: pricing
data:
Pricing__Currency: EUR
Pricing__DiscountPercent: "15"
Pricing__ConfigVersion: "v1"
---
apiVersion: v1
kind: Secret
metadata:
name: partner-credentials
namespace: pricing
type: Opaque
stringData:
Partner__ApiKey: pk_live_7f3a9c2e
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: pricing-api
namespace: pricing
spec:
replicas: 2
selector:
matchLabels:
app: pricing-api
template:
metadata:
labels:
app: pricing-api
spec:
containers:
- name: api
image: pricing-api:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
envFrom:
- configMapRef:
name: pricing-config
- secretRef:
name: partner-credentials
readinessProbe:
httpGet:
path: /healthz
port: 8080
---
apiVersion: v1
kind: Service
metadata:
name: pricing-api
namespace: pricing
spec:
selector:
app: pricing-api
ports:
- port: 80
targetPort: 8080
kubectl apply -f pricing.yaml
kubectl -n pricing rollout status deploy/pricing-api
kubectl run curl --rm -i --restart=Never --image=curlimages/curl:8.17.0 -q -- \
sh -c 'sleep 2; curl -s http://pricing-api.pricing/api/prices/SKU-42'
{"sku":"SKU-42","currency":"EUR","listPrice":140,"finalPrice":119.00,"configVersion":"v1","partnerKeyConfigured":true}

Step 4 — Look Inside etcd

Define the etcdctl function from the Hands-On section, then ask etcd about itself:

etcdctl endpoint status -w table
etcdctl member list -w table

Condensed:

| ENDPOINT | ID | VERSION | DB SIZE | IS LEADER | RAFT TERM | RAFT INDEX |
| https://127.0.0.1:2379 | b9048c54cd74e23b | 3.6.8 | 1.5 MB | true | 2 | 701 |

| ID | STATUS | NAME | PEER ADDRS | CLIENT ADDRS |
| b9048c54cd74e23b | started | etcd-lab-control-plane | https://172.18.0.12:2380 | https://172.18.0.12:2379 |

One member, 1.5 MB. Count the keys and list those of the pricing namespace, leaving out the Events:

etcdctl get /registry/ --prefix --keys-only | grep -c .
etcdctl get /registry/ --prefix --keys-only | grep '/pricing/' | grep -v events
417
/registry/configmaps/pricing/kube-root-ca.crt
/registry/configmaps/pricing/pricing-config
/registry/deployments/pricing/pricing-api
/registry/endpointslices/pricing/pricing-api-nph8l
/registry/pods/pricing/pricing-api-777d9fc7b7-hz7xs
/registry/pods/pricing/pricing-api-777d9fc7b7-t7f7h
/registry/replicasets/pricing/pricing-api-777d9fc7b7
/registry/secrets/pricing/partner-credentials
/registry/serviceaccounts/pricing/default
/registry/services/endpoints/pricing/pricing-api
/registry/services/specs/pricing/pricing-api

Now read the Secret straight from etcd, bypassing the API server and its RBAC:

etcdctl get /registry/secrets/pricing/partner-credentials --print-value-only | head -c 16 | xxd
etcdctl get /registry/secrets/pricing/partner-credentials | strings | grep -E 'pk_live|Partner__ApiKey'
00000000: 6b38 7300 0a0c 0a02 7631 1206 5365 6372 k8s.....v1..Secr
{"apiVersion":"v1","kind":"Secret","metadata":{"annotations":{},"name":"partner-credentials","namespace":"pricing"},"stringData":{"Partner__ApiKey":"pk_live_7f3a9c2e"},"type":"Opaque"}
Partner__ApiKey
pk_live_7f3a9c2e

The value starts with the k8s protobuf prefix, and the API key is there in clear text, twice: in the data and in the last-applied-configuration annotation that kubectl apply writes. This cluster has no encryption at rest:

docker exec etcd-lab-control-plane grep -c encryption-provider-config /etc/kubernetes/manifests/kube-apiserver.yaml
0

Every snapshot of this etcd is therefore as sensitive as all the Secrets in the cluster together. Store snapshots encrypted and with tight access control.

Step 5 — Take a Snapshot

TS=$(date -u +%Y%m%d-%H%M%S)
etcdctl snapshot save /var/lib/etcd/snapshot-$TS.db
Snapshot saved at /var/lib/etcd/snapshot-20261021-123203.db
Server version 3.6.0

The etcd Pod can write only to the directories mounted into it, so the file lands in the data directory first. Move it to a backup directory on the node and copy it off the node. On a real cluster that last step is scp or an upload to object storage; with kind it is docker cp:

docker exec etcd-lab-control-plane sh -c "mkdir -p /var/backups/etcd && mv /var/lib/etcd/snapshot-$TS.db /var/backups/etcd/ && ls -l /var/backups/etcd/"
mkdir -p backups
docker cp etcd-lab-control-plane:/var/backups/etcd/snapshot-$TS.db backups/
ls -l backups
-rw------- 1 root root 1478688 Oct 21 12:32 snapshot-20261021-123203.db
-rw-------@ 1 fcakiroglu wheel 1478688 21 Eki 15:32 snapshot-20261021-123203.db

A snapshot that exists only on the node it protects is not a backup: when the control plane node dies, the snapshot dies with it.

Check the file with etcdutl. It runs from the same etcd image, started directly in containerd with ctr, so it does not depend on the API server:

docker exec etcd-lab-control-plane ctr -n k8s.io run --rm \
--mount type=bind,src=/var/backups/etcd,dst=/backup,options=rbind:ro \
registry.k8s.io/etcd:3.6.8-0 etcdutl-status \
etcdutl snapshot status /backup/snapshot-$TS.db -w table
+----------+----------+------------+------------+---------+
| HASH | REVISION | TOTAL KEYS | TOTAL SIZE | VERSION |
+----------+----------+------------+------------+---------+
| 2234843f | 670 | 417 | 1.5 MB | 3.6.0 |
+----------+----------+------------+------------+---------+

The snapshot is consistent at revision 670 and contains the same 417 keys you counted.

Step 6 — Life Goes On After the Snapshot

The team ships a new configuration and rolls the API:

kubectl -n pricing patch configmap pricing-config --type merge \
-p '{"data":{"Pricing__DiscountPercent":"20","Pricing__ConfigVersion":"v2"}}'
kubectl -n pricing rollout restart deploy/pricing-api
kubectl -n pricing rollout status deploy/pricing-api
kubectl run curl --rm -i --restart=Never --image=curlimages/curl:8.17.0 -q -- \
sh -c 'sleep 2; curl -s http://pricing-api.pricing/api/prices/SKU-42'
{"sku":"SKU-42","currency":"EUR","listPrice":140,"finalPrice":112.0,"configVersion":"v2","partnerKeyConfigured":true}

Another team creates a namespace of its own:

kubectl create namespace reports
kubectl -n reports create configmap created-after-snapshot --from-literal=note=lost-on-restore

Step 7 — The Disaster

Someone cleans up the wrong namespace:

kubectl delete namespace pricing
kubectl get ns
kubectl get all -n pricing
namespace "pricing" deleted
NAME STATUS AGE
default Active 73s
kube-node-lease Active 73s
kube-public Active 73s
kube-system Active 73s
local-path-storage Active 69s
reports Active 11s
No resources found in pricing namespace.

The Deployment, both Pods, the Service, the ConfigMap and the Secret are gone, and with the Secret the partner API key, which nobody has written down anywhere else.

Step 8 — Stop the Control Plane

Move all static Pod manifests out of the directory the kubelet watches. The kubelet stops the containers within a few seconds:

docker exec etcd-lab-control-plane sh -c 'mkdir -p /etc/kubernetes/manifests-stopped && mv /etc/kubernetes/manifests/*.yaml /etc/kubernetes/manifests-stopped/'
docker exec etcd-lab-control-plane crictl ps --name 'etcd|kube-apiserver|kube-controller|kube-scheduler'
kubectl get nodes
CONTAINER IMAGE CREATED STATE NAME ATTEMPT POD ID POD NAMESPACE
Unable to connect to the server: EOF

No control plane container is running, and the API is down. The workloads on the worker keep running as they were; only the API is unavailable.

Step 9 — Restore Into a New Data Directory

A restore needs the member name and peer URL the etcd static Pod uses. Read them from its manifest:

docker exec etcd-lab-control-plane grep -E 'initial-cluster=|initial-advertise-peer-urls|name=' /etc/kubernetes/manifests-stopped/etcd.yaml
- --initial-advertise-peer-urls=https://172.18.0.12:2380
- --initial-cluster=etcd-lab-control-plane=https://172.18.0.12:2380
- --name=etcd-lab-control-plane

Run etcdutl snapshot restore from the etcd image, with /var/lib of the node mounted so it can create the new directory next to the old one:

docker exec etcd-lab-control-plane ctr -n k8s.io run --rm \
--mount type=bind,src=/var/backups/etcd,dst=/backup,options=rbind:ro \
--mount type=bind,src=/var/lib,dst=/var/lib,options=rbind:rw \
registry.k8s.io/etcd:3.6.8-0 etcdutl-restore \
etcdutl snapshot restore /backup/snapshot-$TS.db \
--data-dir /var/lib/etcd-restored \
--name etcd-lab-control-plane \
--initial-cluster etcd-lab-control-plane=https://172.18.0.12:2380 \
--initial-advertise-peer-urls https://172.18.0.12:2380 \
--bump-revision 1000000000 --mark-compacted

The interesting lines of the output:

info snapshot/v3_snapshot.go:305 restoring snapshot {"path": "/backup/snapshot-20261021-123203.db", "wal-dir": "/var/lib/etcd-restored/member/wal", "data-dir": "/var/lib/etcd-restored", ...}
info snapshot/v3_snapshot.go:396 bumping latest revision {"latest-revision": 670, "bump-amount": 1000000000, "new-latest-revision": 1000000670}
info snapshot/v3_snapshot.go:413 marking revision compacted {"revision": 1000000670}
info membership/cluster.go:424 added member {"cluster-id": "...", "added-peer-peer-urls": ["https://172.18.0.12:2380"], ...}
info snapshot/v3_snapshot.go:333 restored snapshot {"path": "/backup/snapshot-20261021-123203.db", ...}

The old data directory is still there, untouched, in case the restore has to be rolled back:

docker exec etcd-lab-control-plane sh -c 'ls -la /var/lib | grep etcd'
drwx------ 3 root root 4096 Oct 21 12:32 etcd
drwx------ 3 root root 4096 Oct 21 12:33 etcd-restored

On a node with its own package manager you would install etcdutl from the etcd release archive of the same version instead of using ctr. The command line is identical.

Step 10 — Point etcd at the Restored Data and Start the Control Plane

The etcd container always uses --data-dir=/var/lib/etcd. What changes is the host directory mounted there, the etcd-data volume:

docker exec etcd-lab-control-plane sed -i 's#path: /var/lib/etcd$#path: /var/lib/etcd-restored#' /etc/kubernetes/manifests-stopped/etcd.yaml
docker exec etcd-lab-control-plane grep -B1 -A2 'path: /var/lib/etcd' /etc/kubernetes/manifests-stopped/etcd.yaml
- hostPath:
path: /var/lib/etcd-restored
type: DirectoryOrCreate
name: etcd-data

Move the manifests back and wait for the API:

docker exec etcd-lab-control-plane sh -c 'mv /etc/kubernetes/manifests-stopped/*.yaml /etc/kubernetes/manifests/'
until kubectl get --raw /readyz >/dev/null 2>&1; do sleep 2; done; kubectl get --raw /readyz
ok

The API server answered four seconds after the manifests were back.

Step 11 — Restart the Components That Cache State

The kubelets, controllers and CoreDNS all hold watches and caches built before the restore. Start with the kubelets:

for n in etcd-lab-control-plane etcd-lab-worker; do docker exec $n systemctl restart kubelet; done

The controller manager and scheduler were already restarted in Steps 8 and 10, together with the API server, because they are static Pods too. CoreDNS follows in the next step.

Step 12 — Verify What Came Back

kubectl get ns
kubectl -n pricing get deploy,rs,pods,svc,cm,secret
NAME STATUS AGE
default Active 2m53s
kube-node-lease Active 2m53s
kube-public Active 2m53s
kube-system Active 2m53s
local-path-storage Active 2m49s
pricing Active 2m25s

NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/pricing-api 2/2 2 2 2m25s

NAME DESIRED CURRENT READY AGE
replicaset.apps/pricing-api-777d9fc7b7 2 2 2 2m25s

NAME READY STATUS RESTARTS AGE
pod/pricing-api-777d9fc7b7-hz7xs 1/1 Running 1 2m25s
pod/pricing-api-777d9fc7b7-t7f7h 1/1 Running 1 2m25s

NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/pricing-api ClusterIP 10.96.14.179 <none> 80/TCP 2m25s

NAME DATA AGE
configmap/kube-root-ca.crt 1 2m25s
configmap/pricing-config 3 2m25s

NAME TYPE DATA AGE
secret/partner-credentials Opaque 1 2m25s

Everything is back with its original names, the original Service ClusterIP and even the original Pod names. The Pod objects came back from etcd still bound to etcd-lab-worker, whose containers had been removed when the namespace was deleted, so the kubelet started them again; that is the RESTARTS 1. The events show it:

kubectl -n pricing get events --sort-by=.lastTimestamp | tail -3
32s Normal Pulled pod/pricing-api-777d9fc7b7-t7f7h Container image "pricing-api:1.0" already present on machine and can be accessed by the pod
32s Normal Started pod/pricing-api-777d9fc7b7-hz7xs Container started
32s Normal Created pod/pricing-api-777d9fc7b7-hz7xs Container created

Now look at which state came back:

kubectl -n pricing get cm pricing-config -o jsonpath='{.data}{"\n"}'
kubectl -n pricing get secret partner-credentials -o jsonpath='{.data.Partner__ApiKey}' | base64 -d; echo
kubectl get ns reports
{"Pricing__ConfigVersion":"v1","Pricing__Currency":"EUR","Pricing__DiscountPercent":"15"}
pk_live_7f3a9c2e
Error from server (NotFound): namespaces "reports" not found

The configuration is v1, the state at the snapshot. The v2 change from Step 6, the rollout it triggered, and the whole reports namespace of the other team are gone. A restore rewinds the entire cluster, not just the namespace you lost. That is the price of etcd snapshots, and the reason to take them often.

The API answers with the restored configuration:

kubectl run curl --rm -i --restart=Never --image=curlimages/curl:8.17.0 -q -- \
sh -c 'sleep 2; curl -s http://pricing-api.pricing/api/prices/SKU-42'
{"sku":"SKU-42","currency":"EUR","listPrice":140,"finalPrice":119.00,"configVersion":"v1","partnerKeyConfigured":true}

This answer did not come immediately. During the first minute or so after the API came back, the same command failed, and the Pod logged curl: (6) Could not resolve host: pricing-api.pricing. A little later, name resolution and the request worked without any further intervention. Cluster DNS, like every component with a cache, needs time to catch up after a restore. Rather than waiting for that, make a restart of CoreDNS and of every other controller in the cluster, such as ingress controllers, cert-manager or operators, part of the runbook, so none of them acts on a stale cache:

kubectl -n kube-system rollout restart deploy/coredns
kubectl -n kube-system rollout status deploy/coredns
deployment.apps/coredns restarted
Waiting for deployment "coredns" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "coredns" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "coredns" rollout to finish: 1 of 2 updated replicas are available...
deployment "coredns" successfully rolled out

Then verify the application end to end before you declare the incident over.

Finally, the revision. New writes continue from the bumped number, so no client can mistake the restored store for an older one:

etcdctl endpoint status -w json | jq '.[0].Status.header.revision'
kubectl create configmap rv-check -n pricing --from-literal=a=b -o jsonpath='{.metadata.resourceVersion}{"\n"}'
1000000788
1000000789

Step 13 — Schedule Snapshots With a CronJob

A manual snapshot protects one moment. A CronJob on the control plane node takes one every six hours and keeps the newest seven. The etcd image has no shell, so the work is split: an init container runs etcdctl snapshot save, and a small busybox container renames the file with a timestamp and deletes old ones. Save as etcd-backup-cronjob.yaml:

apiVersion: batch/v1
kind: CronJob
metadata:
name: etcd-backup
namespace: kube-system
spec:
schedule: "0 */6 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
hostNetwork: true
nodeSelector:
node-role.kubernetes.io/control-plane: ""
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
initContainers:
# 1. Take the snapshot with etcdctl from the etcd image itself (distroless, no shell).
- name: snapshot
image: registry.k8s.io/etcd:3.6.8-0
command:
- etcdctl
- --endpoints=https://127.0.0.1:2379
- --cacert=/etc/kubernetes/pki/etcd/ca.crt
- --cert=/etc/kubernetes/pki/etcd/healthcheck-client.crt
- --key=/etc/kubernetes/pki/etcd/healthcheck-client.key
- snapshot
- save
- /backup/latest.db
volumeMounts:
- name: etcd-certs
mountPath: /etc/kubernetes/pki/etcd
readOnly: true
- name: backup
mountPath: /backup
containers:
# 2. Give the file a timestamp and keep only the newest 7 snapshots.
- name: rotate
image: busybox:1.37
command:
- sh
- -c
- |
set -e
name="etcd-$(date -u +%Y%m%d-%H%M%S).db"
mv /backup/latest.db "/backup/$name"
ls -1t /backup/etcd-*.db | tail -n +8 | xargs -r rm -f
echo "saved $name"; ls -l /backup
volumeMounts:
- name: backup
mountPath: /backup
volumes:
- name: etcd-certs
hostPath:
path: /etc/kubernetes/pki/etcd
type: Directory
- name: backup
hostPath:
path: /var/backups/etcd
type: DirectoryOrCreate

hostNetwork: true lets the job reach etcd on 127.0.0.1:2379, where kubeadm's etcd listens, and the healthcheck-client certificate keeps the job away from etcd's own server key. Run it twice by hand instead of waiting six hours:

kubectl apply -f etcd-backup-cronjob.yaml
kubectl -n kube-system create job --from=cronjob/etcd-backup etcd-backup-manual-1
kubectl -n kube-system wait --for=condition=Complete job/etcd-backup-manual-1 --timeout=120s
kubectl -n kube-system create job --from=cronjob/etcd-backup etcd-backup-manual-2
kubectl -n kube-system wait --for=condition=Complete job/etcd-backup-manual-2 --timeout=120s
kubectl -n kube-system logs job/etcd-backup-manual-2 -c snapshot | tail -2
kubectl -n kube-system logs job/etcd-backup-manual-2 -c rotate
Snapshot saved at /backup/latest.db
Server version 3.6.0
saved etcd-20261021-123549.db
total 5008
-rw------- 1 root root 1806368 Oct 21 12:35 etcd-20261021-123543.db
-rw------- 1 root root 1830944 Oct 21 12:35 etcd-20261021-123549.db
-rw------- 1 root root 1478688 Oct 21 12:32 snapshot-20261021-123203.db

The snapshots still sit on the control plane node they are meant to protect. In production, add a step that uploads each file to object storage in another failure domain, encrypted, or run the job from a backup tool that does this for you. With several control plane nodes, the job needs to run on only one of them; a snapshot from any healthy member contains the whole cluster.

Step 14 — Clean Up

kind delete cluster --name etcd-lab --kubeconfig ./kubeconfig
Deleting cluster "etcd-lab" ...
Deleted nodes: ["etcd-lab-worker" "etcd-lab-control-plane"]

The local copy in backups/ contains every Secret of the cluster in clear text. Delete it too.

Summary

  1. etcd holds every API object under /registry/<resource>/<namespace>/<name>. Without encryption at rest, Secrets are stored in clear text, in etcd and in every snapshot.
  2. A snapshot is a consistent copy of the whole cluster state at one revision. It contains no PersistentVolume data, no container images, no certificates from /etc/kubernetes/pki and no encryption keys. Back those up separately.
  3. etcdctl talks to a running etcd (snapshot save, endpoint status); etcdutl works on files (snapshot status, snapshot restore). Both are in the etcd image kubeadm already runs.
  4. Copy snapshots off the node. A snapshot that dies with the control plane is not a backup.
  5. To restore: stop the static Pods by moving their manifests, etcdutl snapshot restore into a new data directory with the member's --name, --initial-cluster and --initial-advertise-peer-urls, point the etcd-data hostPath at it, start the control plane, restart kubelets and controllers.
  6. Always use --bump-revision and --mark-compacted, so watches restart cleanly and resource versions keep increasing.
  7. A restore rewinds everything: the v2 configuration and the unrelated reports namespace created after the snapshot were lost. Expect a short period of stale caches, such as DNS, and verify end to end.
  8. Multi-member clusters must restore every member from the same snapshot. Encrypted clusters need the same encryption configuration and keys.
  9. Schedule snapshots with a CronJob on the control plane, keep several, ship them off-site. For single namespaces, volume data, other clusters or managed Kubernetes, use Velero, covered in the next lesson, Backup and Disaster Recovery with Velero.