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 — vermutlich — 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:

 1apiVersion: helm.toolkit.fluxcd.io/v2
 2kind: HelmRelease
 3metadata:
 4  name: cnpg-operator
 5spec:
 6  chart:
 7    spec:
 8      chart: cloudnative-pg
 9      version: "0.27.1"
10  upgrade:
11    crds: CreateReplace # CRDs beim Upgrade mitziehen
12    remediation:
13      strategy: rollback
14  values:
15    monitoring:
16      podMonitorEnabled: true
17      grafanaDashboard:
18        create: true
19        namespace: "monitoring"
20        annotations:
21          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 nicht nachträglich angeflanscht, 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:

 1apiVersion: postgresql.cnpg.io/v1
 2kind: Cluster
 3metadata:
 4  name: synapse-v1
 5spec:
 6  instances: 3
 7  storage:
 8    storageClass: "${BLOCK_STORAGE_CLASS}"
 9    size: 50Gi
10  walStorage:
11    storageClass: "${BLOCK_STORAGE_CLASS}"
12    size: 5Gi
13  backup:
14    volumeSnapshot:
15      className: "${BLOCK_STORAGE_CLASS}"
16  monitoring:
17    enablePodMonitor: true
18  bootstrap:
19    initdb:
20      owner: synapse
21      database: synapse
22      encoding: UTF8
23      localeCType: C
24      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:

 1apiVersion: postgresql.cnpg.io/v1
 2kind: ScheduledBackup
 3metadata:
 4  name: synapse
 5spec:
 6  schedule: "@weekly"
 7  immediate: true
 8  backupOwnerReference: self
 9  cluster:
10    name: synapse-v1

VolumeSnapshots statt pg_dump haben einen handfesten Vorteil: Sie sind konsistent auf Block-Ebene, und ihre Dauer wächst nicht mit der Größe der Datenbank. 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:

1task cnpg:create-db DB_NAME=foo DB_USER=foo DB_PASS=2task 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 Postgres mit automatischem Failover. 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 Probleme#

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.