Eine Postgres-Plattform statt zehn Datenbanken — CloudNativePG im Lab

Synapse, Mastodon, Harbor, Pocket-ID, Headscale: fast jeder zustandsbehaftete Dienst im Lab will Postgres. Früher hieß das zehn handgepflegte Datenbanken. Heute ist es ein Operator und pro Dienst ein dreizeiliges Cluster-Manifest — mit synchroner Replikation über drei Instanzen, VolumeSnapshot-Backups, automatischem Failover und Prometheus-Metriken out of the box. Wie CloudNativePG aus einer Bürde eine Plattform macht.
Table of contents

Wenn ich die zustandsbehafteten Dienste in meinem Lab durchgehe, fällt ein Muster auf: Synapse, Mastodon, Harbor, Pocket-ID, Headscale — sie alle wollen Postgres. Früher bedeutete das eine Handvoll handgepflegter Datenbanken, jede mit eigener Backup-Logik, eigenem Failover-Plan (also keinem) und eigener Überwachung (auch keiner). Heute ist Postgres in meinem Lab eine Plattform: ein Operator, und pro Dienst ein winziges deklaratives Cluster-Manifest. Das ist CloudNativePG .

Der Bruch: Datenbank als Bürde vs. Datenbank als Ressource

Eine Postgres-Instanz von Hand zu betreiben heißt: Replikation aufsetzen, Failover-Skripte schreiben, Backups planen und — der ehrliche Teil — Restores nie testen. In Kubernetes ohne Operator wird das nicht besser, nur containerisiert. Ein Operator dreht das um. Ich beschreibe was ich will (drei Instanzen, 50 GiB, wöchentliches Backup), und der CNPG-Operator kümmert sich um das wie: Primary-Wahl, synchrone Replikation, Failover, Backup-Orchestrierung, Metriken.

Der Operator selbst läuft genau einmal pro Cluster, ausgerollt über einen Flux-HelmRelease in common — also auf jedem Cluster identisch:

apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: cnpg-operator
spec:
  chart:
    spec:
      chart: cloudnative-pg
      version: "0.27.1"
  upgrade:
    crds: CreateReplace          # CRDs beim Upgrade mitziehen
    remediation:
      strategy: rollback
  values:
    monitoring:
      podMonitorEnabled: true
      grafanaDashboard:
        create: true
        namespace: "monitoring"
        annotations:
          grafana_folder: Postgres

Zwei Details, die mir wichtig sind: crds: CreateReplace lässt Flux die CRDs beim Chart-Upgrade aktualisieren (sonst driften sie), und der Operator bringt sein Grafana -Dashboard gleich selbst mit — es landet automatisch im Ordner „Postgres". Observability ist hier kein Nachgedanke, sondern ein Helm-Value.

Ein Dienst, ein Manifest

Das ist der Teil, der die Plattform-These trägt. So sieht die komplette Datenbank für Synapse aus — drei hochverfügbare Instanzen, dediziertes WAL-Volume, Snapshot-Backups, Metriken:

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: synapse-v1
spec:
  instances: 3
  storage:
    storageClass: "${BLOCK_STORAGE_CLASS}"
    size: 50Gi
  walStorage:
    storageClass: "${BLOCK_STORAGE_CLASS}"
    size: 5Gi
  backup:
    volumeSnapshot:
      className: "${BLOCK_STORAGE_CLASS}"
  monitoring:
    enablePodMonitor: true
  bootstrap:
    initdb:
      owner: synapse
      database: synapse
      encoding: UTF8
      localeCType: C
      localeCollate: C

Das ist alles. instances: 3 heißt: ein Primary, zwei Replicas, synchrone Streaming-Replikation, automatisches Failover bei Ausfall des Primary. Der Operator wählt einen neuen Primary, hängt die Replicas um, aktualisiert den Read-Write-Service — ohne dass ich ein Skript schreibe.

Ein paar Entscheidungen, die in den Zeilen stecken:

  • Getrenntes walStorage. Das Write-Ahead-Log auf ein eigenes PVC zu legen, trennt den sequenziellen WAL-Schreibverkehr von den zufälligen Daten-I/Os. Auf den NVMe-Volumes meiner RK1-Knoten merkt man das.
  • localeCType: C / localeCollate: C. Bewusst die C-Locale — schnellere String-Vergleiche, deterministische Sortierung, kein böses Erwachen bei einem glibc-Collation-Update, das Indizes still korrumpiert. Apps wie Synapse brauchen kein lokalisiertes Sorting.
  • name: synapse-v1. Das -v1-Suffix ist Absicht. Ein CNPG-Cluster ist immutable in vielen Feldern; ein Major-Upgrade oder ein Bootstrap-from-Recovery wird ein neuer Cluster synapse-v2, auf den ich die App umschwenke. Versionierung im Namen macht das sauber.

