Skip to main content

Alerting Gap Analysis & Fixes — 9 Gaps Closed

Principle: alert only on actionable issues.

An alert that can't be acted on is noise. Noise trains people to ignore alerts. Ignored alerts miss real incidents.

Every gap below was evaluated against that principle: does the signal translate into a concrete human action? If yes, it gets a receiver and a description that says what to do. If no (k3s structural impossibilities, benign OCC retries), it is either silenced or excluded by label.


Gap inventory​

#SeverityGapFiles changedStatus
1CriticalWatchdog → external dead-man's switchkube-prometheus-stack-values.yaml✅
2CriticalGmail delivery path via Amazon SESSES config, Alertmanager global SMTP✅
3HighNo Slack receiver — SMTP outage silences all alertskube-prometheus-stack-values.yaml, 19-alertmanager-slack-externalsecret.yaml✅
4HighLonghorn not scraped by Prometheus at all20-longhorn-servicemonitor.yaml, 21-longhorn-alerts.yaml✅
5MediumCPU/Memory thresholds wrong (kps default 90% vs required 80%/85%)22-node-resource-alerts.yaml✅
6MediumKubeAPIDown self-defeating without external checkkube-prometheus-stack-values.yaml✅
7LowInfrastructure alerts have no runbook or recovery steps04-infrastructure-alerts.yaml✅
8LowKubeControllerManagerDown/KubeSchedulerDown wrongly silencedkube-prometheus-stack-values.yaml✅
9LowKine/SQLite write failures completely invisible23-kine-alerts.yaml✅

Gap 1 — Dead-man's switch (Watchdog → healthchecks.io)​

Problem​

The kube-prometheus-stack ships a Watchdog alert that fires constantly (always-on self-test). The original routing silenced it to null. Result: if the entire cluster died — Prometheus, Alertmanager, and all email — there would be zero external notification. A complete outage would be indistinguishable from silence.

Fix​

Route Watchdog to a dedicated receiver that pings healthchecks.io every 60 seconds. If the pings stop for 8 minutes, healthchecks.io sends an alert via its own delivery path (Gmail, independent of the cluster SMTP).

# kube-prometheus-stack-values.yaml
route:
routes:
- matchers: ['alertname = "Watchdog"']
receiver: watchdog
repeat_interval: 1m # ping every 60s — healthchecks.io notices silence after 8 min

receivers:
- name: watchdog
webhook_configs:
- url: https://hc-ping.com/cc3f232b-2dbf-4852-821d-3eb6ecf0ff29
send_resolved: false

The send_resolved: false is critical — if Alertmanager goes down, healthchecks.io should NOT receive a "resolved" ping and reset its timer. Silence is the signal.

Why it's actionable​

The only action is "start investigating the cluster." healthchecks.io delivers that prompt via an out-of-band channel (Gmail) that does not depend on anything inside the cluster.


Gap 2 — Gmail delivery via Amazon SES​

Problem​

The cluster SMTP uses Stalwart Mail Server (stalwart.mail.svc.cluster.local:587). Stalwart runs on a Longhorn PVC. A storage failure that takes down Stalwart also silences all email alerts.

Critical alerts need a second delivery path: Gmail, which is external and always reachable.

Fix​

Amazon SES (eu-west-1) is configured as Stalwart's outbound relay for @devandre.sbs addresses. Gmail (kanmegnea@gmail.com) is added as a recipient in the webhook-critical receiver alongside the cluster mail address.

receivers:
- name: webhook-critical
email_configs:
- to: kanmegnea@devandre.sbs,kanmegnea@gmail.com
send_resolved: true
headers:
Subject: '[CRITICAL] {{ .GroupLabels.alertname }} — minicloud'

SES domain verification, SPF (include:amazonses.com), DKIM (AWS-managed CNAMEs), and DMARC (p=none) are all applied to devandre.sbs.


Gap 3 — Slack receiver (parallel critical path)​

Problem​

If both Stalwart and SES are unreachable simultaneously, all critical alerts are lost. A third, independent channel removes that single point of failure.

Architecture​

critical alert fires
│
├─ email → kanmegnea@devandre.sbs (Stalwart → SES → Gmail)
├─ email → kanmegnea@gmail.com (SES → Gmail direct)
└─ Slack → #general (Slack API — independent of cluster mail)

Implementation​

The Slack webhook URL is stored in Vault at secret/platform/alertmanager (key slack-webhook-url) and synced into the monitoring namespace via an ExternalSecret — keeping it out of git.

# 19-alertmanager-slack-externalsecret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: alertmanager-slack
namespace: monitoring
spec:
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: alertmanager-slack
data:
- secretKey: slack-webhook-url
remoteRef:
key: secret/platform/alertmanager
property: slack-webhook-url

