Repository navigation
feat(observability): bound every service's memory, and alert before the kill (#114) - #298
Conversation
…he kill (#114) No service set `mem_limit`, so a leak in any one container could take a host with 8 GB soldered down with it. All seven now carry one, plus a `memswap_limit` equal to it. Sized from fourteen days of `container_memory_rss` — 2026-08-21 to 09-04, the full extent of the per-container series, which only exist since #62 fixed `cgroup: host`. RSS rather than working set, because `mem_limit` bounds a cgroup the kernel reclaims page cache from before it kills anything: alloy's 512 MiB working-set peak is 221 MiB of anonymous memory and the rest cache from walking /rootfs. That distinction is also what retires the method the issue proposed — 3x every working-set peak is 7600 MiB against 7816 MiB of RAM. prometheus 1024M loki 1536M grafana 1280M alloy 768M alertmanager 256M snmp-exporter 128M blackbox-exporter 128M The multipliers are per service and each says why. loki's is the loosest ratio and the largest number for a reason that is not Loki's: its 1015 MiB peak is an ingest flood, #286, in which `make backup` pipes each volume's gzip stream to a container's stdout and Alloy tails every container on the socket. Loki's RSS tracks the ingest rate through that hour minute for minute, and it recurs on the backup timers. The compactor cycling and 400s in the logs are a Loki shedding load, not the cause. `memswap_limit` is set deliberately rather than defaulted. Equal to `mem_limit` is `memory.swap.max = 0`; left unset Docker defaults it to twice `mem_limit`, so adding a memory limit would have handed each container an equal allowance of the unencrypted `/swap.img` this repository already carries as a data-at-rest exposure. Every service had already paged into it — 70 MiB for prometheus. Adds `ContainerNearMemoryLimit`, which the limits make possible and also make necessary: the largest is 0.20 of the host against `ContainerHighMemory`'s 0.40, so every service can now be OOM-killed by its own limit without that rule firing. It divides RSS by the limit with the `> 0` guard #63 is about, and has four `promtool test rules` cases — one failing if the numerator is "simplified" to working set, one if the guard is dropped. No limits on `stacks/lab`: it runs on another host with its own Prometheus and no series here to size from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # README.md # docs/observability.md # docs/runbooks/deploy-stack.md # docs/security.md # stacks/observability/README.md
Deployed and verified
The limits are in the kernel, not just the file.
cAdvisor now reports a real ceiling, which is what makes the new rule Rules: 56 alert rules loaded, none in error. All five container rules The
One thing worth writing down
That is the same trap that made the six-hour sizing in #114 misleading: these Follow-ups
|
Closes #114.
No service set
mem_limit, so a leak in any one container could take a hostwith 8 GB soldered down with it. All seven now carry one, and a
memswap_limitequal to it.The gate this issue set has cleared
#114 blocked itself on data, deliberately: cAdvisor has only reported
per-container series since #62 fixed
cgroup: host, so there were six hours ofhistory when it was written. There are now fourteen days — 2026-08-21 to
2026-09-04 — and the numbers come from that window rather than from a guess.
Two of the issue's own premises did not survive it.
Prometheus is no longer the hard one.
prometheus_tsdb_lowest_timestampisexactly 30 days back and
prometheus_tsdb_time_retentions_totalhasincremented, so the store is evicting and has reached the steady state that six
hours of a mostly-empty TSDB could say nothing about. What is still moving is
the series count, not the store.
lokiis. The issue listed it among the low-risk services to start with, ona 152 MiB peak. Fourteen days puts its peak at 1015 MiB.
Sized from RSS, not working set
mem_limitbecomes cgroup v2memory.max, which boundsmemory.current—anonymous pages plus page cache. The kernel reclaims cache before it OOM-kills
anything, so what a limit has to clear is the anonymous high-water mark.
The difference is not cosmetic.
alloy's 512 MiB working-set peak is 221 MiB ofRSS and the rest reclaimable cache from walking
/rootfs— its anonymous memorynever leaves 155-181 MiB across the whole fortnight. Sizing from working set
would have set a 1536M ceiling for a process that never exceeded 221 MiB of the
thing the ceiling is about.
It is also what retires the method the issue proposed: 3x every working-set
peak is 7600 MiB against 7816 MiB of RAM.
1024M1536M1280M768M256M128M128MMiB. Seven, not six —
blackbox-exporterjoined after the issue was written,and it is the one service with six days of history rather than fourteen, which
its comment says.
The multipliers vary and each service says why. The sum, 5120 MiB, is a ceiling
and not a reservation: the stack's combined RSS peaked at 1448 MiB across the
fortnight and averaged 566.
loki's number belongs to a bug, and the bug is on a timer
Its 1015 MiB peak is an ingest flood — new issue #286.
backup-volumes.sharchives each volume with
docker run ... tar -czf -, the gzip stream is thatcontainer's stdout, and Alloy's
loki.source.dockerdiscovers every containeron the socket rather than this stack's seven.
make backupships its ownarchives back through the log pipeline.
Loki's RSS tracks the ingest rate through that hour minute for minute:
Worth being explicit about cause and symptom, because the logs read the other
way round: through those minutes Loki's compactor cycled, logged "instance not
found in the ring" and returned 400s while Alloy dropped batches. That is a Loki
shedding load, not a Loki churning on its own — the containers never restarted,
and both were at baseline by 06:40 because the flood stopped.
It recurs on the backup timers: 577 MiB on 09-04, 179 on 09-01, against
107-128 MiB on the nine quiet days. So
1536Mhas to clear it until #286 lands,at which point this should be re-derived down to roughly
512M. The commentsays so.
Swap, decided rather than defaulted
memswap_limitequal tomem_limitismemory.swap.max = 0. Left unset,Docker defaults it to twice
mem_limit— so adding a memory limit would havehanded every container an equal allowance of the unencrypted
/swap.imgthisrepository already carries as a data-at-rest exposure, and the limit would not
have been a memory limit.
Not hypothetical: peak
container_memory_swapover the window was 70 MiB forprometheus, 17 for alloy, 12 for loki, 8 for grafana, 5 for snmp-exporter. Every
one had already paged out.
SECURITY.mdanddocs/security.mdare updated, andboth say what this does not close.
The rule the limits make necessary
ContainerNearMemoryLimitis the complement #114 raised as optional. It is notoptional any more: the largest limit is
1536M, which is 0.20 of this host,against
ContainerHighMemory's 0.40 — so after this change every service canbe OOM-killed by its own limit without that rule ever firing. The limits close
the gap it watches and open a new one underneath it.
It divides
container_memory_rssby the limit behind the> 0guard that #63is about, and carries four
promtool test rulescases, each paired against aquiet one:
for: 15mthe numerator is ever "simplified" to working set
dropped, which is ContainerHighMemory cannot fire — no service sets a memory limit #63 exactly
ContainerHighMemorystays host-relative. Coupling enforcement to detection iswhat made it unfireable for months, and that is why #114 kept them separate.
Not done
stacks/lab: it runs on another host with its own Prometheus andno series reaching
10.0.99.20to size from. The file says so rather thancopying these numbers.
renderer,archiver,gitleaks,actionlint,editorconfig-checker) — none runs in a normal deploy, so therewas nothing to measure. An unmeasured ceiling on a headless Chromium is the
guess this work exists to avoid.
Verification
make validatepasses (2 skips, both "not the deployment host"). The stackrestart and the post-deploy check are in a comment below.
🤖 Generated with Claude Code