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.

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:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: "${APP}-volsync"
spec:
  secretStoreRef:
    kind: ClusterSecretStore
    name: vault-backend
  target:
    name: "${APP}-volsync-secret"
    template:
      data:
        RESTIC_REPOSITORY: "{{ .RESTIC_REPOSITORY }}/${APP}"
        RESTIC_PASSWORD: "{{ .RESTIC_PASSWORD }}"
  data:
    - secretKey: RESTIC_REPOSITORY
      remoteRef:
        key: "${CLUSTER_NAME}/volsync/restic/borgbase"
        property: repo
    - secretKey: RESTIC_PASSWORD
      remoteRef:
        key: "${CLUSTER_NAME}/volsync/restic/borgbase"
        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:

spec:
  sourcePVC: "${APP}"
  trigger:
    schedule: "${VOLSYNC_SCHEDULE}"   # Standard: "0 3 * * 0" — sonntags 03:00
  restic:
    copyMethod: Snapshot
    pruneIntervalDays: 7
    repository: "${APP}-volsync-secret"
    retain:
      hourly: 24
      daily: 7
      weekly: 5

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:

spec:
  dataSourceRef:
    kind: ReplicationDestination
    apiGroup: volsync.backube
    name: "${APP}-dst"

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:

resources:
  - ../../../../../../templates/volsync
  - deployment.yaml
  - service.yaml
  - httproute.yaml

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

  dependsOn:
    - name: volsync
  postBuild:
    substitute:
      APP: snac
      VOLSYNC_CAPACITY: 5Gi
      VOLSYNC_CACHE_CAPACITY: 5Gi
      VOLSYNC_STORAGECLASS: "ceph-block"
      VOLSYNC_SNAPSHOTCLASS: "ceph-block"
      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:

task backup:list     NS=fediverse-system APP=snac   # Snapshots auflisten
task backup:snapshot NS=fediverse-system APP=snac   # sofort sichern
task 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.