The Alertmanager pod mounts it via alertmanagerSpec.secrets:

alertmanager:
alertmanagerSpec:
secrets:
- alertmanager-slack

receivers:
- name: webhook-critical
slack_configs:
- api_url_file: /etc/alertmanager/secrets/alertmanager-slack/slack-webhook-url
channel: '#general'
send_resolved: true
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }} — minicloud'
text: |
{{ range .Alerts }}
*Severity:* {{ .Labels.severity }} | *Namespace:* {{ .Labels.namespace }}
*Summary:* {{ .Annotations.summary }}
*Description:* {{ .Annotations.description }}
{{ end }}

api_url_file is the standard pattern for keeping the webhook URL out of the in-memory YAML config and therefore out of the Alertmanager API.

Validation​

A live KubePersistentVolumeFillingUp alert arrived in #general within seconds of the receiver going live — no synthetic test needed, the cluster provided a real alert.


Gap 4 — Longhorn health alerts​

Problem​

Prometheus had zero visibility into Longhorn. No ServiceMonitor existed. Volume degradation, node storage pressure, and node storage faults would produce no alert until pods started crashing (too late).

Root cause of "zero visibility"​

Longhorn's manager pod binds its metrics endpoint to the pod IP (:9500), not localhost. The longhorn-backend Service routes to that pod. A ServiceMonitor must target the Service, not try to scrape a localhost port.

# Confirmed: metrics at pod IP only
kubectl exec -n longhorn-system deploy/longhorn-manager \
-- curl -s http://$(hostname -i):9500/metrics | head -5

Fix​

20-longhorn-servicemonitor.yaml — targets the longhorn-backend Service in longhorn-system, port manager (9500):

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: longhorn
namespace: monitoring
spec:
namespaceSelector:
matchNames: [longhorn-system]
selector:
matchLabels:
app.kubernetes.io/name: longhorn
app.kubernetes.io/instance: longhorn
endpoints:
- port: manager
path: /metrics
interval: 30s

21-longhorn-alerts.yaml — 5 PrometheusRules:

AlertMetricThresholdSeverity
LonghornVolumeDegradedlonghorn_volume_robustness == 25 minwarning
LonghornVolumeFaultedlonghorn_volume_robustness == 31 mincritical
LonghornNodeNotReadylonghorn_node_status{condition="ready"} == 05 mincritical
LonghornNodeDiskPressurestorage >85%10 minwarning
LonghornNodeDiskCriticalstorage >95%5 mincritical

longhorn_volume_robustness encoding: 0=Unknown, 1=Healthy, 2=Degraded, 3=Faulted.

Degraded means replicas are rebuilding but the volume is still accessible. Faulted means no healthy replicas — the volume is inaccessible and pods that mount it will block.

:::tip Multipathd / IET context The LonghornVolumeFaulted description references the multipathd incident (Phase 80): multipathd claims Longhorn's iSCSI volumes as mpatha, blocking kubelet mounts. The fix (blacklist in /etc/multipath.conf) is already applied to all 5 nodes. The description explains how to recognise a recurrence. :::


Gap 5 — Node CPU / Memory alert thresholds​

Problem​

kube-prometheus-stack ships NodeCPUHighUsage (fires at >90%, severity: info) and NodeMemoryHighUtilization (fires at >90%, severity: warning).

Stated requirements: warn at CPU >80% for 10 min, warn at Memory >85% for 10 min. At 90%, there is almost no headroom before OOMKill.

Additionally, the kps defaults use severity: info for CPU — which is routed to null. Warnings at 90% CPU are never delivered to anyone.

The controller node (10.0.0.1:9100, scraped via additionalScrapeConfigs) is also covered by node_exporter metrics, so the same rules fire for it.

Fix​

22-node-resource-alerts.yaml — 4 custom rules:

- alert: NodeCPUWarning
expr: >-
sum without(mode) (
avg without(cpu) (
rate(node_cpu_seconds_total{mode!~"idle|iowait"}[2m])
)
) * 100 > 80
for: 10m
labels:
severity: warning # routes to webhook-critical → email + Slack

- alert: NodeCPUCritical
expr: same > 95
for: 5m
labels:
severity: critical

- alert: NodeMemoryWarning
expr: >-
100 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes * 100) > 85
for: 10m
labels:
severity: warning

- alert: NodeMemoryCritical
expr: same > 95
for: 5m
labels:
severity: critical

The kps built-in rules are NOT disabled — they provide a safety net at 90%. The custom rules fire first at 80%/85%, giving earlier warning.


Gap 6 — KubeAPIDown with external-check context​

Problem​

