Backups & Recovery¶
Nobody wants backups. Everybody wants restores. This page is written with that distinction in mind: every mechanism below is described in terms of what it can actually get back for you, and — more importantly — what it cannot.
Current state¶
| Data | Backed up | How |
|---|---|---|
| Kubernetes objects | Nightly, 02:00 | Velero to the Ceph object store, 14 day TTL |
| Ceph RBD volumes (PVCs) | Nightly, 02:00 | Velero CSI snapshot, moved into the object store |
| Grafana dashboards | Nightly | Its PVC is covered by the above |
| etcd (raw) | Nightly, 01:00 | CronJob to the object store, last 14 kept |
| OpenBao secrets | Manual | Raft snapshot |
| OpenBao unseal keys | Manual, off-cluster | Printed once at bao operator init |
| Prometheus metrics | No | 10 day retention, then gone |
Everything in payload/ |
Yes | It is in Git; that is the point of GitOps |
What is not covered¶
Alerting is the other half of a backup: a backup that silently stopped running
is indistinguishable from one that works, right up until the moment you need it,
which is also the moment you find out. Velero's
PrometheusRule covers that, but only reaches whoever Alertmanager is
configured to tell — see
Monitoring for the state of that.
The bigger gap is where the backups land.
Every automated backup here goes into the same cluster's Ceph. That protects
against the failures that actually happen — a deleted PVC, a bad prune, a
corrupted database, a workload that ate its own data. It does not protect
against losing the cluster, because the backups go with it.
This is worth sitting with for a moment rather than nodding past. A backup that shares a failure domain with its source covers operator error and nothing else. Operator error is genuinely the most common cause of data loss, so this is not worthless — it is just precisely one half of the job, and it is important to know which half you have.
Off-site replication is the missing piece. RGW supports bucket replication and
Velero supports a second BackupStorageLocation, so the shape of the fix is
known; neither is configured. Until then, treat the OpenBao unseal keys plus
Git as the real disaster-recovery story and these backups as protection against
mistakes rather than against the building burning down.
Velero¶
Velero backs up Kubernetes objects and volume data nightly at 02:00, keeping 14 days.
| Property | Value |
|---|---|
| Schedule | 0 2 * * *, TTL 336h |
| Scope | All namespaces except kube-system, minus events |
| Destination | S3 bucket velero in the Ceph object store |
| Volume data | CSI snapshot, then moved into the bucket by the data mover (Kopia) |
kube-system is excluded because it is reconstructed from Git on the next sync
and is large. events are excluded because they expire anyway.
Why the data mover matters¶
A CSI snapshot on its own is a Ceph object. Backing up a PVC by snapshotting it
would leave the only copy inside the same Ceph cluster the backup exists to
survive — protection against a deleted PVC, but not against a broken pool. A
snapshot is a bookmark, not a copy, and confusing the two is one of the more
expensive mistakes available in storage. With
defaultSnapshotMoveData, Velero takes the snapshot, streams the data out to
the object store, and deletes the snapshot. The durable copy is the one in the
bucket.
This is why deployNodeAgent is on: the node agent is what reads the snapshot
and does the streaming.
Two settings that are not optional here¶
The AWS plugin's aws-sdk-go-v2 sends a trailing checksum that Ceph RGW rejects
with api error XAmzContentSHA256Mismatch, so every upload fails without
this. The plugin's own README lists Ceph S3 as needing it. "S3-compatible" is
one of the great load-bearing hyphens of our industry, and this is the sort of
thing it is carrying.
Plugin v1.14.x pairs with Velero v1.18.x, this chart's appVersion. The
chart's commented example still shows v1.13.1, which is the v1.17 line.
Other settings worth knowing¶
- No credentials file.
credentials.useSecretisfalse: the AWS plugin reads its keys from the environment, andextraEnvVarsfeeds that straight from theSecretRook writes for thevelero-bucketclaim. The keys are never rendered into a file or into Git. volumeSnapshotLocation: []must stay empty. The chart ships a placeholder entry with a null name and provider, renders it as aVolumeSnapshotLocationcalleddefault, and the CRD schema rejects it, failing every sync. Helm replaces lists rather than merging them, so the empty list removes it. Nothing here needs one: CSI and the data mover use aVolumeSnapshotClass, and aVolumeSnapshotLocationbelongs to the legacy per-provider snapshotter plugins.- No
runAsNonRoot. The plugin initContainer copies itself into/target, and whether that works as non-root depends on the image's ownUSER. Confirm it on a real backup before adding it; the seccomp profile,allowPrivilegeEscalation: falseand dropped capabilities are set. - Node agent sizing. Its limits come from a measured 37Mi peak. It is idle except during a backup, so the headroom is deliberately wide.
Snapshot plumbing¶
kubeadm does not install the CSI snapshot controller and neither does Rook.
Without it the VolumeSnapshot CRDs are absent and the RBD driver advertises
snapshot support nothing can invoke. snapshot-controller.yaml installs the
controller and its CRDs at sync-wave 2, ahead of Velero at 3.
The VolumeSnapshotClass for rook-ceph-block is at wave 3 for the same
reason, and that one is load-bearing. At the default wave it was applied before
its CRD existed and failed with no matches for kind VolumeSnapshotClass.
SkipDryRunOnMissingResource does not help there, since it is the apply that
fails, not the dry run — and the failed sync meant the snapshot-controller and
velero Applications queued behind it were never created at all.
Its deletionPolicy is Delete: the snapshot is only an intermediate step, and
the durable copy is the one the data mover writes into the object store.
Using it¶
kubectl -n backup get backups.velero.io
kubectl -n backup get backupstoragelocation # should be Available
# On demand
velero backup create manual-$(date +%s) --include-namespaces my-app
# Restore
velero restore create --from-backup velero-daily-20260905020000
If backups sit in PartiallyFailed, check that a VolumeSnapshotClass labelled
velero.io/csi-volumesnapshot-class: "true" exists — without it Velero finds no
class for the RBD driver and skips volumes silently, leaving you with a
backup full of Kubernetes objects and none of the data anybody cared about.
Alerting¶
Velero ships a PrometheusRule here: VeleroBackupFailures (critical) and
VeleroBackupPartialFailures (warning). A backup that silently stopped running
is the failure mode this exists to prevent — and it is the one that catches
experienced people, because the dashboard stays green and the CronJob still
exists and everything looks exactly like it did last month.
etcd¶
Velero restores objects through the API server. That is the wrong tool for the case where there is no API server left to restore through — lost quorum, a corrupted data directory, three dead control-plane nodes. For that you need the etcd data itself, which Velero does not capture.
Two backup systems for two genuinely different disasters. It looks like redundancy until the day you need the one you skipped.
A CronJob takes one nightly at 01:00, an hour before Velero runs:
| Property | Value |
|---|---|
| Schedule | 0 1 * * * |
| Where it runs | Any control-plane node — hostNetwork, since etcd listens on 127.0.0.1 and its client certs are on the node |
| Verification | etcdutl snapshot status before upload, so a truncated snapshot fails the job instead of quietly replacing a good backup |
| Destination | S3 bucket etcd-backup, newest 14 kept |
etcdctl snapshot status no longer exists
It was removed in etcd 3.6. The verification step uses etcdutl, which
ships in the same image.
The snapshots go to their own bucket rather than Velero's because they are recovered by entirely different means. Two details of the job are easy to undo by accident:
dnsPolicy: ClusterFirstWithHostNet— withhostNetworkthe default policy uses the node'sresolv.conf, which cannot resolve the RGW Service the upload needs.- The upload writes an AWS CLI config file with
addressing_style = path. RGW addresses buckets by path, the CLI defaults to virtual-host style, and there is no environment variable for it.
Taking one by hand¶
Take a snapshot from a control-plane node:
kubectl -n kube-system exec -it etcd-<node> -- etcdctl \
--cacert /etc/kubernetes/pki/etcd/ca.crt \
--cert /etc/kubernetes/pki/etcd/server.crt \
--key /etc/kubernetes/pki/etcd/server.key \
snapshot save /var/lib/etcd/snapshot.db
kubectl cp kube-system/etcd-<node>:/var/lib/etcd/snapshot.db ./etcd-snapshot.db
Copy it off the cluster. Now. A snapshot stored only on a Ceph PVC does not survive the failure it exists for, and "I'll move it later" has never once happened in the history of operations.
Restoring is etcdctl snapshot restore into a fresh data directory on a stopped
control plane, then restarting kubelet — see the
upstream kubeadm documentation.
OpenBao¶
kubectl -n openbao exec -it openbao-0 -- bao operator raft snapshot save /tmp/snapshot.bao
kubectl -n openbao cp openbao-0:/tmp/snapshot.bao ./openbao-snapshot.bao
The snapshot contains all KV data plus policies, roles and mounts. It does not contain the unseal keys, and it is useless without them — a perfect, verified, encrypted brick. Details in OpenBao → Backups.
Ceph volumes¶
Volume data is covered by Velero: PVCs are snapshotted nightly and the data is moved into the object store, so a deleted PVC is recoverable.
Ceph's own replication is not a backup and should not be mistaken for one. It
spreads each block across OSDs, which protects against a disk or a node failing
and against nothing else — not deletion, not corruption, not a bad prune. That
last one is not hypothetical: every Application here runs with prune: true, so
removing a PersistentVolumeClaim from Git deletes the volume.
What is still open is off-cluster replication — see What is not covered. RBD mirroring to a second cluster would close it.
Rebuilding from scratch¶
What you need, in order:
- The Git repository — all platform and workload manifests.
- The OpenBao unseal keys and root token — without these the restored OpenBao is an encrypted brick.
- An OpenBao raft snapshot, or the willingness to re-enter every secret.
- Optionally an etcd snapshot, to skip re-issuing certificates and waiting for the platform to re-converge.
Items 1 and 2 are the ones that actually matter, and only one of them lives somewhere GitHub keeps a copy. If you take a single action after reading this page, make it checking that you still know where those five key shares are.
The rebuild itself is the Quickstart from step 1. Because the platform is declarative, the cluster converges back to its documented state once ArgoCD points at the repository and OpenBao is unsealed.