Backups, die keine Geheimnisse verraten: VolSync und Restic

Wie Anwendungen im Cluster ihre PVCs per VolSync nach Restic sichern — ohne dass Repo-Adresse oder Passwort jemals im Git oder im App-Pod auftauchen
Table of contents

Ein Backup-System, das nur funktioniert, wenn man Zugangsdaten quer durch die Konfiguration verteilt, hat schon verloren. In meinem Lab sichern die Anwendungen ihre Daten mit VolSync nach restic — und das Schöne daran: Weder die Adresse des Remote-Repos noch sein Passwort stehen jemals im Git, und der eigentliche Anwendungs-Pod bekommt sie nie zu Gesicht.

Ein Template, viele Anwendungen#

Backup soll kein Sonderfall pro App sein, sondern ein Schalter, den man umlegt. Deshalb liegt die gesamte Mechanik in einem geteilten Template unter k8s/templates/volsync/ und besteht aus vier Bausteinen:

  • einer ReplicationSource — der geplante Backup-Job,
  • einer ReplicationDestination — der Restore-Pfad,
  • einem PersistentVolumeClaim, der sich aus genau dieser Destination speist,
  • und einem ExternalSecret, das die restic-Zugangsdaten aus Vault holt.

Eine Anwendung „abonniert“ Backups, indem sie das Template in ihre Kustomization aufnimmt und eine Handvoll Variablen stempelt. Mehr nicht. Namespaces mit strengen Pod-Security-Standards — bei mir etwa Vault, ZNC oder ZeroClaw — geben ihren kurzlebigen Movern zusätzlich das Label volsync.backube/privileged-movers: "true" mit.

Das Geheimnis bleibt im Tresor#

Hier liegt der Kern. Das ExternalSecret zieht Repo-Adresse und Passwort aus Vault und baut daraus erst zur Laufzeit das Secret zusammen, das der VolSync-Mover nutzt:

 1apiVersion: external-secrets.io/v1
 2kind: ExternalSecret
 3metadata:
 4  name: "${APP}-volsync"
 5spec:
 6  secretStoreRef:
 7    kind: ClusterSecretStore
 8    name: vault-backend
 9  target:
10    name: "${APP}-volsync-secret"
11    template:
12      data:
13        RESTIC_REPOSITORY: "{{ .RESTIC_REPOSITORY }}/${APP}"
14        RESTIC_PASSWORD: "{{ .RESTIC_PASSWORD }}"
15  data:
16    - secretKey: RESTIC_REPOSITORY
17      remoteRef:
18        key: "${CLUSTER_NAME}/volsync/restic/borgbase"
19        property: repo
20    - secretKey: RESTIC_PASSWORD
21      remoteRef:
22        key: "${CLUSTER_NAME}/volsync/restic/borgbase"
23        property: key

Drei Dinge fallen auf:

  1. Im Git steht nichts Sensibles — nur Pfadangaben nach Vault. Repo und Passwort leben ausschließlich im Tresor.
  2. Pro App ein eigener Repo-Unterpfad ({{ .RESTIC_REPOSITORY }}/${APP}). Alle teilen sich dasselbe restic-Remote bei BorgBase , aber jede App sichert in ihre eigene Ecke.
  3. Der App-Pod sieht die Credentials nie. Nur der kurzlebige VolSync-Mover-Pod bekommt das ${APP}-volsync-secret gemountet — die Anwendung selbst hat damit nichts zu tun.
Diese Trennung ist bewusst: Selbst wenn eine Anwendung kompromittiert würde, läge der Schlüssel zum Backup-Repo nicht in ihrem Pod. Die Sicherung ist eine Eigenschaft der Plattform, nicht der App.

Sichern und Wiederherstellen#

Die ReplicationSource ist der eigentliche Backup-Job. Sie zieht per CSI einen konsistenten Snapshot des PVCs (copyMethod: Snapshot), übergibt ihn an restic und hält eine gestaffelte Aufbewahrung vor:

 1spec:
 2  sourcePVC: "${APP}"
 3  trigger:
 4    schedule: "${VOLSYNC_SCHEDULE}"   # Standard: "0 3 * * 0" — sonntags 03:00
 5  restic:
 6    copyMethod: Snapshot
 7    pruneIntervalDays: 7
 8    repository: "${APP}-volsync-secret"
 9    retain:
10      hourly: 24
11      daily: 7
12      weekly: 5

Zwei Feinheiten stecken hier drin. copyMethod: Snapshot liest über einen CSI-VolumeSnapshot einen konsistenten Stand — der Mover fasst nie das live gemountete Volume an, was bei schreibenden Diensten wie PostgreSQL oder Vault über brauchbares oder korruptes Backup entscheidet. Voraussetzung dafür ist ein installierter CSI-Snapshot-Controller mit passender VolumeSnapshotClass; fehlt der, läuft copyMethod: Snapshot wirkungslos ins Leere. Und der Schedule steht bewusst in Cron-Syntax (0 3 * * 0) statt als @weekly — YAML 1.1 deutet ein führendes @ als reserviertes Zeichen, und der Mover bekäme nie einen gültigen Plan.

Der Restore-Pfad ist der elegante Teil: Der PVC der Anwendung wird nicht leer angelegt, sondern referenziert über dataSourceRef die ReplicationDestination. Beim ersten Anlegen kann VolSync den PVC damit direkt aus dem letzten restic-Snapshot befüllen — genau das, was man bei einem Cluster-Neuaufbau braucht:

