Контейнер перезапустился с 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 — чтобы не повторилось.