Monitoring-stack (Prometheus, Alertmanager, Grafana)

Dit document legt uit hoe onze self-hosted monitoring in elkaar zit, waarom we voor bepaalde patronen hebben gekozen en waar de subtiliteiten zitten. Voor concrete feiten (URLs, alert-thresholds, secret-keys), zie de reference. Voor stap-voor-stap uitrollen op het cluster, zie de rollout-how-to.

Wat zit er in de stack

Drie componenten draaien in de monitoring-namespace op een vaste worker-node (tapster.nl/node-name: jive):

  • Prometheus scrape’t metrics uit de tapster- en tapster-development-namespaces, plus kube-state-metrics en de cAdvisor-endpoints op de nodes. De evaluatie-loop draait elke 15 seconden en gaat door alle rule_files heen om alerts en recording-rules af te leiden.
  • Alertmanager ontvangt firing alerts van Prometheus, dedupliceert ze, past inhibit-rules toe en routeert naar receivers (e-mail via Flowmailer, webhook voor de Watchdog).
  • Grafana leest data uit Prometheus én Alertmanager als datasources en serveert dashboards en de live alert-overview.

De drie communiceren cluster-intern (alertmanager.monitoring.svc.cluster.local:9093, http://prometheus:9090), dus daar is geen auth nodig. Voor extern verkeer staat er een Traefik IngressRoute per component op *.tapster.nl, met basic-auth voor Prometheus en Alertmanager en Grafana’s eigen login.

Waarom self-hosted en niet Grafana Cloud

Een managed service zou ons de bedrijfsvoering uit handen nemen maar zou ook betekenen dat klant-metrics (pod-labels, request-counts per tenant) een externe SaaS in gaan. Voor de schaal die we nu hebben (één cluster, beperkt aantal services) is self-hosted goedkoper en levert het ons ook de vrijheid om scrape-targets en rule-bestanden in dezelfde git-repo te beheren als de code waar ze over rapporteren. Mocht de operationele last toenemen, dan is migreren naar een managed setup later een open optie. Geen vendor-lock-in nu nodig.

Hoe een alert van metric naar mailbox komt

  1. Een pod in tapster exposed metrics op een Prometheus-port (annotatie prometheus.io/scrape: "true").
  2. Prometheus scrape’t elke 15 seconden, evalueert de rules en markeert een alert als firing zodra de expressie minimaal for: <duur> waar blijft.
  3. Prometheus pusht actieve alerts naar Alertmanager via cluster-interne service-DNS.
  4. Alertmanager groepeert (group_by: [alertname, severity]), wacht 30 seconden om volgende alerts te bundelen, past inhibit-rules toe en stuurt het resultaat naar de eerste matchende route.
  5. Voor severity = critical gaat het naar roy-email (Flowmailer SMTP). Voor alertname = Watchdog gaat het naar watchdog-webhook (zie hieronder).

repeat_interval zorgt dat een alert die firing blijft niet elk evaluatie-tikje opnieuw mailt, maar pas na 1 uur (critical) of 4 uur (default).

Watchdog: wie alarmeert er als het alerting-pad zelf stuk is

Een klassieke faalmode: Prometheus is down, dus de alerts vuren niet, dus jij krijgt niks. Stilte als signaal interpreteren is niet betrouwbaar. We lossen dit op met een Dead Man’s Switch:

  • De Watchdog-rule heeft expressie vector(1) en vuurt dus continu zolang Prometheus draait en evalueert.
  • Alertmanager heeft een aparte route die alléén op alertname = "Watchdog" matcht en die boven de andere routes staat. Hij stuurt elke minuut een POST naar een externe healthcheck-service (healthchecks.io, BetterStack, etc.) via webhook.
  • Die externe service alarmeert ons als de heartbeat 3 minuten uitblijft, via een ander kanaal (push, sms) dan onze eigen mail. Daarmee zit het alerting-pad niet in zijn eigen failure-keten.

Waarom de Watchdog-route boven de e-mailroute staat: zonder die volgorde zou Watchdog matchen op de default-route en elke minuut een mail veroorzaken. Met continue: false (Alertmanager’s default) stopt de match bij de eerste route en vallen alerts niet door.

Inhibit-rules: waarom twee paden in plaats van één

Een critical voor een pod moet de warning voor dezelfde pod onderdrukken, anders zit je mailbox vol met beide. Alertmanager doet dat via equal:-labels: alerts die dezelfde waarde hebben voor de genoemde labels worden als gerelateerd beschouwd.

Het gevoelige punt: in Alertmanager geldt dat een ontbrekend label aan beide kanten als “gelijk” wordt gezien. Een naïeve rule met equal: ['alertname', 'app_name'] zou dus betekenen dat een MongoosePoolCritical (zonder app_name) een PodCrashLooping voor pod X (ook zonder die specifieke label) zou onderdrukken, uit verschillende namespaces door elkaar.

We splitsen daarom in twee rules:

  1. App-specifiek: matcht alleen als app_name =~ ".+" (niet-leeg) aan beide kanten. Dan correleren we op alertname én app_name.
  2. Cluster-wide: matcht alleen als app_name = "" aan beide kanten. Dan correleren we alleen op alertname (impliciet, want dat is hoe Alertmanager equal: zonder lijst behandelt is niet correct, dus we zetten geen equal: en laten label-set gelijkheid het werk doen).

Het effect: critical-onderdrukt-warning werkt nu correct binnen één app én voor cluster-wide alerts, zonder kruislings effect.

envsubst-rendering: waarom we templates renderen en niet env vars direct gebruiken

Alertmanager leest zijn config uit een YAML-bestand, niet uit env-vars. Maar SMTP-credentials en de Watchdog-webhook-URL horen niet in git. We lossen dat op met een init-container die envsubst draait over een template-config, geheime waarden uit Secrets injecteert via envFrom, en het resultaat naar een emptyDir schrijft die de hoofd-container leest.

Twee subtiliteiten:

  • Whitelist verplicht. envsubst zonder argumenten vervangt elke $VAR-patroon dat in zijn omgeving bekend is en eet los staande $-tekens op alsof het variabele-prefixen zijn. Een password met een letterlijke $ erin (bijv. pa$$w0rd) zou stilletjes gecorrumpeerd raken. De whitelist (envsubst '$SMTP_FROM $SMTP_HOST ...') zegt expliciet welke variabelen vervangen mogen worden en laat de rest letterlijk staan.
  • Whitelist moet kloppen met de template. Een variabele die wel in de template staat maar niet in de whitelist, blijft als letterlijke ${X}-string in de output achter en faalt later bij YAML-parsing of URL-parsing. Een variabele die wel in de whitelist staat maar niet in de template, is harmless. Dus bij elke nieuwe placeholder: voeg hem aan de whitelist toe. Een regressie op precies dit punt (Watchdog-URL niet in whitelist) hebben we gehad in #2025: Alertmanager faalde met invalid URL: unsupported scheme "".

Severity-conventie

Alle alert-rules gebruiken één van drie waarden voor het severity-label:

  • critical: gebruikers raken nu of binnen minuten problemen, of een data-integriteits-risico. Direct mail én Watchdog blijft draaien zodat we weten dat het bericht aankwam.
  • warning: trend gaat de verkeerde kant op maar er is tijd om in te grijpen voor de impact merkbaar wordt.
  • none: meta-signalen zoals Watchdog die niet als alert maar als heartbeat dienen. Niet via mail routeren.

Routing in Alertmanager kijkt naar dit label, dus consistent toekennen is belangrijker dan precies de juiste keuze in een grensgeval.

Wat niet in de stack zit (bewust)

  • Log-aggregatie zoals Loki of ELK. Logs lezen we momenteel via kubectl logs of pod-logging directly. Een aparte log-stack is een tweede stap als monitoring volwassen is.
  • Tracing-backend voor OpenTelemetry. We exporteren wel OTel-data uit apps/api maar er zit nu nog geen Jaeger of Tempo achter (open vraag voor later).
  • Multi-cluster. Alle scrape-config gaat uit van één cluster.