The default KubeAPIDown alert fires when absent(up{job="apiserver"}). But: if the API server is truly down, Prometheus may also be down (it runs in the cluster). If Prometheus is down, Alertmanager is down. If Alertmanager is down, the alert is never sent.

This makes KubeAPIDown partially self-defeating. The alert description must reference the dead-man's switch (Gap 1) as the actual "API is down" notification, not the alert itself.

Additionally, the default description gives no recovery steps specific to a k3s single-control-plane setup.

Fix​

Disable the built-in rule via defaultRules.disabled and replace with an enhanced version:

# kube-prometheus-stack-values.yaml
defaultRules:
disabled:
KubeAPIDown: true

additionalPrometheusRulesMap:
kubeapidown-enhanced:
groups:
- name: kubernetes-system-apiserver
rules:
- alert: KubeAPIDown
expr: absent(up{job="apiserver"})
for: 15m
labels:
severity: critical
annotations:
runbook_url: "https://runbooks.prometheus-operator.dev/runbooks/kubernetes/kubeapidown"
summary: "Kubernetes API server (set-hog) unreachable for 15 min"
description: |
The k3s API server on set-hog (10.0.0.2) has been unreachable for 15 minutes.

NOTE: If Prometheus itself is down, this alert will never send.
The dead-man's switch (healthchecks.io) is the authoritative
"cluster is completely offline" signal — check there first.

Recovery:
ssh set-hog "sudo systemctl status k3s && sudo journalctl -u k3s -n 50"
# If k3s is stopped: sudo systemctl start k3s
# Check disk space: df -h /var/lib/rancher/k3s/
# Check NAT (after power failure):
# ssh -t controller "sudo sh -c 'iptables-restore < /etc/iptables/rules.v4'"

The 15-minute for: (vs default 0 minutes) prevents false alerts during rolling updates of the k3s server binary.


Gap 7 — Runbooks and recovery steps on infrastructure alerts​

Problem​

The 04-infrastructure-alerts.yaml PrometheusRules (NodeSchedulingDisabled, NodeFrequentlyNotReady, PodStuckPending, ContainerOOMKilled) had terse descriptions. A 3am alert with "pod is pending" and no next step does not help.

Fix​

Every alert now carries:

  • runbook_url — link to the relevant Prometheus community runbook
  • description — inline, cluster-specific recovery steps

Selected details added:

NodeFrequentlyNotReady — includes swift-mac specifics (Apple SMC never auto-restarts after power failure; manual power button required), the multipathd/IET fix, and the full iptables NAT restoration sequence for post-power-failure controller reboots.

PodStuckPending — lists the 5 most common causes in priority order: image pull failure, resource exhaustion, PVC not bound, Longhorn volume stuck, Gatekeeper policy violation, and NAT loss after power failure.

ContainerOOMKilled — includes the VPA workflow (check kubectl get vpa, compare recommendation to current limit, switch to updateMode: Auto after 7 days of data), and lists historically OOMKill-prone workloads (ArgoCD controller, Authentik server) with their resolved limits.


Gap 8 — Unsilence KubeControllerManagerDown / KubeSchedulerDown​

Problem​

Phase 21 silenced three alerts that fire on vanilla k3s because no separate kube-proxy, kube-controller-manager, or kube-scheduler process exists (they are embedded in the k3s binary):

- matchers:
- 'alertname =~ "KubeProxyDown|KubeControllerManagerDown|KubeSchedulerDown"'
receiver: "null"

This was correct at Phase 21 — there were no scrape targets for the controller manager or scheduler, so the alerts were structural false-positives.

