Files
home-network-k3s-impl/platform/keycloak/postgres-cluster.yaml
T
jakman85 e8d8601ca6
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
feat(cnpg): required pod anti-affinity + switchover primary rolls
`instances: 3` alone does not keep the three instances apart. All three
Clusters used CNPG's default `podAntiAffinityType: preferred`, which is a
scheduler HINT — two instances may share a node whenever that scores better,
and they WILL on the path that matters: when CNPG recreates an instance after
a node loss, the surviving nodes are the only candidates and one of them
already runs an instance. `preferred` is how a 3-instance cluster decays back
into the single-node failure domain the previous commit exists to remove.

`required` makes it a scheduling constraint. On a 3-node cluster with no
spare, the cost is that an instance whose node is gone stays Pending until a
node returns. That is correct: a replacement squeezed onto an already-occupied
node dies with that node while making the cluster LOOK three-way replicated.
2 of 3 instances still serve and CNPG still promotes.

ALSO SETS primaryUpdateMethod: switchover. This is not cosmetic. Changing the
affinity changes the instance pod spec, so CNPG performs a rolling update, and
the CRD default for primaryUpdateMethod is `restart` (verified against the
live CNPG 1.30.0 CRD on both clusters) — a delete-and-recreate of the PRIMARY
pod, i.e. tens of seconds of no Postgres. On keycloak-db that is an SSO
outage, which also means kubectl OIDC, Grafana, Headlamp, Gitea and
forward-auth. With standbys now present, `switchover` promotes an
already-updated streaming replica instead, reducing the roll to a connection
reset. Known constraint documented in the manifest: under `switchover` the
operator rejects an update that changes imageName and a postgresql parameter
in the same reconcile.

Verified satisfiable before merge (read-only): k3s-prod and k3s-dev each have
3 Ready, schedulable, untainted nodes, and every pgdata PVC already has a
Longhorn replica on all three (replica-soft-anti-affinity=false), so any
instance can attach from any node.

DEV IMPACT: identical, and dev can absorb it. Longhorn dev nodes are 124.9 GiB
each with 152.0 GiB already scheduled under a 200% over-provisioning ceiling
(249.8 GiB); the six new 5Gi volumes add 30 GiB scheduled per node -> 182.0,
still inside the ceiling, and only ~600 MiB per volume is really consumed.
Prod is far looser: 239 -> 269 GiB scheduled against a 829 GiB ceiling.

THIS IS THE DISRUPTIVE COMMIT of the three. Land it on its own reconcile,
after the scale-out has produced two Ready standbys per Cluster, so the blast
radius is one clean switchover per database rather than a scale-out and a roll
at once. Expect a brief SSO interruption for keycloak-db.

Verified: yamllint clean; both tiers render the new fields; field names,
types and enum values checked against the live clusters.postgresql.cnpg.io
CRD (CNPG 1.30.0, identical on prod and dev).

Co-Authored-By: Claude Opus 5 <[email protected]>
(cherry picked from commit e1d364238d73e6ee99c9a50108fe8b4e09481a7e)
2026-09-07 23:48:32 -05:00

155 lines
9.1 KiB
YAML

