StackGres und Strimzi: Day-Two-Lektionen aus einem echten Platform-Rebuild

kubernetes operators postgresql kafka devops platform-engineering

Wir haben eine Pre-Production-Umgebung von Grund auf neu aufgebaut. Zwei zustandsbehaftete Systeme mussten umgezogen werden: ein PostgreSQL-Cluster mit TimescaleDB-Erweiterungen für Zeitreihendaten und ein Kafka-Cluster für Event-Streaming über mehrere Pipeline-Stufen.

Für beide stellte sich dieselbe Frage: Standard-StatefulSets, verwaltet über Helm-Charts, oder zweckgebundene Kubernetes-Operatoren?

Wir entschieden uns für Operatoren. StackGres für PostgreSQL, Strimzi für Kafka. Keine der Entscheidungen war von Anfang an offensichtlich. Nach einigen Wochen haben beide ihren Wert bewiesen, wenn auch nicht auf die Weise, die ich erwartet hatte.

Was Operatoren wirklich versprechen

Die Theorie besagt, dass Operatoren operatives Wissen in einen Controller kodieren. Man beschreibt den gewünschten Zustand, einen PostgreSQL-Cluster mit drei Replikaten oder einen Kafka-Cluster mit SCRAM-Authentifizierung (Salted Challenge Response Authentication Mechanism), und der Operator gleicht diesen Zustand kontinuierlich ab. Anders als ein Helm-Chart, das statisches YAML rendert und sich dann zurückzieht, beobachtet und reagiert ein Operator dauerhaft.

Das Marketing-Argument ist Day-1-Setup. Das eigentliche Argument ist Day-2: Backups, Failover, Zertifikatsrotation, Replica-Recovery. Genau dort zeigt sich der Unterschied zwischen “jemand hat YAML für euren StatefulSet geschrieben” und “jemand, der dieses System tiefgreifend versteht, hat einen Controller dafür gebaut.”

StackGres: der Backup-Moment

StackGres umhüllt einen Patroni-verwalteten PostgreSQL-Cluster mit einem Satz von CRDs. Man definiert ein SGCluster, und der Operator stellt den darunterliegenden StatefulSet, Sidecars, Replikationskonfiguration und Connection-Pooling bereit und verwaltet sie kontinuierlich. PgBouncer ist eingebaut. Patroni übernimmt die Leader-Election. Man deklariert die Absicht; der Operator erledigt den Rest.

Die Backup-Konfiguration ist deklarativ:

spec:
  configurations:
    backups:
      - sgObjectStorage: my-s3-storage
        cronSchedule: "0 2 * * *"
        retention: 7

Das SGObjectStorage-Objekt definiert, wohin Backups gehen. WAL-G übernimmt den eigentlichen Upload; der Operator verwaltet den Lebenszyklus. Man schreibt keine Backup-Skripte, Cron-Jobs oder separate Backup-Deployments. Man beschreibt die Policy, und der Operator setzt sie kontinuierlich durch.

Was wir nach einigen Stunden herausfanden: Der Backup-Upload läuft innerhalb des primären Datenbank-Pods, nicht in einem separaten Backup-Job. Das ist relevant für die Injektion von Cloud-Credentials. Wenn man pod-native IAM-Credentials verwendet (also eine Cloud-Identität über den Service Account des Pods injiziert statt über einen statischen Schlüssel), muss die Annotation beim Service Account des Datenbank-Pods landen. Das allResources-Feld des Operators ist der korrekte Ort für diese Annotation, weil manuell gesetzte Annotationen bei jeder Operator-Reconcile gelöscht werden:

spec:
  metadata:
    annotations:
      allResources:
        "eks.amazonaws.com/role-arn": "arn:aws:iam::123456789012:role/pg-backup-role"

Nach der Korrektur und dem Rollout der Pods lief das Backup durch, das tagelang still gescheitert war. Das ist der erste Day-2-Moment: keine Setup-Aufgabe, sondern ein Fehler, den das Monitoring des Operators sichtbar machte und den die Struktur des Operators mit einem klaren Fix-Pfad auflöste.

Die Replica-Reinit

Der zweite Moment war dramatischer.

