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

  1. How a snapshot is taken. Talos's MachineService.EtcdSnapshot streams 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 opens etcd.NewLocalClient and calls Snapshot). 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 for EtcdStatus and 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's Client.EtcdSnapshot and Client.EtcdStatus are used; talosctl etcd snapshot is the reference.
  2. Verification. etcd's snapshot is the bbolt file followed by the sha256 of it. talosctl checks only size % 512 == 32; etcdbackup.Verifier also 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 (an emptyDir; the root filesystem is read-only), uploaded only after verification, and the next control plane is tried.
  3. S3 client: minio-go/v7. go.mod had no S3-capable dependency (no aws SDK, no minio, no gocloud, checked with go mod graph), so any choice adds one. aws-sdk-go-v2 pulls in dozens of modules; minio-go adds nine small ones, speaks SigV4 against AWS, MinIO, Ceph and others, and supports a custom CA.
  4. 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 plain age CLI given the passphrase. Changing hkdfInfo or the derivation orphans every existing backup, so it is part of the format. bundle.Cluster.Secret is not rotated by Talos CA rotation.
  5. Credentials. spec.destination.s3.credentialsSecretRef names a Secret with accessKeyID, secretAccessKey, optional sessionToken and ca.crt. It is read from the controller's own namespace (POD_NAMESPACE), never the backup's (ADR 0011), the same rule as loadBundle and render.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.
  6. Git. The spec holds only references, so it is safe to commit and the design lists TalosEtcdBackup as Git-tracked. Status holds object key, time, size and machine name only. Follow-up: ADR 0028's ichor gitops export allow-list (internal/cli) does not include this kind, so an exported repository misses it; adding it was out of this unit's scope.
  7. 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 (acquireDisruptiveLock is the only Acquire caller 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 EtcdSnapshot streamed: the bbolt file plus the 32-byte sha256 trailer, i.e. the input of talosctl bootstrap --recover-from / Client.EtcdRecover. Restore decrypts, may re-run etcdbackup.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-role carries these rules; it already lacked talosoperations rules 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-r3 was pullable on 2026-10-27; quay.io/minio/minio and docker.io/minio/minio return 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 the OperationService.RequestEtcdBackup RPC set the annotation ichor.maesh.dev/backup-now=<RFC 3339 time> on the TalosEtcdBackup as the caller (same RBAC as the other operation writes: update on talosetcdbackups), so neither UI nor CLI touches Talos. The controller, when that time is newer than the new status.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 in Ready and 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 returns FailedPrecondition for a suspended backup and for a request still waiting, and writes a time never earlier than lastManualBackup plus one second. EtcdBackup in 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, lastManualBackup and 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.

Search the docs

Search pages and headings