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-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:
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.
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.
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: 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.