Ein Replikat ging in eine Crash-Schleife. Was passiert war: Das Replikat hatte seine pg_control-Datei verloren, konnte nicht vom Primary streamen und fiel auf ein StackGres-spezifisches Fallback-Verhalten zurück, nämlich den Versuch, sich aus dem neuesten WAL-G-Backup auf S3 wiederherzustellen. Das Restore lief in ein Timeout. Der Operator setzte ein failed-backup-Flag, und das Replikat startete alle 60 Sekunden neu.

Mit einem rohen StatefulSet hätte das bedeutet: in den Pod wechseln, den Zustand des Patroni-Members verstehen, entscheiden ob man wipe-and-resync oder ein manuelles pg_basebackup macht, und hoffen, es nicht schlimmer zu machen. Mit StackGres machte die Fallback-Logik des Operators den Recovery-Pfad explizit:

# Fehlgeschlagenen Restore-Marker entfernen; Operator fällt zurück auf Streaming vom Primary
kubectl exec -n data <cluster>-1 -c patroni -- \
  rm /var/lib/postgresql/replication/initialization-failed-backup
kubectl delete pod <cluster>-1 -n data

Das Replikat kam zurück, streamte vom Primary und trat dem Cluster wieder bei. Das strukturierte Fallback des Operators, “zuerst S3 versuchen, dann Leader-Streaming”, lieferte einen deterministischen Recovery-Pfad statt einer ergebnisoffenen Debugging-Session.

TimescaleDB zwischen Operatoren migrieren

Beim Umzug der Daten vom alten Deployment auf den neuen StackGres-Cluster reicht ein normales pg_restore für TimescaleDB nicht aus. Die Erweiterung hat ein eigenes Restore-Protokoll:

-- Im Ziel-Datenbank ausführen, bevor pg_restore läuft
SELECT timescaledb_pre_restore();
pg_restore -Fc --no-owner < dump.pgfc | psql target_db
-- Nach pg_restore ausführen
SELECT timescaledb_post_restore();

Die Versionen der Erweiterung in Quelle und Ziel müssen exakt übereinstimmen; das ist das eigentliche Gate vor dem Restore-Versuch. Wir haben die Migration als In-Cluster-Byte-Pipe durchgeführt, indem wir die Ausgabe eines kubectl exec auf dem Quell-Cluster direkt in ein kubectl exec -i auf dem Ziel-Cluster geleitet haben, um Datenbanktraffic nicht über eine VPN-Verbindung zu routen.

Ein Detail, das uns ausgebremst hat: StackGres installiert PostgreSQL-Erweiterungen deklarativ. Eine Datenbank, die auf uuid-ossp angewiesen ist, lässt sich nicht erfolgreich restoren, bis die Erweiterung in der Cluster-Spec deklariert ist:

spec:
  postgres:
    extensions:
      - name: uuid-ossp
        version: "1.1"

Das Anwenden der aktualisierten Cluster-Spec löst eine Live-Installation ohne Pod-Neustart aus. Die Erweiterung steht in wenigen Minuten zur Verfügung.

Strimzi: das Versions-Coupling

Strimzi verwaltet Kafka-Cluster über eine Kafka-CRD. Man deklariert den Cluster, und der Operator übernimmt Broker-Platzierung, Konfiguration, Zertifikatsmanagement und Rolling Restarts.

Das erste Constraint, auf das wir stießen, war das Versions-Coupling. Strimzi 1.0, das Kubernetes 1.30 bis 1.36 unterstützt, liefert ausschließlich Kafka 4.x. Kafka 4.x ist KRaft-only, also Kafkas eingebautes Raft-basiertes Consensus-Protokoll ohne ZooKeeper. Wer Manifeste aus einem Kafka-3.x-Deployment kopiert, muss inter.broker.protocol.version und log.message.format.version entfernen; Kafka 4.x kennt diese Einstellungen nicht mehr.

Das klingt schlimmer als es ist. Das Kafka-Wire-Protokoll verhandelt Versionen bidirektional. Eine 3.x-Client-Bibliothek kann ohne Änderungen mit einem 4.x-Broker kommunizieren.

TLS: warum cert-manager, nicht ACM

Für externen Zugriff brauchten wir Clients, die dem Broker-Zertifikat ohne einen eigenen CA-Import vertrauen. Die Optionen waren ein verwalteter Zertifikatsdienst des Cloud-Providers oder Let’s Encrypt über cert-manager.

