Runbooks¶
Common monitoring scenarios and how to handle them.
Reading this page¶
Two systems are sending you alerts right now. Since 2026-07-21 Alertmanager delivers the Prometheus rules, alongside Grafana which still delivers its own copy of about 12 of them. This parallel run is deliberate — the new path is being proven before the old one is removed — and it has one visible consequence: for mirrored rules you will get two emails for one problem, one titled with each system's name for it. That is expected, not a bug, and it ends when the Grafana rules are deleted.
Each section lists both names, because they differ and either may be the one in your inbox:
- Alert — the Prometheus rule name, delivered by Alertmanager to
alerts@groupe-suffren.com. This is now a live delivery path. - Grafana rule — the string on the Grafana-sent email or Teams card. Removed once Grafana drops to dashboards.
A section marked none — Alertmanager only has no Grafana mirror, so it does not produce a duplicate: you get a single email, under the Prometheus name, delivered by Alertmanager. (Before 2026-07-21 these had no delivery path at all — that phrasing is gone from this page because it is no longer true.)
Container Down / Restarting¶
Alert: ContainerDown, ContainerVanished, ContainerRestarted, HeliosContainerRestarting
Grafana rule: "Container Down", "Container Restarted"
ContainerDown and ContainerVanished detect different failures — they are
not duplicates, and which one fired tells you something:
| What it means | Names the container? | |
|---|---|---|
ContainerDown |
The container is still known to cAdvisor but stopped reporting for 2 min. Usual case: it crashed or was stopped. | Yes — see {{ $labels.name }} |
ContainerVanished |
A monitored container disappeared from cAdvisor entirely — removed (docker rm), or cAdvisor itself died and took every series with it. |
No — only the count |
ContainerVanished firing alone, with no accompanying ContainerDown, most
often means cAdvisor is the problem rather than the container. Check it first:
If the summary reads 0 of N, that is the whole monitored set gone at once —
almost certainly cAdvisor, not N simultaneous outages. (The count comes from the
allow-list in containers.rules.yml; see
Alerting for the current list.)
-
Check which container is affected:
-
Check container logs for the crash reason:
-
Check if OOM killed:
-
Restart if needed:
Systemd Unit Failed¶
Alert: SystemdUnitFailed
Grafana rule: none — Alertmanager only (no Grafana mirror; a single email under the Prometheus name)
A systemd unit is in the failed state. This catches silent service failures
like the Apr 2026 iptables outage where the firewall service failed for 10 days
undetected.
-
Identify which unit failed (the
namelabel on the alert): -
Get the failure reason:
-
Check recent logs for context:
-
Common causes:
- ExecStart path no longer exists (like the iptables incident) — check the unit file
- Dependency unavailable — e.g. service needs docker but docker is down
- Config syntax error — unit file itself is malformed
- Resource limit hit — memory, tasks, or time limits
-
Once fixed, restart the unit to clear the failed state:
-
Verify no other units are failed:
sudo systemctl --failedshould show zero.
High CPU Usage¶
Alert: HighCpuUsage, ContainerHighCpu
Grafana rule: "High CPU Usage", "Container High CPU"
- Check which process/container is consuming CPU:
- Check the Node Exporter Grafana dashboard for CPU breakdown (user, system, iowait)
- If iowait is high: disk I/O bottleneck — check for large database queries or backup running
- If user CPU is high: identify the container and check its logs for processing-heavy operations
- For Celery workers: check for CPU-intensive tasks in the Celery dashboard
ContainerHighCpuspecifically: a single container is pinned >1.5 cores while the host is otherwise fine. Almost always a self-feeding task loop or stuck process — check the alerting container's logs for the same task name / endpoint repeating. The 2026-05-13MediaFilesignal loop is the canonical example:process_media_variantsre-firing post_save indefinitely.
High Memory Usage¶
Alert: HighMemoryUsage, ContainerHighMemory, ContainerHighMemoryUnlimited
Grafana rule: "High Memory Usage"
- Check per-container memory in the Docker Grafana dashboard
- Identify the offending container:
- For Celery workers: check for memory leaks in long-running tasks
- For PostgreSQL: check
work_memand active query count in the PostgreSQL dashboard
Which of the three fired matters¶
They watch different things, and the one that fired tells you where to look:
ContainerHighMemory— a container is near its ownmem_limit. The container is the problem and Docker will OOM-kill it; the host is probably fine.docker statsshows the percentage directly.ContainerHighMemoryUnlimited— a container with nomem_limithas grown past the absolute threshold, counting resident memory and swap. Nothing will stop it: there is no cgroup ceiling, so it grows until the host runs out and the kernel OOM killer picks a victim — which may well be a different, innocent container. Its value is that it names the container.HighMemoryUsage— the host itself is above its threshold. This one cannot name a culprit; if it fires alone, work downdocker statsby absolute usage. Note it readsMemAvailable, which excludes swap — it can stay green through real pressure that swap is absorbing, so do not treat its silence as proof the host is healthy.HostSwapThrashingcovers that case.HostSwapThrashing/HostSwapExhausted— the host is short of RAM and paying for it in page I/O, or has nearly used up the buffer that was hiding that shortage. See the swap section below.
Do not assume ContainerHighMemoryUnlimited gives you a head start on
HighMemoryUsage. It can fire second, or alone, or not at all while the host
suffers — the two thresholds land at roughly the same point on this host, and
HighMemoryUsage has gone a full 15-day window without firing while the host
bottomed out at ~81% used, because swap absorbed the pressure. Treat each alert
as evidence about the thing it measures, not as a stage in a sequence.
Current thresholds, read from the rules:
| Alert | Fires above | Sustained for |
|---|---|---|
ContainerHighMemory |
90% of the container's mem_limit |
5m |
ContainerHighMemoryUnlimited |
1.5 GiB resident + swap | 5m |
HostSwapThrashing |
500 pages/s sustained page-out | 15m |
HostSwapExhausted |
75% of total swap in use | 30m |
ContainerHighMemoryUnlimited has no allow-list — it watches anything without a
mem_limit, deliberately, so it can catch a container nobody enumerated. The
set that qualifies today is shared_postgres, shared_redis, nginx-proxy and
certbot (see below); check the alert's own name label rather than assuming
it is one of those, because a newly added limit-less container joins the rule
automatically. In practice it has meant shared_postgres:
- Find the query holding the memory — a single runaway sort or a leaked connection pool is the usual cause:
- Cross-check the top query shapes in the PostgreSQL dashboard
(
pg_stat_statementsis preloaded) before killing anything. - Terminate only if it is genuinely runaway:
SELECT pg_terminate_backend(<pid>); - If usage is legitimate and simply growing, the fix is
work_mem/shared_bufferstuning, not amem_limit— see the note below.
Do not 'fix' this by adding a mem_limit
It is the obvious response and it is wrong for these containers.
shared_postgres carries oom_score_adj: -800 and nginx-proxy -600,
which tell the host OOM killer to kill something else first. A
mem_limit creates a cgroup-local OOM kill that ignores oom_score_adj
entirely — so adding one converts the two containers we most wanted to
protect into the first ones killed. The limits are absent on purpose, and
that is exactly why this alert exists.
Host Swapping¶
Alert: HostSwapThrashing, HostSwapExhausted
Grafana rule: none — Alertmanager only (no Grafana mirror)
These exist because no other alert on this stack can see swap.
HighMemoryUsage reads MemAvailable, which excludes it by construction, and
the container memory rules read working set, which falls as the kernel pages a
container out. So the stack could be entirely green while the host shed several
gigabytes to disk — and was: over the 15-day window when this gap was found,
minimum available memory hit 2.21 GiB (~81% used) without HighMemoryUsage
firing, while swap in use climbed from 0 to 7.6 GiB.
Which one fired matters.
HostSwapThrashing— happening now. The host is short of RAM and paying disk latency for it; everything on the box is slower, and it will not show up as a memory alert. This is a latency incident.-
HostSwapExhausted— building up. The buffer that has been quietly absorbing memory pressure is nearly gone. When swap fills, the next allocation failure goes to the kernel OOM killer, which picks its own victim. -
Confirm the shape — is it filling, draining, or churning?
-
Find who is swapped out. Per-container metrics do not show this well, so go to the processes:
-
Map the worst offenders back to containers:
-
Decide which problem you have:
- A container is genuinely growing → it is the same investigation as
High Memory Usage above. Start there;
ContainerHighMemoryUnlimitedcounts swap, so it should be naming the container. - Many workers each modestly oversized → the usual cause here is Celery and gunicorn worker counts, not a leak. Reducing worker concurrency reclaims more than tuning any single process.
- Nothing is growing and swap is simply old → pages swapped out during a
past spike are not faulted back in until touched. If
sois ~0 and onlyHostSwapExhaustedfired, this is stale swap, not live pressure. It still needs clearing (swapoff -a && swapon -a, only with enough free RAM to hold the difference — checkfree -mfirst, it will OOM the box otherwise).
Why swap is not simply disabled
Swap is what keeps a transient overshoot from becoming an instant OOM kill,
and shared_postgres / nginx-proxy carry negative oom_score_adj
specifically so the host kills something else first. Removing swap removes
that grace period. The goal is to notice swapping, not to eliminate it —
which is what these two alerts are for.
Disk Space Low¶
Alert: DiskSpaceLow
Grafana rule: "Disk Space Low"
-
Check disk usage:
-
Common space consumers:
- Docker images:
docker system df→docker system prune - Loki logs: check
/opt/docker/monitoring/loki/volume - PostgreSQL WAL: check
/var/lib/postgresql/18/docker/pg_wal/ -
Backup archives: check
/opt/docker/backups/ -
Clean Docker resources:
PostgreSQL Issues¶
Alert: PostgresConnectionsHigh, PostgresDown
Grafana rule: "PostgreSQL Connections High"
-
Check connection count:
-
Find long-running queries:
-
Kill a stuck query (last resort):
Celery Queue Backlog¶
Alert: CeleryQueueBacklog, CeleryWorkerDown, CeleryHighFailRate
Grafana rule: "Celery Task Queue Backlog"
- Check queue depth in the Celery Grafana dashboard
- Check worker status:
- Check for stuck tasks: look for tasks running longer than expected in the dashboard (p99 runtime panel)
- Restart workers if needed:
SSL Certificate Expiring¶
Alert: SslCertExpiringSoon, HeliosSSLExpiringSoon
Grafana rule: "SSL Certificate Expiring Soon"
Certificates should auto-renew via certbot. If they're not:
-
Check certbot logs:
-
Force renewal:
-
Reload nginx after renewal:
Health Check Failing¶
Alert: HealthCheckFailing, HeliosHealthCheckFailing
Grafana rule: "Application Health Check Failed"
First decide which signal fired — they mean different things:
HealthCheckFailing(anyblackbox-healthtarget non-200) is the authoritative backend signal. That job is Aletheia-only:/health/(DB + Redis + Celery) and the API config probe…/api/v1/websites/sites/<domain>/config/(theapps/websitesDRF layer). A 200 from/health/with a failing config probe means DB/Redis/Celery are fine but the websites view/serializer layer is broken. The Helios frontend roots are not in this job, so this alert never fires on a frontend-only outage.-
HeliosHealthCheckFailingis frontend reachability only (its ownblackbox-helios-frontendjob). Helios returns HTTP 200 with a soft-404 error UI when Aletheia is down, so this alert does not detect an Aletheia outage — it only fires when the practice site is fully unreachable (DNS/cert/process/nginx). For backend health, look atHealthCheckFailing, not this. -
Check from the server itself:
curl -sI https://aletheia.groupe-suffren.com/health/ # backend deep health (200/503) curl -s -o /dev/null -w '%{http_code}\n' \ https://aletheia-staging.groupe-suffren.com/api/v1/websites/sites/cabinet-dentaire-aubagne.fr/config/ # API/DRF layer (expect 200) curl -sI https://cabinet-dentaire-aubagne.fr/ # Helios frontend (200 even if backend is down!) -
If
/health/is 200 but the config probe is non-200: the DRF layer is broken (view/serializer/migration) — check Aletheia web logs, not the DB. The live config probe targets staging (aletheia-staging…), so a404there is a real regression — the probedSiteConfig.domain(Aubagne) was unseeded/deleted on staging; reseed it (restore_website_seed). (A 404 is only expected for the prod config probe, which is why that target stays commented out inprometheus.ymluntil practice-site launch.) - If the app responds locally but not externally: check nginx config and DNS
- If the app doesn't respond locally: check container status and logs (see "Container Down" above)
- Check the blackbox targets in Prometheus UI (
http://localhost:9090/targets) for specific probe failures
Helios Practice Site Unreachable (frontend)¶
Alert: HeliosHealthCheckFailing
Grafana rule: "Helios Practice Site Unreachable (frontend)"
A practice site is fully unreachable — DNS, cert, the Next.js process, or the nginx vhost. This is not a backend alert: Helios returns HTTP 200 with a soft-404 error UI when Aletheia is down, so an Aletheia outage does not trigger this. For backend health see "Health Check Failing" above.
This rule is dormant until the first .fr cutover
The blackbox-helios-frontend job has no targets yet, so the rule is held at
noDataState: OK — a job monitoring nothing would otherwise alert permanently.
make helios-golive prints a step 3b reminding you to flip it to Alerting on
the first real cutover. If this alert fires, a live practice site is down.
-
Confirm the outage is real and find which layer broke:
-
If DNS or cert is the problem, the site was likely cut over incorrectly — check the vhost exists and is activated, not merely deployed:
-
If DNS and cert are fine, check the Helios container for that environment:
-
Check the probe itself before assuming the site is down — this rule fires on probe failure, and a dead prober looks identical from here:
High 5xx Error Rate¶
Alert: NginxHigh5xxRate
Grafana rule: "High Nginx 5xx Error Rate"
-
Check nginx error log for the failing upstream:
-
Identify which backend is returning errors:
-
If a specific app is failing: check that app's container logs and health
- If all backends are failing: check shared services (PostgreSQL, Redis) — a database outage causes 500s across all apps
- If nginx itself is the issue:
docker exec nginx-proxy nginx -tto validate config
Redis Issues¶
Alert: RedisDown (if configured), or detected via app errors
-
Check Redis container status:
-
Test connectivity:
-
Check memory usage:
-
Check per-database key counts (prod=0/1, staging=2/3, dev=4/5):
-
If Redis is unresponsive, restart:
Note
Redis is used for Celery broker and Django cache. A Redis outage will cause Celery tasks to stop processing and may degrade app response times.
Nginx Routing Issues¶
Symptom: 502 Bad Gateway, 504 Gateway Timeout, or requests reaching the wrong service
-
Test nginx config syntax:
-
Check which config is active (
.conf.full= HTTPS,.conf.temp= maintenance): -
Check nginx error log for upstream failures:
-
Verify the upstream container is running and on the correct network:
-
After config changes, reload (not restart) nginx:
Monitoring Stack Down¶
Symptom: Grafana unreachable, no alerts firing, Prometheus targets showing as down
-
Check all monitoring containers:
-
If Prometheus is down:
-
If Loki is down (log gap risk):
-
If Alloy (log collector) is down:
-
Restart the full monitoring stack:
Warning
While the monitoring stack is down, no alerts will fire. Check containers
manually with docker ps -a until monitoring is restored.
Alertmanager Notifications Failing¶
Alert: AlertmanagerNotificationsFailing
Symptom: Alertmanager is up and evaluating, but its email leg to Brevo is
dropping notifications. This is partial failure — the notifier works, delivery
does not. (Total Alertmanager failure is not caught here — it is caught by the
external Watchdog heartbeat, which pages via healthchecks.io when the stack
stops pinging. See the Watchdog runbook below.)
This alert delivers over the path it is reporting on
If email is totally down, this alert's own email will not arrive either. It reaches you on degraded email (intermittent failures, single drops), and — while the Grafana parallel run lasts — through Grafana's independent channel. Do not treat its silence as proof email is healthy.
-
Confirm the failure and see why:
docker exec monitoring_prometheus wget -qO- \ 'http://localhost:9090/api/v1/query?query=alertmanager_notifications_failed_total%7Bintegration%3D%22email%22%7D' # the `reason` label (clientError / serverError / contextDeadlineExceeded …) # tells you whether Brevo rejected it (auth/quota) or the connection timed out -
Check Alertmanager's own logs for the SMTP error text:
-
Most common cause — the derived SMTP password is empty or stale:
sudo test -s /opt/docker/monitoring/alertmanager/smtp_password && echo "present" || echo "EMPTY" # if empty: the SMTP_PASSWORD key is missing from monitoring/.env. # `make decrypt` then `make deploy` re-derives it (deploy now aborts rather # than blanking it, but an already-blanked file needs this to refill). -
If the credential is fine, check Brevo status (quota, IP allow-list, key rotation) —
serverError/clientErrorreasons point there rather than at the config.
Alert Emails Are Not Arriving¶
Alert: none — and that is the whole point of this section.
Symptom: You suspect alerts are not reaching the inbox, or you want to prove
they are. Nothing on this stack can page you about this, because every guard
stops at the moment the relay says 250 OK:
AlertmanagerNotificationsFailing sees SMTP errors but not silent drops, and
the Watchdog heartbeat never touches SMTP at all, so it keeps healthchecks.io
green through a total email outage. Email is the sole channel, so this is the
one failure that can be complete and invisible at the same time.
This is therefore a procedure you run, not an alert you wait for.
- Establish where the message stopped. Alertmanager counts what it handed over:
docker exec monitoring_alertmanager wget -qO- http://localhost:9093/metrics \ | grep -E '^alertmanager_notifications_(total|failed_total)\{integration="email"'totalrising withfailed_totalat 0 means Alertmanager and the relay are fine and the loss is downstream — go to step 3. Iffailed_totalis rising, this is theAlertmanagerNotificationsFailingrunbook above instead.
Note these counters reset when the container restarts, so total 0 on a
recently restarted Alertmanager means "nothing sent since restart", not
"nothing ever sent".
-
Send a real test alert end-to-end. It routes to the
emailreceiver like any other (onlyalertname="Watchdog"is diverted), so this exercises the exact production path:It flushes afterNOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) END=$(date -u -d '+2 minutes' +%Y-%m-%dT%H:%M:%SZ) docker exec monitoring_alertmanager sh -c "wget -qO- \ --post-data='[{\"labels\":{\"alertname\":\"DeliverabilityAudit\",\"severity\":\"none\"}, \"annotations\":{\"summary\":\"synthetic test - ignore\"}, \"startsAt\":\"$NOW\",\"endsAt\":\"$END\"}]' \ --header='Content-Type: application/json' \ http://localhost:9093/api/v2/alerts"group_wait(30s); re-run step 1 and expecttotalto have gone up by one. The alert self-resolves after two minutes. -
Read the message in the destination mailbox — including its headers. This is the step no tooling here can do for you, and it is the only thing that distinguishes "delivered" from "accepted by the relay". Check the spam and quarantine folders, not just the inbox.
In the raw message source, find Authentication-Results. Expected, and
measured on 2026-07-22:
dkim=passwith a signing domain matching the sender domain. This is the one that matters. DKIM is the only thing making DMARC pass on this path (see alerting.md for why SPF structurally cannot), sodkim=failis an outage of the alerting channel itself, even while mail appears to send fine.spf=pass— expected, and not about our SPF record: the relay rewrites the envelope sender to its own domain, so the pass belongs to the relay. Our record is not consulted.spf=fail/softfail— this would mean the relay has stopped rewriting the envelope sender, at which point the sender domain's-allgoes live and starts hard-failing every alert. Re-scorespf-authorizes-relayincheck_email_deliverability.pyand publishinclude:spf.brevo.com. Not the situation today.
If anything here differs from the above, record the new reading in the roadmap entry — it is expensive to re-derive and cheap to write down.
-
If the relay accepted it and the mailbox never got it, the loss is at the relay or the receiver. Check, in this order: the relay's own transactional log for that message ID (bounce, blocked, suppression list); whether the recipient address landed on a suppression list after an earlier bounce; and the receiving tenant's quarantine.
-
Check the posture that makes silent quarantine likely in the first place:
The relay account is shared
The same relay account and sender address are used by Grafana and by aletheia's application mail. A quota exhausted or a reputation damaged by application mail degrades the alerting path with it, and neither is visible from the monitoring stack. If alerts stopped arriving with no config change, check whether application mail volume changed.
Watchdog / Monitoring Heartbeat¶
Alert: Watchdog
This alert is different from every other one on this page. It is designed to
fire constantly (expr: vector(1)) and is routed only to healthchecks.io,
never to email. You will never be paged by it. You get paged when it stops —
healthchecks.io alerts on the absence of the heartbeat ping, over a channel
(its own email/SMS/etc.) that shares no infrastructure with our stack.
So a healthchecks.io "down" notification means the monitoring stack itself is
not delivering — Prometheus is not evaluating, Alertmanager is down, or the
host/network is gone. It is the one signal that can catch total stack death,
because it is checked from outside the stack. (It does not prove alert
emails work — the ping path never touches Brevo/SMTP; that leg is watched
separately by AlertmanagerNotificationsFailing.)
When healthchecks.io reports the heartbeat missing:
-
Is the whole box down? Check from another host that the server responds at all before assuming it is a monitoring-only fault.
-
Is Prometheus evaluating?
-
Is Alertmanager up and routing it?
-
Can the container reach healthchecks.io, and is the ping URL present?
sudo test -s /opt/docker/monitoring/alertmanager/healthchecks_url && echo "present" || echo "EMPTY" # if empty: HEALTHCHECKS_PING_URL is missing from monitoring/.env. # `make decrypt` then `make deploy` re-derives it (deploy aborts rather than # blanking it, but an already-blanked file needs this to refill). docker exec monitoring_alertmanager wget -qO- "$(sudo cat /opt/docker/monitoring/alertmanager/healthchecks_url)" && echo "ping OK" -
Restart the stack if a container is wedged (
make restart-monitoring). A successful ping resolves the healthchecks.io alert automatically.
Tuning the detection window
healthchecks.io fires after Period + Grace with no ping. Those are set on
the healthchecks.io side and must stay comfortably larger than Alertmanager's
repeat_interval for the Watchdog route (1m) so a single missed flush is not
a false alarm. Current values: Period 5m, Grace 5m → detects a real stall in
roughly 10 minutes. Change the two together, never one alone.
Grafana Access Issues¶
If Grafana is unreachable at monitoring.groupe-suffren.com:
-
Check the container:
-
Check nginx proxy config:
-
Check Grafana logs:
Alertmanager UI Unreachable¶
https://monitoring.groupe-suffren.com/alertmanager/ is the silence/ack path.
It being down does not stop notifications — Alertmanager can deliver email
perfectly while its UI is unreachable — so this is an access problem, not an
alerting outage. (If notifications have also stopped, that is the Watchdog's
job; see the healthchecks.io runbook above.)
-
401 with a password prompt = working as designed. The credentials are the shared
/opt/docker/nginx/.htpasswd, the same ones staging uses. -
404 or the Grafana login page instead of Alertmanager → the trailing slash or the location block. Check the deployed config actually has it — the server reads
If the block is missing, the config was deployed but never activated:monitoring.conf, notmonitoring.conf.full:make apply, thenmake status. -
502 → nginx cannot resolve or reach the container. It is reached by name over the
webnetwork, so a container recreated without that network is the usual cause:Fix withdocker ps --filter name=monitoring_alertmanager docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' monitoring_alertmanager # expect: monitoring web docker exec nginx-proxy wget -qO- http://monitoring_alertmanager:9093/-/healthymake restart-monitoring(the compose file declares both networks).