observability: add the estate's first alerting; supervise Postfix #58
No reviewers
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Jinemi/thermograph#58
Loading…
Reference in a new issue
No description provided.
Delete branch "ops/mail-supervision-and-alerting"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The estate had no alerting at all
Zero alert rules. The only contact point was Grafana's factory default pointing at the literal string
<example@email.com>. And beta runs an untrackeddocker-compose.override.ymlpointing Grafana's SMTP at10.10.0.1:25— prod's Postfix — so alerts, had any existed, would have been delivered by the box most likely to be on fire.Meanwhile prod served 86 5xx in 24h including five
/healthzfailures, and nobody was told.Thresholds came from a backtest, not from taste
The 24h window used happens to contain a real ~20-minute prod outage at 04:20Z. 10-minute buckets of prod 5xx (non-zero only):
2, 45, 22, 3, 1, 1, 1, 1, 1, 1, 2, 1, 5, 1.Absolute counts, not ratios — prod runs ~7 req/min, so one bad request is 1.4% and a ratio alert screams all night.
ProdHTTP5xxBurstat ≥10/10m fires on the45and22buckets and on nothing else in the day; the largest benign bucket was 5.ProdHealthzFailingusesfor: 0mbecause there are only ~2.4 probes an hour, so waiting for a second sample costs 25 minutes.AlertingWatchdogis a dead-man's switch — one message a day, whose value is its absence.Delivery is proven, not assumed
The rules were provisioned into a throwaway Grafana 11.6.1 against beta's live Loki, and a real alert arrived in Discord. That verification is not optional here:
GET /api/v1/provisioning/contact-pointsreturns[REDACTED]for the URL, so a contact point holding an uninterpolated$DISCORD_ALERT_WEBHOOK_URLlooks perfectly healthy and pages nobody. The only proof is a message arriving.Routed to a new private
#ops-alertschannel — deliberately not to#weather-events,#announcementsor#prod, which are product surfaces that notify real subscribers.It immediately found a real fault
ProdWorkerContainerSilentfired 10 minutes after first evaluation, correctly: the prod worker's logs have not reached Loki since 04:43Z while the container is up, healthy, and writing ~19 lines per 10 minutes to its own docker log. Prod's Alloy stopped tailing it after Swarm replaced the task. Localised to Alloy rather than the app becausetag=heartbeatlines kept arriving from a different Alloy source throughout. This rule will be firing the moment you deploy, and it should be.CI gains a structural check
The existing check only proves YAML parses. An alert rule whose
conditionnames a refId that does not exist is valid YAML, provisions without complaint, and never fires — a paging path that looks healthy and reaches nobody, which is the exact failure this config exists to end. The new step also hard-fails on a literal Discord webhook in the repo. Verified by deliberately introducing all three breakages.Postfix supervision
The 13-hour outage was a boot-ordering race, not a Docker renumbering. From the journal: postfix started 08:09:49,
wg0came up at :51 (winning by one second), postfix fataled at :52 on the missingdocker_gwbridgeaddress — and dockerd did not finish starting until 08:10:16. Stockpostfix@.serviceis ordered onlyAfter=network-online.targetand ships noRestart=, so one lost race became permanent.An
ExecStartPregate now blocks up to 60s until everyinet_interfacesaddress actually exists, absorbing the transient case inside a single start attempt. That is what makes bounded retry correct: 5 attempts in 600s, thenfailed— a genuinely broken config reaches a visible failed state in ~100s instead of re-fataling every 15s forever. A 5-minute watchdog timer then retries indefinitely and runsreset-failed, so "failed" still self-heals. Worst case ~5 minutes rather than 13 hours.Wants=, notRequires=— a dockerd failure must not take down the loopback and mesh listeners that have nothing to do with Docker.Tested on live prod, all reversed:
kill -9on master restored all three listeners in ~21s; a deliberately broken config reachedfailedat ~100s and stopped retrying; and the original lie was reproduced — zero listeners whilesystemctl is-active postfixreportedactive.One gotcha worth reading before writing any mail tooling
postmulti,postqueueandpostfixall resolveinet_interfaces, so they fatal precisely when an address is missing — the state you most need to detect. The first version of this health check usedpostmulti, got an empty string, concluded "0 addresses to verify", and reportedstatus=ok bound=0/0. It now usespostconf -c, which only reads the file, and correctly reportsstatus=fail reason=address-missing-from-hostwhile Postfix is still serving — catching the next-reboot outage with zero downtime.main.cfis untouched and byte-identical to its pre-work backup.Known gaps, documented rather than papered over
loki.source.journalis added. Specified, deliberately not implemented here.mynetworkson prod has drifted from whatprovision-mail.shgenerates: live config includes172.19.0.0/16, which is not dead — Postfix has accepted real mail from172.19.0.3, the bridge Centralis runs on. Re-running the provisioning script would silently drop it. Left alone; flagged.thermograph_daemon,autoscaler,autoscaler-lakeandlakeare not alertable by log silence — their healthy floor is indistinguishable from dead. A heartbeat line each is the highest-value follow-up.Deploy note
The first beta deploy needs
docker compose up -d grafana, notdocker restart— it introduces a new env var and restart will not pick it up. Beta has an.envso compose is safe there; the "never compose" rule is a prod constraint.DISCORD_ALERT_WEBHOOK_URLis a secret, deliberately not in the repo, and must be added to beta's/opt/observability/.env.