Verwaltete Cloud-Zertifikate funktionieren hier nicht. Sie lassen sich nicht exportieren. TLS lässt sich damit am Load Balancer terminieren, aber ein verwaltetes Zertifikat lässt sich nicht in einen Broker-Pod mounten. Ein Kafka-Broker benötigt das Zertifikat lokal für L4-TLS; ein vorgelagerter Proxy, der TLS terminiert, reicht nicht. cert-manager mit Let’s Encrypt war der einzige Weg zu einem öffentlich vertrauenswürdigen Zertifikat, das sich in den Pod mounten lässt.

Ein Gotcha, das uns einige Minuten kostete: cert-manager generiert standardmäßig PKCS#1-Private-Keys. Kafkas SSL-Implementierung lehnt PKCS#1 mit algid parse error, not a sequence ab. Die Korrektur ist ein einziges Feld:

spec:
  privateKey:
    encoding: PKCS8

Nach der Korrektur der Key-Kodierung und einem erzwungenen Operator-Reconcile über die strimzi.io/force-reconcile-Annotation starteten die Broker fehlerfrei.

Automatische Zertifikatsrotation

Das Let’s Encrypt-Zertifikat läuft nach 90 Tagen ab. cert-manager erneuert es automatisch am Tag 60 und überschreibt dasselbe Kubernetes-Secret an Ort und Stelle. Der Secret-Name ändert sich nicht, also ändert sich auch die Strimzi-Listener-Referenz nicht.

Strimzi hasht das referenzierte Secret und rollt Broker, wenn sich der Hash ändert, einen nach dem anderen, ohne Ausfallzeit. Clients bemerken nichts: Host und SAN bleiben konstant, und Let’s Encrypt’s Root-Zertifikat ist bereits im Standard-JVM- und OS-Truststore vorhanden.

Die Monitoring-Haltung wird damit: das cert-manager-Certificate-Objekt auf Ready=False nahe dem Erneuerungsdatum beobachten und auf ein fehlendes CertificateRenewed-Event achten. Wenn cert-manager den DNS-01-Challenge nicht abschließen kann, bedienen die Broker weiterhin das noch gültige alte Zertifikat bis es abläuft. Man hat ein 30-Tage-Fenster, um die Erneuerung zu beheben, bevor Clients etwas sehen.

Zum Vergleich: Zertifikatsrotation ohne Operator bedeutet neues Zertifikat generieren, Secret aktualisieren, betroffene Pods identifizieren, den Neustart koordinieren ohne Quorum-Verlust und sicherstellen, dass Clients wieder verbunden sind. Mit Strimzi ist diese Abfolge das Standardverhalten bei jedem Erneuerungszyklus.

Broker-Platzierung und der AZ-Deadlock

Kafka-Broker müssen auf separaten Nodes liegen. Ohne required Anti-Affinity landen alle drei Broker auf einem einzigen Node; fällt dieser aus, fällt der Cluster aus:

affinity:
  podAntiAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchExpressions:
            - key: strimzi.io/name
              operator: In
              values: ["my-cluster-kafka"]
        topologyKey: kubernetes.io/hostname

Anti-Affinity bringt ein subtileres Problem mit sich. Wenn Broker beim Cluster-Start zunächst auf einem Node landen, provisionieren ihre EBS-Volumes in der Availability Zone dieses Nodes. Wenn die Anti-Affinity-Regel dann einen Broker auf einen Node in einer anderen Zone verschiebt, kann das Volume nicht folgen. EBS-Volumes sind zonen-lokal. Der verschobene Broker bleibt im Pending-Zustand mit didn't match PersistentVolume's node affinity.

Auf einem frischen Cluster ist die Lösung einfach: das leere PVC des hängengebliebenen Brokers löschen. Strimzi legt es beim Reconcile neu an, und diesmal provisioniert das Volume in der Zone, in der der Broker tatsächlich geplant ist.

Für echte Fehlertoleranz sollten Broker über drei Availability Zones verteilt sein. Zwei Zonen sind ein Kostenkompromiss mit einem bekannten Failure-Mode: ein AZ-Ausfall kann zwei von drei Brokern treffen und das Quorum verlieren lassen.

Deklarative Benutzer und ACLs

KafkaUser-CRDs ermöglichen die deklarative Verwaltung von Authentifizierung und Autorisierung:

apiVersion: kafka.strimzi.io/v1
kind: KafkaUser
metadata:
  name: event-producer
