merge: CNPG DR alerts scoped to the cluster, and the hint that lied
CNPGNoRecoverabilityPoint fired permanently on all six CNPG replicas because only the primary owns the backup catalogue; CNPGNoRecentBaseBackup had the same latent defect, held back only by a > 0 guard. Both now aggregate pod away and key on the cluster. The remediation text also told the on-call to run 'kubectl get backup', which reports no resources here.
This commit is contained in:
commit
501d587541
1 file changed
+92
-7
@@ -48,6 +48,33 @@
|
||||
# cnpg_collector_pg_wal{value="count"} vs {value="max"} prod: 17 vs 64 dev: 36 vs 64 (neither fires yet)
|
||||
# cnpg_last_error prod+dev: nextcloud-db = 1
|
||||
#
|
||||
# AGGREGATION SCOPE — WHICH RULES MAY KEY ON `pod`, AND WHY (incident, 2026-09-12). The first cut of
|
||||
# this file grouped EVERY rule `by (namespace, pod)`. That is correct only for metrics every instance
|
||||
# reports independently, and WRONG for the two backup-catalogue metrics, which ONLY THE PRIMARY
|
||||
# populates. Verified live on prod 2026-09-12 (`cnpg_collector_first_recoverability_point`, raw):
|
||||
# keycloak-db-2 = 1788764922 nextcloud-db-2 = 1788764955 semaphore-db-2 = 1788764954 <- PRIMARIES
|
||||
# keycloak-db-1/-3, nextcloud-db-1/-3, semaphore-db-1/-3 = 0 <- REPLICAS
|
||||
# Since the Clusters went to `instances: 3`, `CNPGNoRecoverabilityPoint` was therefore PERMANENTLY
|
||||
# FIRING on all six replicas while all three Clusters reported firstRecoverabilityPoint=2026-09-07,
|
||||
# lastSuccessfulBackup within hours, ContinuousArchiving=True and LastBackupSucceeded=True. Backups
|
||||
# were fine; the alert was noise, and noise on a DR alarm is worse than no alarm at all — a REAL loss
|
||||
# of recoverability would have arrived as a seventh identical email nobody reads.
|
||||
#
|
||||
# THE RULE OF THUMB, applied as an audit to every rule below:
|
||||
# * `== 0` / staleness shapes ("nothing reports a value") MUST aggregate `pod` AWAY, or every pod
|
||||
# that structurally never reports the value fires forever.
|
||||
# * `> 0` shapes ("some pod reports a bad value") are SAFE per-pod: a pod sitting at 0 simply does
|
||||
# not fire, and keeping `pod` names the instance to go look at.
|
||||
#
|
||||
# THE GROUPING LABEL IS `job`, NOT `namespace`. The metric carries NO `cnpg.io/cluster` label — its
|
||||
# full label set on prod is {container, endpoint, instance, job, namespace, pod} — but the CNPG
|
||||
# operator creates one PodMonitor per Cluster, named after the Cluster, in the Cluster's namespace,
|
||||
# so prometheus-operator derives `job="<namespace>/<cluster>"`: `keycloak/keycloak-db`,
|
||||
# `nextcloud-db/nextcloud-db`, `semaphore-db/semaphore-db`. That identifies the CNPG CLUSTER
|
||||
# uniquely. Grouping by `namespace` alone happens to work today (one Cluster per namespace, verified)
|
||||
# but would let two Clusters in one namespace MASK each other — the healthy one's non-zero value would
|
||||
# satisfy `max` for both. `job` cannot do that, so `job` is what the DR rules group by.
|
||||
#
|
||||
# SEVERITIES: every rule here is `warning` (-> the email-warning receiver). This is deliberate.
|
||||
# A database heading for disk-full deserves a warning with weeks of lead time, and this repo already
|
||||
# has alert fatigue from a permanently-firing `critical` (see the VPNTunnelDown post-mortem in
|
||||
@@ -82,6 +109,12 @@ spec:
|
||||
# 24h; prod has never been below 3 (min_over_time 24h: keycloak 9, nextcloud 5, semaphore 3).
|
||||
# A count threshold like `> 5` would have delayed semaphore-db's alert indefinitely — its
|
||||
# backlog sits at 3 because it writes almost nothing.
|
||||
#
|
||||
# SCOPE AUDIT (2026-09-12): keeps `pod`, deliberately. Every instance has its own pg_wal on its
|
||||
# own PVC and every instance reports this metric (verified: all 9 prod pods publish
|
||||
# `{value="ready"}` = 0 today), and this is a `> 0` shape — a replica sitting at 0 never fires,
|
||||
# so there is no permanent-firing hazard and no masking (`> 0` fires if ANY pod is bad). `pod`
|
||||
# earns its place here: it names the instance whose archiver is stuck.
|
||||
- alert: CNPGWALArchiveBacklog
|
||||
expr: |
|
||||
max by (namespace, pod) (cnpg_collector_pg_wal_archive_status{value="ready"}) > 0
|
||||
@@ -102,15 +135,37 @@ spec:
|
||||
# `for: 6h` because a legitimately fresh Cluster reads 0 until its first base backup lands
|
||||
# (ScheduledBackup immediate:true, but MinIO/bucket setup can lag) — 6h keeps a normal cold
|
||||
# start quiet while still catching a bootstrap that never produced a recoverability point.
|
||||
#
|
||||
# SCOPE — `by (namespace, job)`, NOT `by (namespace, pod)`. THIS IS THE FIX FOR THE 2026-09-12
|
||||
# permanently-firing incident (see the AGGREGATION SCOPE block in the header). The recoverability
|
||||
# point is a property of the CLUSTER's backup catalogue, which only the PRIMARY reads; a replica
|
||||
# reports a hard 0 forever. Grouped by `pod`, each replica was evaluated on its own and this
|
||||
# `== 0` fired permanently on all six of them. Aggregating `pod` away makes `max` take the
|
||||
# PRIMARY's non-zero value as the cluster's answer, so the alert says what its own summary always
|
||||
# claimed to say — "this Postgres CLUSTER has no recoverability point" — and fires only when NO
|
||||
# instance of that Cluster reports one.
|
||||
#
|
||||
# PROVEN LIVE, read-only, prod, 2026-09-12 (promtool query instant):
|
||||
# OLD max by (namespace, pod) (...) == 0 -> 6 series: keycloak-db-1/-3, nextcloud-db-1/-3,
|
||||
# semaphore-db-1/-3 (and 6 ALERTS firing)
|
||||
# NEW max by (namespace, job) (...) == 0 -> EMPTY
|
||||
# and it is not merely vacuous — forcing the metric to 0 for one cluster's primary only
|
||||
# (`* on(pod) group_left() ...{pod="keycloak-db-2"}`) yields exactly one series,
|
||||
# {job="keycloak/keycloak-db"}, so a genuine single-cluster loss of DR still alerts, per cluster.
|
||||
#
|
||||
# A switchover cannot flap this: while the primary restarts, `max` briefly sees only replicas' 0,
|
||||
# but `for: 6h` requires the condition at EVERY evaluation for six hours and a promotion takes
|
||||
# seconds. Annotations key on `job` (= "<namespace>/<cluster>") because `pod` no longer exists on
|
||||
# these series — a `{{ $labels.pod }}` left behind here would render as an empty string.
|
||||
- alert: CNPGNoRecoverabilityPoint
|
||||
expr: |
|
||||
max by (namespace, pod) (cnpg_collector_first_recoverability_point) == 0
|
||||
max by (namespace, job) (cnpg_collector_first_recoverability_point) == 0
|
||||
for: 6h
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "CNPG cluster has NO recoverability point: {{ $labels.namespace }}/{{ $labels.pod }}"
|
||||
description: "PostgreSQL instance {{ $labels.namespace }}/{{ $labels.pod }} reports firstRecoverabilityPoint = null for 6h — there is NO point-in-time this database can be restored to. Off-cluster DR for this database does not exist (R-IDP-05 / R-STATE-03). Usually the barmanObjectStore credentials/endpoint/path are wrong; check `kubectl get cluster -n {{ $labels.namespace }} -o yaml` for the ContinuousArchiving condition."
|
||||
summary: "CNPG cluster has NO recoverability point: {{ $labels.job }}"
|
||||
description: "PostgreSQL cluster {{ $labels.job }} (namespace {{ $labels.namespace }}) reports firstRecoverabilityPoint = null on EVERY instance for 6h — there is NO point-in-time this database can be restored to. Off-cluster DR for this database does not exist (R-IDP-05 / R-STATE-03). This is a per-CLUSTER statement: the primary owns the backup catalogue, so a healthy cluster clears it even though its replicas report 0. Usually the barmanObjectStore credentials/endpoint/path are wrong; check `kubectl get cluster -n {{ $labels.namespace }} -o yaml` for the ContinuousArchiving condition."
|
||||
runbook_url: "https://${BASE_DOMAIN}/runbooks/cnpg-archiving"
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# The daily ScheduledBackup silently stopped. Nothing else covers this: the Velero rules in
|
||||
@@ -119,16 +174,31 @@ spec:
|
||||
# backup). Guarded with `> 0` so a cluster that has NEVER had a backup is reported by
|
||||
# CNPGNoRecoverabilityPoint above and does not double-notify here (time()-0 would otherwise be
|
||||
# ~57 years and always true). 36h = one missed daily run plus slack.
|
||||
#
|
||||
# SCOPE — `by (namespace, job)`, fixed alongside CNPGNoRecoverabilityPoint (2026-09-12).
|
||||
# `cnpg_collector_last_available_backup_timestamp` has the SAME primary-only defect, measured on
|
||||
# prod the same day: keycloak-db-2 = 3.1h old, nextcloud-db-2 = 2.1h, semaphore-db-2 = 0.1h, and
|
||||
# ALL SIX REPLICAS = 0, i.e. `time() - 0` = 497020h ≈ 56.7 YEARS. Unlike the rule above this one
|
||||
# was not firing, because the `> 0` guard was load-bearing — it was the single thing standing
|
||||
# between this rule and six more permanent false positives, which is far too thin a margin for a
|
||||
# DR alarm. Aggregating `pod` away removes the dependence: `max` picks the PRIMARY's timestamp as
|
||||
# the cluster's answer (a demoted primary drops to 0 and can never win the `max`, and a fresher
|
||||
# backup always beats a staler one), the `> 0` guard then means exactly "this cluster has had at
|
||||
# least one backup", and the result is a real per-cluster staleness statement.
|
||||
#
|
||||
# PROVEN LIVE, prod, 2026-09-12: OLD -> empty, NEW -> empty (so the fix changes no live verdict),
|
||||
# while the per-CLUSTER ages the new aggregation actually evaluates are keycloak 3.09h /
|
||||
# nextcloud 2.09h / semaphore 0.09h — the primaries' real values, all far inside 36h.
|
||||
- alert: CNPGNoRecentBaseBackup
|
||||
expr: |
|
||||
(time() - max by (namespace, pod) (cnpg_collector_last_available_backup_timestamp)) > (36 * 3600)
|
||||
and max by (namespace, pod) (cnpg_collector_last_available_backup_timestamp) > 0
|
||||
(time() - max by (namespace, job) (cnpg_collector_last_available_backup_timestamp)) > (36 * 3600)
|
||||
and max by (namespace, job) (cnpg_collector_last_available_backup_timestamp) > 0
|
||||
for: 2h
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "No recent CNPG base backup: {{ $labels.namespace }}/{{ $labels.pod }}"
|
||||
description: "The most recent completed base backup for {{ $labels.namespace }}/{{ $labels.pod }} is over 36h old — the daily ScheduledBackup has stopped producing backups, so the PITR window is ageing out against the 30d retentionPolicy. Check `kubectl get backup -n {{ $labels.namespace }}`."
|
||||
summary: "No recent CNPG base backup: {{ $labels.job }}"
|
||||
description: "The most recent completed base backup for PostgreSQL cluster {{ $labels.job }} (namespace {{ $labels.namespace }}) is over 36h old — the daily ScheduledBackup has stopped producing backups, so the PITR window is ageing out against the 30d retentionPolicy. Per-CLUSTER: the newest timestamp across the cluster's instances is used, which is the primary's. Check `kubectl get backups.postgresql.cnpg.io -n {{ $labels.namespace }}` — the FULLY-QUALIFIED name is required: longhorn.io, postgresql.cnpg.io and velero.io all register kind Backup, and bare `kubectl get backup` resolves to Longhorn's (empty) set and reports 'No resources found' while CNPG backups exist."
|
||||
runbook_url: "https://${BASE_DOMAIN}/runbooks/cnpg-backup-missed"
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# WAL-VOLUME-SPECIFIC ESCALATION. pg_wal holds more segments than max_wal_size allows for,
|
||||
@@ -146,6 +216,12 @@ spec:
|
||||
# any Cluster), which is why the PVC-fill rules cover it at all; the CNPG-native
|
||||
# `cnpg_collector_pg_wal{value="volume_size"}` is NaN on both clusters for that reason and is
|
||||
# therefore NOT usable as a signal.
|
||||
#
|
||||
# SCOPE AUDIT (2026-09-12): keeps `pod`, deliberately — and here it is REQUIRED. Every instance
|
||||
# reports its own count/max (verified on all 9 prod pods: count 16-37 against max 64), pg_wal is
|
||||
# per-instance on a per-instance PVC, and the comparison only means anything pod-against-itself:
|
||||
# aggregating `pod` away would compare one instance's count with another's max. `>` is also a
|
||||
# per-pod shape, so no instance can sit permanently true.
|
||||
- alert: CNPGWALVolumeNotRecycling
|
||||
expr: |
|
||||
max by (namespace, pod) (cnpg_collector_pg_wal{value="count"})
|
||||
@@ -170,6 +246,15 @@ spec:
|
||||
# Remedy: grant the exporter role CONNECT on the application database. `for: 6h` keeps a pod
|
||||
# restart or a brief connection blip quiet; severity stays `warning` so it never becomes the
|
||||
# permanently-firing `critical` this repo has been burned by.
|
||||
#
|
||||
# SCOPE AUDIT (2026-09-12): keeps `pod`, UNCHANGED — this rule's meaning and firing set are
|
||||
# deliberately left exactly as they were. `cnpg_last_error` is genuinely per-instance: each
|
||||
# instance's own exporter reports its own SQL-collection health, and prod proves the metric
|
||||
# discriminates rather than tracking role — all THREE nextcloud-db pods (primary -2 included)
|
||||
# report 1 while all six keycloak/semaphore pods report 0. So there is no primary-only defect to
|
||||
# fix, and per-pod is the right granularity: the remedy is a GRANT applied per database, and you
|
||||
# want to see it clear on every instance. It is a TRUE POSITIVE right now (nextcloud-db, the
|
||||
# 42501 CONNECT grant) and is owned by separate work; nothing here silences or rescopes it.
|
||||
- alert: CNPGMetricsCollectorErroring
|
||||
expr: |
|
||||
max by (namespace, pod) (cnpg_last_error) == 1
|
||||
|
||||
Reference in new issue
Block a user