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-Clusterist immutable in vielen Feldern; ein Major-Upgrade oder ein Bootstrap-from-Recovery wird ein neuer Clustersynapse-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.
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.
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: falsesorgt 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.
