Заметка

OOMKilled-форензика: от pmap до cgroups memory.stat

Exit code 137 не оставляет ни stack trace, ни финальной строки в логах, а дашборд клянётся, что памяти хватало. Инструменты, которые отвечают на вопрос «куда ушла память», — пока под ещё жив.

Контейнер перезапустился с Reason: OOMKilled, exit code 137. В логах — обрыв на полуслове: SIGKILL приходит от ядра, и хендлером его не поймать. Дашборд при этом показывает спокойные 60% памяти. Эта комбинация — самый частый сценарий «тихого» рестарта в Kubernetes, и реакция «поднять лимит и надеяться» лечит симптом ровно до следующего пика трафика.

Цепочка, которую стоит знать наизусть

Убивает не Kubernetes — приговор выносит kernel OOM Killer. Лимит памяти из pod spec транслируется в cgroup; когда процесс пытается выделить сверх него, ядро выбирает жертву и шлёт SIGKILL. Контейнер выходит с кодом 137 (128 + 9), kubelet помечает его OOMKilled и при restartPolicy: Always перезапускает только контейнер, не под: IP сохраняется, sidecar'ы живы, volume'ы примонтированы. Повторение по кругу даёт CrashLoopBackOff с экспоненциальным backoff до пятиминутного потолка.

Полезный контраст: за превышение CPU Kubernetes не убивает — троттлит через cgroup bandwidth. CPU-превышение деградирует latency, memory-превышение обрывает процесс без предупреждения. Это два разных failure mode, и смешивать их в одном алерте — ошибка.

Почему дашборд «не видел»

Классический случай: поды регулярно убиваются, а график памяти не поднимается выше 65%. Корневая причина — пики длиной 200–500 мс: достаточно, чтобы пробить hard limit, и слишком коротко для scrape interval в 15–30 секунд. Сглаживание добивает картину: avg() на длинном окне показывает 320 Mi там, где пик был 600.

Третья ловушка — не та метрика. Ядро принимает OOM-решение по working set, поэтому опора — container_memory_working_set_bytes, а не heap приложения. Для детекта пиков:

max_over_time(container_memory_working_set_bytes[30s])
 # коррелировать с:
kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}

Пики — не баги: парсинг крупного JSON, GC-паузы, всплеск конкурентных запросов. В сочетании с подрезанными впритык лимитами они и есть триггер.

Хирургия на живом поде

Пока под жив — между рестартами — форензику делают через ephemeral container с shared process namespace:

kubectl debug -it <pod> --image=nicolaka/netshoot --target=<container>

--target открывает процессы основного контейнера: cat /proc/1/status | grep VmRSS — текущий footprint, ls /proc/1/fd | wc -l — утечка дескрипторов. Дальше — diff двух снимков pmap -x 1 с интервалом в пять минут: какие сегменты выросли. Если diff явного роста не показывает — eBPF memleak из BCC выводит stack traces всех неосвобождённых аллокаций. Всё это без рестарта и пересборки образа, работает и на distroless.

Ground truth — cgroups memory.stat

Метрики cadvisor вводят в заблуждение на shared-страницах: libc и libssl, разделяемые контейнерами, RSS засчитывает каждому полностью. Честную картину даёт сам cgroup:

cat /sys/fs/cgroup/memory.stat   # cgroup v2
 # anon  — heap и стеки: «настоящая» память контейнера
 # file  — page cache: ядро освободит его раньше, чем убивать
 # shmem — разделяемая между процессами

Реальный OOM-риск — memory.current − file − shmem, то есть только heap. PSS (его показывает smem) делит shared-страницы поровну между процессами — честная мера per-container footprint. И частая ложная тревога: pgmajfault — major page faults, симптом I/O-bottleneck'а, а не OOM-сигнал; алерты на него ловят не то.

Чинить, а не молиться

Сводится к четырём ходам. Лимиты — от p99 working set: limit = p99 × 1.5, при истории OOM — × 2.0; целевой baseline — 50% лимита вместо 70%. В алёртинге — max_over_time на коротких окнах вместо avg(). Нагрузочное тестирование — с реалистичными burst'ами, а не ровной синтетикой. И с Kubernetes 1.36 есть ранний сигнал: kubelet_psi_memory_full (PSI, GA) — доля времени, когда все потоки ждут память; это кандидат в OOM до первого SIGKILL.

OOMKilled перестаёт быть мистикой, когда расследование идёт по слоям: kubectl describe — что случилось; PromQL с правильным разрешением — когда; pmap и memory.stat — куда ушла память; right-sizing — чтобы не повторилось.

© 2026 axyi.ru · CC BY 4.0