---
# Keycloak's PostgreSQL — a CloudNativePG Cluster (CR of the CNPG CRDs installed by platform/cnpg).
# CARRIED BY platform-config (dependsOn platform) so the postgresql.cnpg.io CRDs exist first (F-MON1).
#
# DR-critical (R-IDP-05): storage on the 3-replica `longhorn` StorageClass (ADR-0003) — the data
# survives node loss. CNPG creates the keycloak-db-app Secret (keys username/password) that the
# Keycloak CR consumes, plus the keycloak-db-rw/-ro/-r Services. Keycloak is stateless and rebuilds
# from this DB + the git-managed realm import (docs/15).
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: keycloak-db
namespace: keycloak
spec:
# THREE instances — one primary + two hot standbys, one per node (2026-09-07).
#
# WAS `instances: 1`, on ADR-0014's "single replica with DB on Longhorn + backups is acceptable".
# Longhorn's 3-way replication saves the DATA when a node dies; it does NOT save the SERVICE. With
# one instance, losing the node means rescheduling the pod, re-attaching the volume and replaying
# WAL before Postgres will accept a connection again — minutes, and only if scheduling cooperates.
#
# WHY NOW (the 2026-09-07 incident): all three CNPG primaries — keycloak-db, nextcloud-db and
# semaphore-db — were co-resident on k3s-prod-2, because with `instances: 1` nothing in the spec
# ever said they must not be. One node took out SSO, Nextcloud and Semaphore together, and with
# SSO went kubectl OIDC, Grafana, Headlamp, Gitea and forward-auth. Three instances give this
# Cluster something to fail OVER to: CNPG promotes a streaming standby in seconds and repoints the
# -rw Service, with no volume re-attach and no restore. The `affinity.podAntiAffinityType:
# required` below is what makes the three land on three DIFFERENT nodes.
#
# UNBLOCKED BY WORKING BACKUPS, NOT A SUBSTITUTE FOR THEM. Replicas copy corruption and deletions
# faithfully; only the Barman archive below is a backup. Adding standbys while archiving was
# broken would have multiplied the disk-full clock described further down, so this change waited
# for all three Clusters to report ContinuousArchiving=True with a real firstRecoverabilityPoint.
#
# COST: two more 5Gi PVCs here (Longhorn thin-provisions; the live pgdata is ~600 MiB), ~30 GiB
# PROVISIONED across all three DBs and well under 3 GiB actually consumed. Longhorn allows 200%
# over-provisioning on both clusters and both have headroom.
#
# APPLYING THIS IS NON-DISRUPTIVE: CNPG scales out by creating -2 then -3 sequentially, each
# seeded by pg_basebackup from the RUNNING primary. The primary is not restarted and clients are
# not disconnected. (The anti-affinity change is a separate commit precisely because that one DOES
# cause a switchover — see it for the blast radius.)
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.4
# HARD anti-affinity — the three instances MUST land on three DIFFERENT nodes (2026-09-07).
#
# CNPG's default is `preferred`, which is only a scheduler HINT: the scheduler may put two
# instances on one node whenever that scores better, and it WILL do so on the path that matters —
# when CNPG recreates an instance after losing a node, the surviving nodes are the only candidates
# and one of them already runs an instance. So `preferred` is precisely how `instances: 3` decays
# back into the single-node failure domain this whole change exists to remove; nothing pins the
# good layout in place. `required` makes it a scheduling CONSTRAINT instead of a preference.
#
# THE TRADE, STATED PLAINLY: on a 3-node cluster with no spare node, an instance whose node is
# gone stays Pending until a node is available. That is the CORRECT behaviour here. A replacement
# squeezed onto a node that already hosts an instance buys nothing — both die with that node —
# while making the cluster LOOK three-way replicated. Availability is unaffected either way:
# 2 of 3 instances still serve and CNPG promotes one of them.
#
# Satisfiable on both clusters: prod and dev each have 3 schedulable, untainted nodes (checked
# read-only before merge). topologyKey defaults to kubernetes.io/hostname — the failure domain
# that matters here. enablePodAntiAffinity defaults to true; stated explicitly so this block can
# be read without knowing the default.
affinity:
enablePodAntiAffinity: true
podAntiAffinityType: required
# Roll the PRIMARY by SWITCHOVER, not by restarting it in place. CNPG's default for
# primaryUpdateMethod is `restart`, which deletes and recreates the primary pod — tens of seconds
# with no Postgres serving, and on THIS database that is an SSO outage. With standbys present
# (instances: 3 above) a switchover promotes an already-updated, already-streaming replica and
# demotes the old primary, so the interruption is a connection reset rather than a cold start.
# This is the field that makes the anti-affinity roll below a switchover rather than an outage.
#
# KNOWN CONSTRAINT: under `switchover` the operator REJECTS an update that changes `imageName`
# AND a postgresql parameter in the same reconcile. Renovate bumping the image alone is fine;
# never combine an image bump with a postgresql.parameters change in one commit.
primaryUpdateMethod: switchover
storage:
storageClass: longhorn
size: 5Gi
bootstrap:
initdb:
database: keycloak
owner: keycloak
# Emit Postgres metrics so Prometheus scrapes DB health (R-OBS-01). The CNPG operator creates the
# PodMonitor at runtime (CRD already present from Phase-4 monitoring) — no kustomize CRD-ordering hazard.
monitoring:
enablePodMonitor: true
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
# --- Off-cluster DR backup (R-IDP-05) — CNPG-native Barman → object storage ---
# Continuous WAL archiving + periodic base backups give proper Postgres PITR (superior to a Velero
# PVC snapshot for a live DB). CNPG appends `serverName` (defaults to the Cluster name) to
# destinationPath, so objects land under ${CNPG_BACKUP_PATH}/keycloak-db/ — dev
# s3://cnpg/keycloak-db/ on the on-Proxmox MinIO, prod s3://k3s-prod-backup/barman/keycloak-db/ on
# Cloudflare R2. Both endpointURL and destinationPath are cluster-vars knobs (R-ENV-02),
# substituted by the `platform-config` Kustomization (this file sits under platform/keycloak/ but
# platform-config is the only tier that lists it as a resource — see its kustomization.yaml).
# Credentials: the scoped key from the in-namespace SOPS Secret keycloak-db-backup-credentials
# (a Pod only reads same-ns Secrets).
#
# *** archive_mode IS ON, SO A FAILING ARCHIVE COMMAND IS A DISK-FULL CLOCK, NOT A DEGRADED
# BACKUP. *** Postgres will not recycle a WAL segment it has not successfully archived, so
# whenever these two values are wrong the pgdata PVC fills monotonically until the primary
# PANICs. This is not hypothetical: prod ran from bootstrap to 2026-09-07 with
# `endpointURL: TBD-PROD-S3-BACKUP-ENDPOINT` (barman-cloud-wal-archive exit 4 on every segment)
# and had burned ~579 MiB of a 5 GiB PVC on the way to taking SSO — and with it kubectl OIDC,
# Grafana, Headlamp, Gitea and forward-auth — down with it. Never leave a placeholder here, and
# treat `ContinuousArchiving=False` on this Cluster as an availability incident, not a backup one.
backup:
retentionPolicy: "30d" # keep 30 days of base backups + WAL (PITR window); prunes older
barmanObjectStore:
destinationPath: ${CNPG_BACKUP_PATH}
endpointURL: ${S3_BACKUP_ENDPOINT}
# PER-ENVIRONMENT credential (2026-09-07): dev reads keycloak-db-backup-credentials (MinIO),
# prod reads keycloak-db-backup-credentials-prod (Cloudflare R2). Both Secrets are applied to
# both clusters by platform/keycloak/kustomization.yaml; ${BACKUP_CREDENTIALS_SUFFIX} (dev ""
# | prod "-prod") picks one. Before the split these were ONE shared Secret, so writing prod's
# R2 token into it would have left dev signing MinIO requests with an R2 key — a 403 on every
# archive command, which under archive_mode=on is the disk-full clock described above, not a
# missing backup.
s3Credentials:
accessKeyId:
name: keycloak-db-backup-credentials${BACKUP_CREDENTIALS_SUFFIX}
key: ACCESS_KEY_ID
secretAccessKey:
name: keycloak-db-backup-credentials${BACKUP_CREDENTIALS_SUFFIX}
key: ACCESS_SECRET_KEY
wal:
compression: gzip
maxParallel: 2
data:
compression: gzip
---
# Daily base backup of keycloak-db (WAL is archived continuously by the barmanObjectStore above).
# CNPG cron is 6-field (sec min hour dom mon dow). immediate:true takes a first base backup as soon
# as the schedule is created so the PITR chain has a starting point. Staggered 01:00 (nextcloud 02:00,
# semaphore 04:00, Velero 03:00) to avoid concurrent base backups hammering MinIO.
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: keycloak-db-daily
namespace: keycloak
spec:
immediate: true
schedule: "0 0 1 * * *"
backupOwnerReference: self
cluster:
name: keycloak-db
method: barmanObjectStore