fix(observability): reap orphans, bound tasks, cap the TSDB (#71) - #184
Merged
Conversation
#71 records that no service sets any resource bound and that Prometheus retention is time-only. Its memory half is #114 and stays there — the nine days of history since have widened the case rather than settled it, so the roadmap entry is rewritten to say that waiting for more is not what unblocks it. This is the half that was sizeable. Verifying it turned up a live fault. Grafana was holding 2437 defunct ssl_client processes and gaining two more every 30 seconds: busybox wget forks a TLS helper for the https healthcheck, the helper is reparented to PID 1 on exit, and PID 1 there is `grafana server`, which never reaps. At 2880 charges a day against the inherited pids.max of 9056 it would have run out of tasks about three days after each start, presenting as `unhealthy` — which Docker does not restart. It was only ever saved by being redeployed sooner than that. Nothing in the stack could see it: container_tasks_state reads zero for every state. So `init: true` comes first and `pids_limit` second; the reverse order would have cut Grafana off sooner rather than protecting it. pids_limit is 512 (1024 for alloy, 4096 for the unmeasured renderer) against an observed 9-17 tasks and a 21-thread peak. The number is loose deliberately: what makes a task ceiling safe to set and a memory limit not is that the gap between "certainly enough" and "certainly protective" spans two orders of magnitude here. ulimits stays absent, and the file says why — RLIMIT_NPROC is enforced per real UID across the whole kernel, and alertmanager and snmp-exporter run as the operator's own uid. The TSDB ceiling is 12 GiB, sized from the per-block write rate rather than the store's current size. 1021 MiB on disk is not a steady state: prometheus_tsdb_time_retentions_total is 0, nothing has ever been evicted, and the rate went 16.1 -> 72.9 MiB/day at 08-17 when #62 fixed cAdvisor and the series count went 3,514 -> 24,005. Steady state is 2.28 GiB, or 2.73 GiB once Saruman gets an agent, so 4 GiB would have been 1.5x against an estate that is already planned — a second retention policy wearing a ceiling's clothes, in a repository that promises 30 days in four places. A ceiling that engages silently shortens retention to whatever fits, so PrometheusSizeRetentionActive says when it does, keyed on the size counter rather than the time counter, which increments constantly by design. It ships with promtool cases for both firing and quiet, per #63. Deployed to the lab in two cycles and confirmed: init=true on all seven, pids.max 512/1024 read back from the kernel, retention_limit_bytes 12884901888, grafana flat at 20-29 tasks with zero defunct children across five healthcheck intervals, 39 rules loaded and healthy, no OOM kills and no restarts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the half of #71 that could be sized. The memory half stays with #114.
What #71 asked for, and what the data said
#71 lists
mem_limit,cpus,deploy.resources,pids_limitandulimitsasall absent, plus the missing
--storage.tsdb.retention.size. Two of those turnout to be sizeable today and the rest do not, which is the shape of this PR.
Memory is not sizeable, and nine more days made that clearer rather than
less. #114 was blocked on getting 14 days of container history; there are now
9.4, and the distribution widened:
loki,grafanaandalloyall peaked in the same hour on 2026-08-29 —episodic, not a distribution that converges with sampling. And #114's own
method no longer fits the machine: 3x every peak is 7536 MiB against 7816 MiB
of RAM. So
docs/roadmap.mdnow says the gate has changed rather than moved —what unblocks #114 is explaining that one hour, not waiting for more history.
The leak this found
Verifying #71 turned up something already running. Grafana was holding 2437
defunct
ssl_clientprocesses, gaining two more every 30 seconds:BusyBox
wgetforks a TLS helper for the https healthcheck; on exit the helperis reparented to PID 1, which here is
grafana serverand never callswait().Only Grafana was affected — every other healthcheck is plain HTTP and forks
nothing. At 2880 charges/day against the inherited
pids.maxof 9056 that is afork failure about 3.1 days after each start, presenting as
unhealthy,which Docker does not restart. It has only ever been saved by being redeployed
sooner than that.
Nothing in the stack could see it:
container_tasks_statereads zero for everystate and
node_processes_stateonly sees Alloy's own namespace. Same shape as#63 — a fault that runs healthy and produces nothing.
init: truetherefore comes first andpids_limitsecond. The reverse orderwould have cut Grafana off sooner rather than protecting it.
Changes
init: trueon the shared anchor, so every service has a reaping PID 1.pids_limit: 512(1024 alloy, 4096 for the unmeasured renderer) againstan observed 9–17 tasks and a 21-thread peak. Loose on purpose: what makes a
task ceiling safe to set and a memory limit not is that the gap between
"certainly enough" and "certainly protective" spans two orders of magnitude.
ulimitsstays absent and the file records why —RLIMIT_NPROCis enforcedper real UID kernel-wide, and
alertmanager/snmp-exporterrun as theoperator's own uid via
RENDER_UID.--storage.tsdb.retention.size=12GB, plumbed throughPROMETHEUS_RETENTION_SIZE.PrometheusSizeRetentionActive, with promtool cases for firing and quiet.Why 12 GiB and not 4
The first draft used 4 GiB, reasoning from the 1021 MiB currently on disk. That
was wrong:
prometheus_tsdb_time_retentions_totalis0, so nothing has everbeen evicted and the store is still filling. Per-block write rates over the
retained window:
The step at 08-17 is #62 fixing cAdvisor. Steady state at the current rate is
30 × 72.9 + ~150 MiBWAL/head = 2.28 GiB, or 2.73 GiB onceSarumangets an agent (#88). 4 GiB would have been 1.5x against an already-planned
estate — a second retention policy wearing a ceiling's clothes, in a repo that
promises 30 days in four places. 12 GiB is 4.4x that and 12% of the volume.
The comment is also explicit about what the flag cannot do: the budget counts
the WAL and m-mapped head chunks but only ever evicts persistent blocks, so a
flood through the remote-write receiver lands somewhere the ceiling can never
reclaim. It bounds sustained growth. Bounding the flood is #182's job.
Verified on the lab, in two deploy cycles
init/pids_limitappliedinit=trueon all seven;pids.maxread back from the kernel as 512, 1024 on alloy2495 → 20, zero defunct children, flat across five healthcheck intervalsprometheus_tsdb_retention_limit_bytes0 → 12884901888prometheus_tsdb_size_retentions_total= 0inactivemake validatepasses. (editorconfig-checkerfails locally on four gitignoredalertmanager/.rendered/*files thatmake renderwrites without a trailingnewline — pre-existing, untouched here, and invisible to CI, which never
renders. Filed separately.)
Deliberately not done
mem_limit/memswap_limit→ #114.cpusprevents nothing at 0.4 of 4 cores.read_only/cap_drop/security_optare the group #71 itself calls "worthdeciding on separately". Filed instead:
ContainerCpuThrottledis unfireable: it divides CFS counters thatare permanently zero with no CPU quota set, and has no test. ContainerHighMemory cannot fire — no service sets a memory limit #63 again, one
rule down in the same file. The fix is a test plus an honest comment, not
adding
cpusto manufacture the condition.read_only+tmpfs: five services safe from config inspection,grafana needs a soak, alloy pointless while privileged.
cap_drop: [ALL]+no-new-privilegeson the six non-Alloyservices.
privileged: true+ docker socket +/:/rootfsis root on the host and read access to the age key. The largestitem in the file; No resource limits on any container, and no size bound on retention #71 raises it in one clause, and Run the stack read-only where it can be, and record where it cannot #186/Drop capabilities and set no-new-privileges on the six non-Alloy services #187 are worth much
less until it is done.
disk.
#114 has a comment recording the two things this settles: its 14-day gate cannot
be satisfied on its own terms, and
memswap_limitis decidable today with nohistory.
🤖 Generated with Claude Code