Three corrections after rebasing onto main. 1. THE GUARD CAUGHT A REAL ONE ON ITS FIRST CONTACT WITH main. main had gained `CNPGPrimariesCoLocated` (platform/cnpg/alerts.yaml) pointing at /runbooks/cnpg-primary-spread, with no runbook. The check failed the rebase exactly as designed — 46 annotations, 35 names, 34 present — naming both the missing file AND the missing configMapGenerator entry. runbooks/cnpg-primary-spread.md now exists: what co-located primaries actually cost here (blast radius, not an outage, because instances: 3 + required anti-affinity), why the drift is uncorrectable-by-declaration (CNPG has no primary-placement field), the readiness gate that keeps the rule quiet during a real node outage, and the ~9s-per-database switchover rebalance, one cluster at a time. The 2026-09-07 outage in which all three primaries sat on one node is the reason the alert exists at all. 2. WHOLE-REPO SCOPE, NOT A HAND-WRITTEN STAGE. main's refactor replaced ci/l0.sh's per-check "UNCONDITIONAL" stages with a declaration: `conformance.home.k3s/scope: "whole-repo"`, unioned into every gate run by Stage 3b's `--include-scope`. My +38-line Stage 3f is removed (ci/l0.sh is now byte-identical to main) and the annotation is declared instead. Assertion (7) of the check was rewritten to match: it no longer asserts its own path appears in ci/l0.sh — it asserts the test DECLARES the scope and that ci/l0.sh still passes --include-scope, since neither half alone makes the check load-bearing. That matters here because ci/touched-requirements.sh maps platform/monitoring/* to "R-OBS-01 R-OBS-02", not R-OBS-04, and maps platform/kubevirt/* and runbooks/* to nothing at all. 3. A FACTUAL CORRECTION IN ALL 35 RUNBOOKS. Every page claimed `dev.sh` defaults its current-context to k3s-dev. main's `9b4744d fix(devcontainer): dev.sh defaults to prod` made that false, and a runbook that misstates which cluster a command talks to is worse than one that says nothing. The pages now say what is durably true: the default has already changed once, so naming `--context k3s-prod` is what makes a command mean the same thing next month, and the per-invocation banner is there to be read. The check's own failure message was corrected the same way. Re-verified after all three: check green at 374 assertions (46 annotations, 35/35 runbooks present, 35/35 projected), re-applied to prod, and re-probed in-cluster through the Gateway — node-down, keycloak-down, keycloak-client-secrets, cnpg-archiving, longhorn-faulted, vpn-down, node-io and cnpg-primary-spread all HTTP 200 with text/plain; charset=utf-8 and 8.9-12.1 KB of real body; /runbooks/ index 200; an unknown name 404s with a pointer rather than a blank page; and homepage still answers the apex `/`, confirming Gateway API longest-path precedence. Full merge gate: PASS (spec-immutability, lint, l0, render-golden, l1 all green; prom-rules SKIPPED for want of promtool in the image; L2/L3 SKIPPED, no cluster). Co-Authored-By: Claude Opus 5 <[email protected]>
11 KiB
cnpg-primary-spread — Two or more CNPG primaries share one node
Alert: CNPGPrimariesCoLocated (warning, 30m)
File: platform/cnpg/alerts.yaml, group platform-health.cnpg
Expression: count by (node) (<ready *-rw endpoints joined to cnpg_collector_up>) > 1
and gated on every node being Ready:
count(kube_node_status_condition{condition="Ready",status="true"} == 1) == count(kube_node_info)
What this means here
More than one CloudNativePG primary has been running on the same node for 30 minutes, while all
three nodes are Ready. There are exactly three CNPG Clusters on this platform — keycloak/keycloak-db,
nextcloud-db/nextcloud-db and semaphore-db/semaphore-db — so on a 3-node cluster the healthy shape is
one primary per node, and this alert says that shape has drifted.
This is BLAST RADIUS, not an outage. Each Cluster has instances: 3 with one pod per node
(podAntiAffinityType: required) and one synchronous standby (method: any, number: 1,
dataDurability: preferred), so every one of them survives losing the shared node — you just get two or
three simultaneous PostgreSQL failovers instead of one. Nothing is broken right now, which is why it is a
warning with a long for.
Why it matters anyway: this exact drift has already caused an outage. All three Clusters used to run
instances: 1 and all three primaries had drifted onto one node, and on 2026-09-07 that node took SSO,
Nextcloud and Semaphore down together. instances: 3 means the same drift no longer causes an outage —
but if keycloak-db's primary is co-located and that node dies, the SSO failover happens at the same
moment as two others, on nodes that are also absorbing Longhorn replica rebuilds.
Why the drift happens and cannot be declared away: CNPG has no declarative primary-placement field. The primary lands wherever the last promotion put it — a node reboot, a switchover, an operator upgrade. So this is avoidable drift that must be corrected, not prevented, and the alert re-arms itself so it will be reported again rather than silently persisting.
Why the node-readiness gate is in the expression. During a real node outage, primaries legitimately
pile onto the survivors and there is nothing to do about it; alerting then would be noise at the worst
moment. The and on () clause suppresses the rule unless every node is Ready, and re-arms when the node
returns.
This rule was proven in both directions on live prod data (2026-09-13): the expression returned
{node="k3s-prod-1"} => 3 before the rebalance and a throwaway twin with for: 1m was observed going
pending at +70s and firing at +130s; after two switchovers the same expression returned an empty
vector while the inner join still resolved all three primaries — quiet because the condition is false,
not because the query went blind. It is also unit-tested in alerts.promtool-test.yaml
(run ./run-alert-tests.sh) with four cases, mutation-checked so that deleting the cnpg_collector_up
join, the readiness gate, or shortening for: makes a test FAIL.
Running the commands in this runbook
kubectl does not work on the operator workstation. The toolchain lives only in the pinned dev
container, and the host's k3s-oidc context hangs on a browser prompt once its token expires —
that hang is not the cluster being down. Every command below runs as:
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes'
Always pass --context k3s-prod explicitly. dev.sh merges BOTH clusters into one kubeconfig and
picks a current-context per invocation, and that default has already changed once — it was k3s-dev
until dev was decommissioned on 2026-09-13, and is k3s-prod now. A command that names its context means
the same thing next month as it does today; one that relies on the default does not, and the failure is
silent (it answers confidently about the other cluster). CLUSTER=prod infra/devcontainer/dev.sh run kubectl ... selects it explicitly, and every invocation prints a banner naming the cluster it is about
to talk to — read it. Do not run two dev.sh invocations at once; they share one container and
interfere.
USE FULLY-QUALIFIED RESOURCE NAMES, ALWAYS. Three CRDs on this cluster register the kind Backup:
longhorn.io/v1beta2, postgresql.cnpg.io/v1 and velero.io/v1. The bare short name resolves to
Longhorn's, which holds zero objects — so kubectl get backup -A prints "No resources found" on
a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09-13. Reading that as
"the backups are gone" and acting on it turns a false negative into real data loss. Write
backups.velero.io, backups.postgresql.cnpg.io, clusters.postgresql.cnpg.io,
settings.longhorn.io, volumes.longhorn.io, nodes.longhorn.io.
Mutating kubectl (apply, patch, annotate, rollout) and flux reconcile are blocked for
agents here: every durable change goes through git (git push origin main is the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
First commands
Where the primaries are — the one command this alert is about:
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod get pods -A -l cnpg.io/instanceRole=primary \
-o custom-columns=NS:.metadata.namespace,POD:.metadata.name,NODE:.spec.nodeName'
Are all three Clusters actually healthy enough to move one? (Fully-qualified:
clusters.postgresql.cnpg.io — cluster is an ambiguous short name.)
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod get clusters.postgresql.cnpg.io -A \
-o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,INSTANCES:.status.instances,READY:.status.readyInstances,PRIMARY:.status.currentPrimary,PHASE:.status.phase'
Is every node Ready (the gate the rule itself applies)?
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes -o wide'
Is there storage or I/O trouble that makes a switchover a bad idea right now?
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n longhorn-system get volumes.longhorn.io \
-o custom-columns=NAME:.metadata.name,ROBUST:.status.robustness | grep -v healthy'
ssh k3s-prod-1 'cat /proc/pressure/io'
And confirm DR is intact before you deliberately move a primary — the primary owns the backup
catalogue, and fully-qualified names matter: kubectl get backup -A prints "No resources found" on
this cluster even with 27 CNPG backups present, because three CRDs register the kind Backup and the
short name resolves to Longhorn's (zero objects):
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod get backups.postgresql.cnpg.io -A | tail -10; echo; \
kubectl --context k3s-prod get clusters.postgresql.cnpg.io -A \
-o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,ARCHIVING:.status.conditions[?\(@.type==\"ContinuousArchiving\"\)].status,FIRSTPITR:.status.firstRecoverabilityPoint'
Likely causes, most likely first
1. A node rebooted and the primaries never moved back
The dominant cause. Promotions during the reboot put two primaries on a survivor, and CNPG has no reason to move them back afterwards. Remediation: a deliberate switchover, below.
2. A rolling operator or Postgres upgrade
primaryUpdateMethod: switchover is set deliberately (the CRD default restart is a
delete-and-recreate of the primary, i.e. tens of seconds with no Postgres — an SSO outage on
keycloak-db). Switchovers during an upgrade are correct behaviour and leave the primaries wherever
they landed.
3. One node was cordoned or unschedulable for a while
Same mechanism. Note that podAntiAffinityType: required means an instance stays Pending until a
node returns rather than co-locating — that is the accepted cost of the 2026-09-07 fix, and it is why
the instances stay spread even when the primaries do not.
4. A Longhorn or I/O event caused a health-check failover
A primary whose storage stalled gets demoted. Cross-read /runbooks/node-io and
/runbooks/longhorn-degraded — if that is why, fix the node before rebalancing.
5. The alert is firing during a genuine node outage
It should not be able to: the readiness gate suppresses it. If it fires anyway with a node NotReady, the gate is broken — that is a monitoring bug worth fixing, since a rule that cannot stop is fatigue.
Remediation — move one primary, deliberately
The cnpg plugin is NOT in the devcontainer image; fetch the kubectl-cnpg release matching the
operator version (CNPG 1.30.0 here) before you start.
kubectl cnpg promote -n <namespace> <cluster> <instance-on-an-idle-node>
Do ONE CLUSTER AT A TIME and wait for readyInstances: 3 before the next. A switchover interrupts
writes to that database for about 9 seconds — measured on both clusters moved on 2026-09-13. Nine
seconds of keycloak-db unavailability is a brief SSO interruption; doing two at once is an avoidable
compound one.
Pick the target by reading the primaries table above and choosing an instance that already sits on a node holding no other primary. Then re-run the table to confirm the new distribution, and let the alert clear on its own.
When to escalate or stop
- Do not rebalance during any other incident. A switchover is a small, deliberate write interruption,
and it is the wrong thing to add to a node that is already under I/O pressure or to a cluster with
degraded Longhorn volumes. This alert has a 30-minute
forprecisely because it is never urgent. - Never switch over a Cluster whose
readyInstancesis below 3. You would be promoting into a cluster that has no standby left to fall back on. - Never change
dataDurabilitytorequiredwhile thinking about durability here: it is a strict quorum, and one degraded standby would then block writes entirely on the SSO database. - Do not
kubectl delete podthe primary to force a failover. That isrestartsemantics — the thingprimaryUpdateMethod: switchoverexists to avoid. - Check
ContinuousArchivingandfirstRecoverabilityPointfirst (/runbooks/cnpg-archiving). If archiving is broken, that is a real countdown to a disk-full PANIC and it outranks blast-radius tidying completely. - Escalate to the operator for the switchover itself if the affected Cluster is
keycloak-db: the brief write interruption is user-visible across SSO, kubectl OIDC, Grafana, Headlamp and Gitea.
Source of truth: runbooks/cnpg-primary-spread.md. Index: /runbooks/
Records: platform/cnpg/alerts.yaml (the rule's own both-directions proof on live prod data and its
promtool tests); commits f338e91 (instances 3), e8d8601 (required anti-affinity + switchover),
cac413e (synchronous replication, dataDurability: preferred); the 2026-09-07 single-node SSO outage.