spec:
  authentication:
    type: scram-sha-512
  authorization:
    type: simple
    acls:
      - resource:
          type: topic
          name: "*"
          patternType: literal
        operations: [Write, Create, Describe]

Der Operator erstellt SCRAM-Credentials, speichert sie in einem Secret, das nach dem User benannt ist, und hält die ACLs mit dem Live-Cluster synchron. Einen User zu widerrufen ist ein kubectl delete kafkauser. Der Operator entfernt Credentials und ACLs. Keine Broker-Konfiguration bearbeiten, kein Passwort manuell rotieren.

Ein Verhalten, das man kennen sollte: Ein KafkaUser umzubenennen erfordert das explizite Löschen des alten CR. kubectl apply mit einem neuen Namen erstellt einen neuen User; es entfernt den alten nicht. Der alte User arbeitet mit Live-Credentials weiter, bis man das alte CR löscht. Das ist korrektes Verhalten, kann aber bei Bereinigungen zu Ghost-Usern mit aktiven SCRAM-Credentials führen.

Was Operatoren nicht lösen

Operatoren reduzieren Toil; sie beseitigen ihn nicht.

Änderungen an SGInstanceProfile in StackGres propagieren nicht vollständig. Der Operator cached Sidecar-Resource-Requests und kann bei CPU- oder Memory-Änderungen einen requests > limits-Konflikt erzeugen. Die Lösung ist, das Profile zu löschen und neu zu erstellen, damit es sauber neu berechnet. Das ist bekanntes Verhalten, kein Bug, aber die Annahme “YAML aktualisieren reicht” bricht bei Resource-Änderungen.

PVC-Resize in StackGres erfordert eine bestimmte Reihenfolge: Cluster-Spec aktualisieren, bevor Pods gelöscht werden. Löscht man einen Pod vor der Spec-Aktualisierung, liest der In-Pod-Cluster-Controller die alte Größe und versucht, das PVC zu verkleinern. Das ergibt einen 422, und der Controller gerät in eine Crash-Schleife.

Strimzi’s Broker-Anti-Affinity kombiniert mit zonen-lokalen Volumes bedeutet, dass eine Zwei-AZ-Node-Gruppe keine echte Fehlertoleranz bietet. Man ist gegen Node-Ausfälle geschützt, nicht gegen Zone-Ausfälle.

Das Muster, das sich zeigt

Nach dem Einsatz beider Operatoren durch einen Umgebungsaufbau, Datenmigration und die ersten Wochen unter produktionsähnlichem Traffic ist das Muster klar. Operatoren rechtfertigen ihren Preis, nämlich echte CRD-Komplexität und eine neue Debugging-Schicht, genau bei den Operationen, die schwer einmalig richtig zu machen sind und unter Druck so gut wie unmöglich dauerhaft richtig zu halten.

Backup-Restore mit StackGres. Zertifikatsrotation mit Strimzi. Replica-Reinit nach einem schlechten Zustandsübergang. Das sind die Aufgaben, bei denen “jemand, der dieses System tiefgreifend versteht, hat einen Controller dafür geschrieben” mehr wert ist als jede noch so gut gemeinte Shell-Skript-Sammlung.

Der Setup-Vorteil ist real, aber überschaubar. Der Day-2-Vorteil akkumuliert sich mit jedem Vorfall, bei dem man genau weiß, was der Operator als Nächstes tun wird.

$ cat KUBERNETES .md
· 5 Min. Lesezeit

Die Agent-Kontrollebene: Omnigent auf Kubernetes

Databricks hat Omnigent als Open Source veröffentlicht: ein Meta-Harness, das beliebige Agent-Harnesses hinter einer einheitlichen API mit Policies, Sessions und persistentem State bündelt. Wer schon zustandsbehaftete Dienste auf Kubernetes betrieben hat, findet den Weg zum Self-Hosting kürzer als erwartet.

kubernetes devops platform-engineering ai-agents
$ cat KUBERNETES .md
· 5 Min. Lesezeit

Helm-Deploy erfolgreich. Config nie angewendet.

Eine grüne CI-Pipeline, während das Live-Gateway 404s zurückgibt, ist kein Testfehler. Es ist eine stille Reload-Lücke. Hier ist das Cross-Release-Checksum-Pattern, das sie in helmfile schließt.

kubernetes helm helmfile devops platform-engineering