# nh3-dev: make memory exhaustion diagnosable after the fact. # # Context: three memory-exhaustion events in 14 days (2026-08-14, 2026-08-26, # 2026-08-28) with the interval halving. forseti reported that none could be # attributed because "kernel messages are not being persisted to journald". # # ⚠ THAT PREMISE WAS WRONG, and the way it was wrong matters more than the fix. # journald on this box IS persistent and HAS every OOM report: 15,068 kernel # entries in the 82-day previous boot, 351 OOM records overall, full task tables # with per-process RSS. `journalctl -b -1 -k` returned "one entry" because it was # run by a user in neither `adm` nor `systemd-journal` — journalctl silently shows # you only your OWN messages and prints the reason as a hint. The same artifact # also produced the "journal stops mid-line at 05:36:08 with no shutdown # sequence" claim: the true boot -1 boundary is 05:47:04, and OOM kills are # recorded at 05:38, 05:40 and 05:42. # # So step 1 is a GROUP MEMBERSHIP fix, not a logging fix. The evidence was # always there and unreadable. # # What the evidence says, now that it can be read: the hog is Claude Code. # `/home/lkraven/.local/share/claude/versions/2.1.220` is the versioned CC binary, # so OOM victims named `2.1.220` / `2.1.177` / `2.1.168` are CC sessions, as are # the ones named `claude`. Largest single anon-rss recorded: 18.4 GB (2.1.177, # Aug 14), with 15.8 GB seen twice. Everything else killed — althing-forseti, # caddy, ttyd, zellij, the althing daemons — is 30-55 MB collateral. # # steps 2-4 add the timeseries that the journal cannot give: the journal records # the moment of the kill, not the ramp toward it, and the Aug 28 event was a hard # lockup where the box never got far enough to log a coherent sweep. # - sysstat -> system-wide memory/CPU timeseries (the ramp) # - atop -> PER-PROCESS timeseries (which session, and how fast) # atop is the one that answers "which of the dozen sessions", which sar cannot. # # Run: scripts/elway infra-ops@nh3-dev --playbook playbooks/nh3-dev-memory-forensics.yaml # Rerunnable: a second run shows every step `skipped` or `ok`. vars: # The interactive/agent user whose sessions read the journal. journal_user: lkraven # Debian's journald ACL grants read to `adm` explicitly (getfacl shows # group:adm:r-x); `systemd-journal` owns the files. `adm` is the documented # Debian path and the one the ACL names, so use it. journal_group: adm # 5 min, not Debian's default 10 — a CC session can add several GB inside one # 10-minute bucket, which is exactly the resolution the ramp needs. sar_interval: "*:00/05" # 60s per-process sample. ~7 generations keeps this under ~1 GB against 80 GB free. atop_interval: "60" atop_generations: "7" steps: - name: Grant the agent user journal read access (THE actual fix for "no evidence") shell: sudo usermod -aG {{ journal_group }} {{ journal_user }} when: "! id -nG {{ journal_user }} | grep -qw {{ journal_group }}" - name: Install sysstat and atop shell: sudo DEBIAN_FRONTEND=noninteractive apt-get install -y sysstat atop when: "! dpkg -s sysstat >/dev/null 2>&1 || ! dpkg -s atop >/dev/null 2>&1" - name: Enable sysstat collection in /etc/default/sysstat # The package ships ENABLED="false" and the timer is a no-op until this flips. shell: sudo sed -i 's/^ENABLED=.*/ENABLED="true"/' /etc/default/sysstat when: "! grep -qxF 'ENABLED=\"true\"' /etc/default/sysstat 2>/dev/null" - name: Tighten the sysstat collection interval to 5 minutes shell: | sudo mkdir -p /etc/systemd/system/sysstat-collect.timer.d printf '[Timer]\n# Default is */10. A CC session can add several GB inside one 10-minute\n# bucket; 5 min is the resolution the memory ramp actually needs.\nOnCalendar=\nOnCalendar=%s\n' '{{ sar_interval }}' | sudo tee /etc/systemd/system/sysstat-collect.timer.d/override.conf >/dev/null when: "! grep -qxF 'OnCalendar={{ sar_interval }}' /etc/systemd/system/sysstat-collect.timer.d/override.conf 2>/dev/null" - name: Configure atop for 60s per-process sampling with 7-day retention shell: | sudo sed -i 's/^LOGINTERVAL=.*/LOGINTERVAL={{ atop_interval }}/' /etc/default/atop sudo sed -i 's/^LOGGENERATIONS=.*/LOGGENERATIONS={{ atop_generations }}/' /etc/default/atop when: "! grep -qxF 'LOGINTERVAL={{ atop_interval }}' /etc/default/atop 2>/dev/null || ! grep -qxF 'LOGGENERATIONS={{ atop_generations }}' /etc/default/atop 2>/dev/null" - name: Reload systemd and enable the collectors shell: | sudo systemctl daemon-reload sudo systemctl enable --now sysstat.service sysstat-collect.timer sysstat-summary.timer sudo systemctl enable --now atopacct.service atop.service atop-rotate.timer sudo systemctl restart atop.service - name: Seed one sysstat sample so sar has data immediately shell: sudo /usr/lib/sysstat/sa1 1 1 verify: - name: Agent user is now in the journal-reading group # `sg` evaluates the membership WITHOUT waiting for a re-login, so this # asserts the effective grant rather than the /etc/group substring. shell: sudo -u {{ journal_user }} sg {{ journal_group }} -c 'journalctl -b -1 -k --no-pager 2>/dev/null | wc -l' | awk '{ if ($1 > 100) exit 0; else exit 1 }' changed_when: "false" - name: sysstat collection timer is active shell: systemctl is-active --quiet sysstat-collect.timer changed_when: "false" - name: sysstat is collecting at the 5-minute cadence # ⚠ Assert the EFFECTIVE value, not the string we wrote. systemd normalises # `*:00/05` to `*-*-* *:00/5:00`, so grepping for our own input fails while # the setting is live — which is exactly how this verify failed on the first # run and briefly looked like the override had not applied. shell: systemctl show sysstat-collect.timer -p TimersCalendar | grep -qF '*:00/5:00' changed_when: "false" - name: sar can actually read a memory timeseries (not just that the timer exists) shell: sar -r 2>/dev/null | tail -2 | grep -qE '[0-9]' changed_when: "false" - name: atop daemon is running shell: systemctl is-active --quiet atop.service changed_when: "false" - name: atop is writing a readable per-process log shell: sudo test -s /var/log/atop/atop_$(date +%Y%m%d) changed_when: "false"