Gap 5 (k8s-monitoring-gaps) added a socat DaemonSet proxy on set-hog (PR #157) that exposes these components via dedicated Services and ServiceMonitors. Both targets are now confirmed up in Prometheus.

Keeping them silenced after the socat proxy went live means a real controller-manager or scheduler failure would produce zero alert.

Fix​

# Before (Phase 21)
- matchers:
- 'alertname =~ "KubeProxyDown|KubeControllerManagerDown|KubeSchedulerDown"'
receiver: "null"

# After
- matchers:
- 'alertname = "KubeProxyDown"'
receiver: "null"

KubeProxyDown stays silenced. k3s does not have a kube-proxy component and that alert is structurally impossible — silencing it is permanent, not a workaround.

KubeControllerManagerDown and KubeSchedulerDown now route through the normal severity tree (severity: critical → webhook-critical).


Gap 9 — Kine/SQLite write failure alert​

Problem​

k3s stores all cluster state in SQLite via Kine (a Kubernetes datastore adapter). A SQLite I/O error, disk-full condition, or database corruption on set-hog would silently corrupt cluster state. No alert existed for this.

Metric structure​

Kine exposes two relevant metrics:

MetricLabelsMeaning
kine_sql_totalname (operation)Successful operation counter — no error dimension
kine_sql_time_secondsname, error_codeTiming histogram. error_code is absent on success; present with a string on error

Known benign error_code values (must be excluded from alerts):

error_codeOperationCause
constraint failedInsertLastInsertIDOptimistic concurrency control — kine's normal CAS retry loop
context canceledCompactRevWatch canceled during API server rotation — benign

Any other error_code (disk I/O errors, SQLITE_FULL, SQLITE_CORRUPT, etc.) indicates a genuine storage failure.

Fix​

23-kine-alerts.yaml:

- alert: KineSQLError
expr: >-
increase(
kine_sql_time_seconds_count{
error_code!="",
error_code!~"constraint failed|context canceled"
}[5m]
) > 0
for: 2m
labels:
severity: critical
annotations:
runbook_url: "https://andrelair-platform.github.io/minicloud-platform-docs/docs/runbooks/kine-sqlite"
summary: "Kine/SQLite error on {{ $labels.name }}: {{ $labels.error_code }}"
description: |
k3s control plane database (kine/SQLite) is reporting an unexpected error.
Operation: {{ $labels.name }}
Error: {{ $labels.error_code }}

Diagnose:
ssh set-hog "df -h /var/lib/rancher/k3s/"
ssh set-hog "sudo sqlite3 /var/lib/rancher/k3s/server/db/state.db 'PRAGMA integrity_check;'"
ssh set-hog "sudo journalctl -u k3s -n 100 --no-pager | grep -i 'error|fail|corrupt'"

If disk full: free space on set-hog root partition.
If corrupt: restore from Velero (velero restore create --from-schedule velero-daily-full).
Dead-man's switch: healthchecks.io will alert if cluster goes fully silent.

Verified: KineSQLError loads health=ok, state inactive (no current unexpected error codes in the cluster — both constraint failed and context canceled are excluded).


Final alert routing tree​

alert fires
│
├─ alertname = "Watchdog"
│ └─ watchdog receiver (hc-ping.com every 1m)
│
├─ alertname = "KubeProxyDown"
│ └─ null (k3s has no kube-proxy — structurally impossible)
│
├─ severity = critical
│ └─ webhook-critical:
│ ├─ email → kanmegnea@devandre.sbs + kanmegnea@gmail.com
│ ├─ Slack → #general
│ └─ webhook-logger (in-cluster log)
│
├─ severity = warning
│ └─ webhook-default:
│ └─ webhook-logger only (no external notification)
│
└─ severity = info
└─ null

Inhibit rules: critical suppresses warning+info for the same (alertname, namespace). This prevents alert storms when a single root cause fires across multiple severity tiers.


Files changed​

FileWhat changed
helm-values/kube-prometheus-stack-values.yamlWatchdog receiver, Slack mount, KubeAPIDown override, null route narrowed, defaultRules.disabled
manifests/monitoring/04-infrastructure-alerts.yamlrunbook_url + recovery steps on all 4 rules
manifests/monitoring/19-alertmanager-slack-externalsecret.yamlNew — ESO sync from Vault → k8s Secret
manifests/monitoring/20-longhorn-servicemonitor.yamlNew — first Prometheus scrape of Longhorn
manifests/monitoring/21-longhorn-alerts.yamlNew — 5 Longhorn health rules
manifests/monitoring/22-node-resource-alerts.yamlNew — 4 node CPU/Memory rules at correct thresholds
manifests/monitoring/23-kine-alerts.yamlNew — KineSQLError rule for unexpected SQLite errors

Verification commands​

# All custom PrometheusRules loaded and healthy
kubectl -n monitoring get prometheusrule \
infrastructure-alerts longhorn-alerts node-resource-alerts kine-alerts

# Longhorn targets up in Prometheus
kubectl port-forward svc/kps-prometheus -n monitoring 9090:9090 &
curl -s 'http://localhost:9090/api/v1/targets' | python3 -c "
import json, sys
t = json.load(sys.stdin)['data']['activeTargets']
longhorn = [x for x in t if 'longhorn' in x['labels'].get('job','')]
for x in longhorn:
print(x['labels']['job'], x['health'])
"

# KineSQLError rule state (should be inactive — no unexpected errors)
curl -s 'http://localhost:9090/api/v1/rules' | python3 -c "
import json, sys
for g in json.load(sys.stdin)['data']['groups']:
for r in g['rules']:
if 'kine' in r.get('name','').lower():
print(r['name'], r['state'], r['health'])
"

# Live Alertmanager config — verify null route
kubectl -n monitoring exec statefulset/alertmanager-kps-alertmanager \
-- cat /etc/alertmanager/config_out/alertmanager.env.yaml \
| grep -A2 'KubeProxy\|KubeControl\|KubeSchedul'