Min Hetzner-maskine kørte rent i 22 dage. Så, omkring kl. 00:40 i nat, blev den tavs. Ingen alarmer, ingen SSH, ingen ping. Ingen mails fra Hetzner — de generer dig ikke, når kassen er levende nok til ikke at udløse deres hardware-watchdogs, men død nok til at intet svarer indefra.
En manuel hardware-reset fra Robot-panelet hentede den tilbage her til morgen — hurtigt, fordi vi allerede havde styr på BIOS-boot-rækkefølgen og GRUB efter et tidligere eventyr. Det interessante spørgsmål var ikke hvordan jeg fik den op igen. Det var hvad der egentlig slog den ihjel.
Sidste gang det her skete, måtte jeg bede Hetzner om at koble en konsol på, så jeg kunne se hvad maskinen lavede. Denne gang var en simpel reboot nok — og så graven i det bagefter.
Ingen OOM, ingen panic, ingen log
Første instinkt ved enhver Linux-død: journalctl -b -1. Hvis kernens OOM-killer havde fyret, ville der være umiskendelige spor — invoked oom-killer, Out of memory: Killed process, fejlede page-allokeringer.
Der var ingen.
Ingen kernel-panic. Ingen allokeringsfejl. Bare tre ensomme linjer fra systemd-journald om at den »under memory pressure, flushing caches« omkring 00:37, og så stoppede journald simpelthen med at skrive kl. 00:40:58. De allersidste linjer er kedelige Docker-health-check-timeouts — den slags der sker når noget er overbelastet, men stadig i live. Så intet.
Det er ikke OOM. Det er et hang.
Det sysstat fangede
Heldigvis havde sysstat stille samplet systemtilstanden hvert 10. minut i hele den 22-dages oppetid — én fil per dag under /var/log/sysstat/. Den rygende pistol lå i sa13:
| Tid | %commit | Load avg | %memused |
|---|---|---|---|
| 23:10 | 93,2 % | 2,28 | 39,6 % |
| 23:20 | 112,3 % | 401,5 | 45,2 % |
| 23:30 | 122,8 % | 697,1 | 45,1 % |
| 23:40 | 158,4 % | 2.098 | 48,2 % |
| 23:50 | 222,8 % | 5.369 | 51,8 % |
| 00:40 | 498,2 % | — | 68,1 % |
To kolonner betyder noget.
%commit er forholdet mellem committed hukommelse og fysisk RAM plus swap. Committed hukommelse er det, processerne har bedt om — via mmap, malloc, brk — uanset om de faktisk har rørt det endnu. Linux defaulter til vm.overcommit_memory=0: »heuristisk« overcommit. Kernen uddeler gladeligt langt mere virtuel adresseplads end der fysisk findes, og vædder på, at de fleste processer aldrig bruger alt, hvad de reserverer. (Og det gør de som regel heller ikke. Java-heaps reserverer gigabytes, de aldrig rører. Node's V8 gør lignende tricks.)
Kl. 23:20 krydsede commit 100 %. Kl. 23:50 var den på 222 %. Kl. 00:40 var den på 498 % — næsten 678 GB committed mod 125 GB fysisk RAM og 4 GB swap.
Load average gik fra 2,28 til 5.369 på fyrre minutter. Det er tusindvis af tråde i kø, der venter på noget, de ikke engang kan dræbes ud af.
Hvad der faktisk skete
Jeg havde startet noget tungt arbejde i en devcontainer omkring 23:20 — en pks brain extract, der spawnede hundredevis af arbejdere (det er app-niveau-historien fra samme nat). Men det præcise hvad er ikke pointen. Mønstret er.
Workload'en committede enorme mængder hukommelse. Et stykke tid er det fint — committed hukommelse er ikke rigtig hukommelse, før den bliver rørt. Men efterhånden som arbejdet kørte, begyndte det at røre pages. Kernen måtte bakke de commits op med fysisk RAM. De frie pages svandt.
Og så swap. Hver eneste swap-slot var optaget af noget, kernen havde besluttet at evicte tidligere. Der var ingen fri swap at evicte flere pages ned i. Så kernen sad fast i swap-ind/swap-ud-dansen: læs en page tilbage fra swap, skriv en anden ud, igen og igen. Klassisk swap-thrashing.
Disk-I/O klatrede. Processer, der havde brug for hukommelse, blokerede på I/O. De gik i D-state — uafbrydelig søvn — og hobede sig op. Load average eksploderede. Kernens OOM-heuristik holdt mund, for set fra dens stol virkede reclaim: pages bevægede sig mellem RAM og swap. Det ligner succes, ikke fejl.
Til sidst var I/O-køen så dyb, at systemet ikke kunne komme videre med noget som helst. Inklusive at skrive journal-linjer. Inklusive at svare på ping. Inklusive SSH-dæmonens accept-loop.
Kassen var ikke løbet tør for hukommelse i OOM-forstand. Den var deadlocked på sin egen swap.
Læringen — og fiksen
Den faktiske fiks er uglamourøs: begræns hvor meget hukommelse hver container må bruge. Docker har understøttet det via cgroups i årevis; jeg havde bare ikke brugt det på en host, hvor jeg kører mange devcontainere.
Påført alle eksisterende containere, live, uden genstart:
for c in $(docker ps -aq); do
docker update --memory=64g --memory-swap=64g "$c"
done
--memory-swap=64g (lig med --memory) slår swap helt fra for containeren — hvilket er det rigtige her, eftersom swap-thrashing var fejlmoden. Hvis en container prøver at overskride loftet, fyrer OOM-killeren mod processer inde i den containers cgroup alene. Host'en forbliver rask.
Det er den anden halvdel af spawn smalt-historien: app-laget skal ikke spilde tools det ikke bruger — men selv en velopdragen app skal host'en kunne overleve. Grænser per container er den forsikring.
Vis hele forensikken + defense-in-depth
Hvorfor kernen aldrig greb ind
Et par grunde til, at OOM-killeren aldrig vågnede:
- Fysisk hukommelse var kun 68 % brugt på toppen. Rigeligt var teknisk set frit eller i caches. Men fragmenteret og omstridt.
- Reclaim »lykkedes«. Pages bevægede sig mellem RAM og swap. Kernens bogføring ser bevægelse, ikke fejl.
- OOM reagerer på allokeringsfejl, ikke på langsomhed. Swap-thrashing gør allokeringer meget, meget langsomme — men de fejler ikke direkte.
Systemet kom teknisk set fremad, lige indtil alting timede ud.
Ingen default-grænse på nye containere
Docker har ingen global »default memory limit«-indstilling, hvilket er irriterende, men en kendt begrænsning. Workaround'en er et cron-job på én linje, der fanger alt nyt inden for et minut:
sudo tee /usr/local/bin/docker-default-memcap.sh > /dev/null << 'EOF'
#!/bin/bash
CAP="${1:-64g}"
for cid in $(docker ps -aq 2>/dev/null); do
lim=$(docker inspect -f '{{.HostConfig.Memory}}' "$cid" 2>/dev/null)
if [ "$lim" = "0" ]; then
name=$(docker inspect -f '{{.Name}}' "$cid" 2>/dev/null | sed 's|^/||')
docker update --memory="$CAP" --memory-swap="$CAP" "$cid" > /dev/null 2>&1 \
&& logger -t docker-memcap "Applied $CAP cap to $name"
fi
done
EOF
sudo chmod +x /usr/local/bin/docker-default-memcap.sh
(sudo crontab -l 2>/dev/null; echo "* * * * * /usr/local/bin/docker-default-memcap.sh 64g") | sudo crontab -
Enhver container, der laves af VS Code Dev Containers, Coolify-deploys, manuel docker run — bliver cappet inden for ca. 60 sekunder. Holder på tværs af container-genstarter.
Defense in depth
Mens vi var i gang, et par billige diagnostiske opgraderinger til næste gang.
Hukommelses-monitor-cron, fanger tilstanden hvert 5. minut, så næste mysterium kommer med data:
sudo tee /usr/local/bin/mem-monitor.sh > /dev/null << 'EOF'
#!/bin/bash
{
echo "=== $(date -Iseconds) ==="
free -h
cat /proc/buddyinfo | grep -E "DMA32|Normal"
ps -eo rss,user,comm --sort=-rss | head -11
docker stats --no-stream --format '{{.Name}} {{.MemUsage}} ({{.MemPerc}})' | head -15
} >> /var/log/mem-monitor.log 2>&1
EOF
sudo chmod +x /usr/local/bin/mem-monitor.sh
(sudo crontab -l 2>/dev/null; echo "*/5 * * * * /usr/local/bin/mem-monitor.sh") | sudo crontab -
Hung-task-kernel-advarsler. Hvis noget blokerer i >120 sekunder, logger kernen nu et stack-trace — det ville have givet os en konkret tråd at give skylden i nat:
sudo tee /etc/sysctl.d/99-debug-hung.conf > /dev/null << 'EOF'
kernel.hung_task_timeout_secs = 120
kernel.hung_task_warnings = 10
kernel.softlockup_all_cpu_backtrace = 1
EOF
sudo sysctl -p /etc/sysctl.d/99-debug-hung.conf
De fire ting, jeg tog med
- OOM er ikke den eneste måde at dø af hukommelse på. Swap-thrashing dræber tavst. Hvis du nogensinde har haft en Linux-kasse, der »frøs« uden tydelig grund og uden logs, der forklarede det — er det her sandsynligvis hvad der skete.
%commitbetyder mere end%memusedfor at forudsige ballade. Usage viser, hvad der bliver rørt lige nu. Commit viser, hvad der vil være brug for, hvis processerne rører det, de allerede har reserveret. Når commit klatrer op i flere hundrede procent, spiller du kylling med kernen.- Default-Docker har ingen ressourcegrænser. En enkelt uncapped container, med en eftergivende overcommit-politik, kan absolut tage host'en ned. Kernens default er »stol på alle«. Container-grænser er, hvordan du holder op med det.
sysstater uvurderlig og undervurderet. Default-on i Ubuntu, sampler hvert 10. minut, gemmer 28 dage. Nul konfiguration. Den koster intet at have og sparede mig timers gætteri.
Cap'sene er på. Monitoren kører. Hung-task-advarslerne fyrer næste gang. Vi får se, hvor lang den næste oppetid bliver.
