0047. TalosEtcdBackup: verified, bundle-keyed age snapshots in S3
Status: Accepted · Date: 2026-10-26 · Design section: Backup, recovery, certificates
Decided: TalosEtcdBackup snapshots one healthy non-leader control plane over Talos's EtcdSnapshot stream, checks the etcd sha256 trailer, encrypts with age using a key derived from the secrets bundle, and stores <prefix>/etcd-<UTC>.snapshot.age in S3 via minio-go; restore reads exactly that
Context
Goal 4 of the design is "restore from the secrets bundle and a snapshot alone".
Nothing existed: no type, no controller, no storage format. This unit builds the
backup half. Restore (live and --recover-cluster) is the next unit, so the
stored format below is written for it and lives in internal/etcdbackup, which
both sides import so they cannot drift.
Decisions
- How a snapshot is taken. Talos's
MachineService.EtcdSnapshotstreams the bbolt database from the node's local etcd member (internal/app/machined/internal/server/v1alpha1/v1alpha1_server.go,Server.EtcdSnapshot, Talos 1.14.3: it opensetcd.NewLocalClientand callsSnapshot). It is control-plane only (CheckControlplane) and any one member suffices, since a snapshot is one consistent MVCC read. The controller asks every approved control plane forEtcdStatusand tries healthy followers first (no errors, not a learner,member_id != leader), then healthy leaders, stopping at the first that yields a verified snapshot. A single-control-plane cluster snapshots its leader. Machinery'sClient.EtcdSnapshotandClient.EtcdStatusare used;talosctl etcd snapshotis the reference. - Verification. etcd's snapshot is the bbolt file followed by the sha256 of
it.
talosctlchecks onlysize % 512 == 32;etcdbackup.Verifieralso checks the hash while streaming. It does not open the database (that needs etcd's packages); it proves the stream was complete and intact. A snapshot that fails is never uploaded: the encrypted stream is staged to a spool file (anemptyDir; the root filesystem is read-only), uploaded only after verification, and the next control plane is tried. - S3 client:
minio-go/v7.go.modhad no S3-capable dependency (no aws SDK, no minio, no gocloud, checked withgo mod graph), so any choice adds one.aws-sdk-go-v2pulls in dozens of modules; minio-go adds nine small ones, speaks SigV4 against AWS, MinIO, Ceph and others, and supports a custom CA. - Encryption: age with a scrypt recipient, passphrase derived from the bundle.
Snapshots contain Secrets, so they are always encrypted; there is nothing to
configure. The key is
hex(HKDF-SHA256(secret = bundle.Cluster.Secret, salt = bundle.Cluster.ID, info = "ichor.maesh.dev/etcd-backup/v1")). It is recomputable from the bundle and stored nowhere, so (a) the bucket alone reveals nothing, and (b) "bundle + snapshot" really is sufficient, with no second key to lose. A key stored with the backups would make encryption pointless; a key held separately would add a thing recovery depends on. The cost is that the bundle is the root of trust for backups, which it already is for the cluster. age gives streaming authenticated encryption without us writing crypto; the file also decrypts with the plainageCLI given the passphrase. ChanginghkdfInfoor the derivation orphans every existing backup, so it is part of the format.bundle.Cluster.Secretis not rotated by Talos CA rotation. - Credentials.
spec.destination.s3.credentialsSecretRefnames a Secret withaccessKeyID,secretAccessKey, optionalsessionTokenandca.crt. It is read from the controller's own namespace (POD_NAMESPACE), never the backup's (ADR 0011), the same rule asloadBundleandrender.ResolveRegistryAuth; a test puts the backup elsewhere with a decoy Secret to prove it. Errors name the Secret and key, not values. Every error string is passed through a scrubber holding the credentials, the derived passphrase and the bundle's secret scalars before it reaches status or a log, because S3 and Talos error text is not ours. - Git. The spec holds only references, so it is safe to commit and the design
lists
TalosEtcdBackupas Git-tracked. Status holds object key, time, size and machine name only. Follow-up: ADR 0028'sichor gitops exportallow-list (internal/cli) does not include this kind, so an exported repository misses it; adding it was out of this unit's scope. - No cluster lock. A snapshot is a read of one member; it does not stop, drain
or restart anything. The lock exists for disruptive operations
(
acquireDisruptiveLockis the onlyAcquirecaller and the guard test keeps it so). A node mid-upgrade fails its snapshot and the next is tried. The restore unit, which does stop etcd, must take the lock.
Stored format (what restore reads)
- Key:
<spec.destination.s3.prefix>/etcd-<YYYYMMDDTHHMMSSZ>.snapshot.age, UTC. Sorting the names sorts by time. Objects under the prefix not matching the pattern are never listed, restored or pruned. Use one prefix per cluster. - Content: an age file (scrypt recipient) whose plaintext is exactly what
EtcdSnapshotstreamed: the bbolt file plus the 32-byte sha256 trailer, i.e. the input oftalosctl bootstrap --recover-from/Client.EtcdRecover. Restore decrypts, may re-runetcdbackup.Verifier, and feeds it to Talos unchanged. - To restore: read the bundle,
etcdbackup.Passphrase(bundle),Store.List(newest first),Store.Get,Decrypt. Nothing else is needed. - Retention: after each successful upload, keep the newest
retention.keepLast(default 24) and delete the rest. Pruning errors are reported but do not fail the backup. A suspended backup prunes nothing.
Schedule, status, metrics
spec.schedule is a five-field cron in UTC (robfig/cron/v3, no dependencies),
default hourly. Due-ness is computed from status.lastAttemptTime, so a restarted
controller resumes; after a failure it retries every 5 minutes rather than waiting for
the next tick. Ready is True after success, False after a failure or when the
newest success is older than 2.5 schedule intervals (Stale). Metrics:
ichor_etcd_backup_last_success_timestamp_seconds and
ichor_etcd_backup_failures_total, labelled by namespace and name; alert rules over
them belong with the chart's monitoring option.
Deferred
-
ponytail:only S3; no GCS/Azure/local. Path-style vs virtual-host is minio-go's auto-detection; no override field. -
ponytail:no deep verification (opening the bbolt file); a periodic restore test in e2e is the real proof, per the design. -
Snapshots are staged on local disk (one snapshot of space in the
emptyDir); a streaming multipart upload would avoid that but cannot un-upload a bad snapshot. -
The chart's
manager-rolecarries these rules; it already lackedtalosoperationsrules before this change (not touched here). -
Amendment 2026-10-27: snapshots are sealed with scrypt work factor 2^10 (1 MiB) instead of age's default 2^18 (~270 MiB of heap per derivation, which OOM-killed the controller on real hardware); the key is a 256-bit HKDF output so the cost bought nothing. Decrypt caps at the same 2^10. There is no compatibility with pre-release backups: none sealed with 2^18 exist (the only ones were in torn-down test buckets), so the legacy decrypt path is dropped. The manager memory limit is 256Mi (request 128Mi): normal use is tens of MiB plus ~100 MiB of caches, and no legacy derivation needs covering. For the S3 test harness,
docker.io/bitnamilegacy/minio:2025.7.23-debian-12-r3was pullable on 2026-10-27;quay.io/minio/minioanddocker.io/minio/minioreturn 401 anonymously. -
Amendment 2026-10-28: a backup can be taken on demand.
ichor etcd backup now NAME, the Backups page's "Back up now" button and theOperationService.RequestEtcdBackupRPC set the annotationichor.maesh.dev/backup-now=<RFC 3339 time>on theTalosEtcdBackupas the caller (same RBAC as the other operation writes:updateontalosetcdbackups), so neither UI nor CLI touches Talos. The controller, when that time is newer than the newstatus.lastManualBackup, records it there and takes one snapshot in the same reconcile, outside the schedule, through the same path as a scheduled one (same key format, verification, encryption, pruning); the schedule then counts from that snapshot. A request is acted on once, whether the snapshot succeeded or not: a failure shows inReadyand is retried on the 5-minute failure interval like any failed attempt. A missing, unparsable or not-newer annotation is ignored, and a suspended backup ignores it. The pattern is ADR 0095's retry annotation. The RPC returnsFailedPreconditionfor a suspended backup and for a request still waiting, and writes a time never earlier thanlastManualBackupplus one second.EtcdBackupin the API now also carries the destination (endpoint, region, bucket, prefix; never the credentials Secret),keepLast, the last success time, the last error (the Ready condition's message while it is Failed),suspended,lastManualBackupand whether a request is pending, and the Backups page shows them. The controller now also reconciles on annotation changes, not only on spec generation changes.