Backups: Snapshot, nicht pg_dump

Das Backup hängt direkt am Storage-Layer. Ein ScheduledBackup nutzt CSI-VolumeSnapshots — der CNPG-Operator orchestriert einen konsistenten Snapshot der Daten- und WAL-Volumes:

apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
  name: synapse
spec:
  schedule: "@weekly"
  immediate: true
  backupOwnerReference: self
  cluster:
    name: synapse-v1

VolumeSnapshots statt pg_dump haben einen handfesten Vorteil: Sie sind konsistent auf Block-Ebene und skalieren mit der DB-Größe nicht in der Laufzeit. Der Snapshot landet auf demselben Rook-Ceph-Layer, von dem auch die PVCs kommen — und fügt sich in dieselbe Backup-Philosophie ein, die ich für VolSync und Restic beschrieben habe. Ein auskommentierter bootstrap.recovery-Block im Cluster-Manifest ist der Gegenpart: Restore heißt, einen neuen Cluster aus genau diesem Snapshot zu bootstrappen.

flowchart LR subgraph CNPG["CNPG Cluster synapse-v1"] P["Primary"] -->|sync repl| R1["Replica"] P -->|sync repl| R2["Replica"] end P --- WAL["walStorage PVC"] P --- DATA["data PVC (ceph-block)"] SB["ScheduledBackup @weekly"] -->|VolumeSnapshot| SNAP["CSI Snapshot"] P -.->|metrics| PM["PodMonitor → Prometheus"]

Wie eine App ihre Datenbank bekommt

Der Operator legt die Datenbank und den Owner beim Bootstrap an (initdb). Das Passwort dafür kommt — natürlich — nicht aus dem Manifest, sondern über die Secrets-Pipeline: eine ExternalSecret-Ressource materialisiert die Credentials aus Vault, die App mountet sie. Für den interaktiven Fall — schnell eine Datenbank für etwas Neues — gibt es einen Task, der über den pg.${INTERNAL_DOMAIN}-Endpoint geht:

task cnpg:create-db DB_NAME=foo DB_USER=foo DB_PASS=task cnpg:list-db

Der Read-Write-Service zeigt immer auf den aktuellen Primary, auch nach einem Failover. Die App kennt nur den Service-Namen; welcher Pod gerade Primary ist, ist ihr — und mir — egal.

Der Lohn: Headscale wird HA, fast nebenbei

Den schönsten Beweis für die Plattform-These liefert Headscale. Headscale fuhr lange auf SQLite — eine Datei, ein Pod, kein Failover. Der Umzug auf HA war im Kern kein Headscale-Projekt, sondern ein CNPG-Projekt: ein dreizeiliges Cluster-Manifest, und die Kontrollebene meines Tailnets lag plötzlich auf synchron repliziertem, automatisch failover-ndem Postgres. Die schwere Arbeit hatte der Operator schon erledigt, bevor ich das Problem hatte.

Genau das ist der Plattform-Effekt: Die zehnte Datenbank kostet so viel wie die erste — ein kleines Manifest. HA, Backup, Failover und Metriken sind nicht pro Dienst zu lösen, sondern einmal, im Operator. Was vorher eine Entscheidung war („lohnt sich HA für diesen Dienst?"), ist jetzt der Default.

Day-2 und die ehrlichen Kanten

Kein Operator nimmt einem das Nachdenken ab, er verschiebt es nur. Drei Dinge, die ich im Blick behalte:

  • Major-Version-Upgrades sind kein In-Place-Toggle. Sie laufen über Recovery in einen neuen Cluster (-v2) plus App-Umschwenk — geplant, nicht beiläufig.
  • nodeMaintenanceWindow.reusePVC: false sorgt dafür, dass beim Draining eines Knotens eine neue PVC gezogen und die Instanz frisch aus der Replikation aufgebaut wird, statt das alte Volume umzuhängen. Sauberer auf einem Storage-Layer, der ohnehin repliziert.
  • Restores muss man testen. Ein Snapshot, aus dem nie ein Cluster gebootet wurde, ist eine Hoffnung, kein Backup. Der bootstrap.recovery-Pfad gehört regelmäßig geübt.

Der Bogen gefällt mir, weil er meine ganze Lab-Haltung spiegelt: Beschreibe den Zielzustand, lass eine Control-Plane den Weg dahin finden, und mach die teure Eigenschaft — hier Hochverfügbarkeit — zur Standardeinstellung statt zur Heldentat. Zehn Datenbanken, ein Operator, und das nächste zustandsbehaftete Ding, das Postgres will, ist wieder nur drei Zeilen entfernt.