1spec:
2  dataSourceRef:
3    kind: ReplicationDestination
4    apiGroup: volsync.backube
5    name: "${APP}-dst"

Das greift sauber beim ersten Anlegen des PVC — beim Cluster-Neuaufbau also genau richtig. Einen bereits laufenden Dienst auf einen alten Stand zurückzuholen ist dagegen kein Live-Cutover: Das verlangt einen Ziel-PVC, angepasste claimName-Referenzen und einen geplanten Neustart — ein Pfad, den man einmal im Lab geübt haben sollte, bevor man im Ernstfall darauf angewiesen ist.

Das Zusammenspiel im Überblick:

flowchart LR VAULT["Vault<br/>volsync/restic/borgbase"] -->|External Secrets| SEC["${APP}-volsync-secret"] subgraph NS["Namespace der App"] PVC[("PVC ${APP}")] APP["App-Pod<br/>(mountet PVC, kennt kein Secret)"] RS["ReplicationSource<br/>Mover (geplant)"] RD["ReplicationDestination<br/>Mover (Restore)"] end REMOTE[("restic @ BorgBase<br/>repo/${APP}")] APP --- PVC PVC -->|Snapshot| RS SEC -. nur an Mover .-> RS & RD RS -->|"push"| REMOTE REMOTE -->|"restore"| RD --> PVC

Ein handfestes Beispiel: snac#

Meine snac-Instanz hält ihren gesamten Zustand in einem Verzeichnisbaum auf einem einzigen PVC — ein idealer Kandidat. Das Abonnement besteht aus zwei winzigen Stellen. In der App-Kustomization wird das Template eingebunden:

1resources:
2  - ../../../../../../templates/volsync
3  - deployment.yaml
4  - service.yaml
5  - httproute.yaml

Und in der Flux-Kustomization werden die Variablen gestempelt — inklusive dependsOn: volsync, damit der Operator vorher steht:

 1  dependsOn:
 2    - name: volsync
 3  postBuild:
 4    substitute:
 5      APP: snac
 6      VOLSYNC_CAPACITY: 5Gi
 7      VOLSYNC_CACHE_CAPACITY: 5Gi
 8      VOLSYNC_STORAGECLASS: "ceph-block"
 9      VOLSYNC_SNAPSHOTCLASS: "ceph-block"
10      VOLSYNC_CACHE_SNAPSHOTCLASS: "ceph-block"

Im Deployment mountet snac dann einfach den PVC namens snac, den VolSync bereitstellt — fertig. Ab da sichert sich snac jeden Sonntag um drei selbst.

Die Bedienung läuft über ein paar Tasks, die Repo-Pfad und Schlüssel im Hintergrund aus Vault ziehen:

1task backup:list     NS=fediverse-system APP=snac   # Snapshots auflisten
2task backup:snapshot NS=fediverse-system APP=snac   # sofort sichern
3task backup:restore  NS=fediverse-system APP=snac   # neuesten Stand zurückspielen

Fazit#

VolSync verlagert Backups dorthin, wo sie hingehören: in die Plattform, deklarativ, pro Anwendung mit einem Dreizeiler aktivierbar. Die wirklich wichtige Eigenschaft ist aber die Trennung der Geheimnisse — Repo-Adresse und Passwort wohnen im Vault, materialisieren sich nur im flüchtigen Mover-Pod und tauchen weder im Repository noch in der Anwendung auf. So fühlt sich ein Backup-System an, dem man auch im Ernstfall vertraut.

Nachtrag (25. Juni 2026): Wenn der Restore mehr Rechte braucht#

Eine Stelle, an der mich VolSync zuletzt überraschte: Der Restore selbst kann an den Berechtigungen scheitern. Restic stellt beim Zurückspielen die ursprünglichen Datei-Eigentümer wieder her — und ruft dafür lchown auf, was CAP_CHOWN voraussetzt. Der Default-Mover läuft aber bewusst unprivilegiert (drop: ["ALL"]), also kippt der Restore mit operation not permitted, sobald die Daten nicht alle dem Mover-User gehören.

Aufgefallen ist das beim Build-Namespace meines CI-Brokers : Dort liegen der Radicle-Store und das /run/containers von rootless buildah, beide mit Datei-Eigentümern, die der Restore exakt rekonstruieren muss. Die Lösung ist eine Namespace-Annotation, die VolSync in den privilegierten Mover schaltet:

1metadata:
2  annotations:
3    # Restic-Restore stellt Original-Ownership per lchown wieder her -> CAP_CHOWN.
4    volsync.backube/privileged-movers: "true"

Der privilegierte Mover läuft als root mit CHOWN/FOWNER/DAC_OVERRIDE/SETUID/SETGID. Das gebe ich nicht leichtfertig her — nur in Namespaces, die ohnehin unter privilegiertem Pod-Security-Standard stehen, wie eben der Build-Namespace mit seinem rootful buildah. Für die normalen App-Backups bleibt es beim unprivilegierten Default; ein snac-PVC braucht das nicht. Es ist die Sorte Detail, die man erst im Ernstfall lernt — nämlich dann, wenn man den Restore wirklich einmal braucht.