Kubernetes OOMKilled: как диагностировать и устранить убийство подов по памяти без угадывания лимитов

Коротко:

  • Статус OOMKilled означает, что ядро Linux убило процесс контейнера, потому что тот превысил выставленный memory limit.
  • Первый шаг - kubectl describe pod и kubectl get events: там видно последний exit code и причину завершения.
  • Корень проблемы чаще всего в одном из двух: лимит выставлен слишком низко или в приложении реальная утечка памяти.
  • Не угадывай значения вручную - используй данные metrics-server и рекомендации VPA в режиме Off или Initial.
  • Правильная формула: request покрывает рабочий базовый уровень, limit дает буфер под пики, но не превышает то, что нода реально может выделить.
  • После изменения лимитов нужно наблюдать метрику container_memory_working_set_bytes минимум один рабочий цикл нагрузки.

Под падает, в статусе написано OOMKilled, Slack пищит, дежурный открывает ноутбук в 3 ночи. Знакомо. Хуже всего то, что следующим шагом команда обычно идет не к данным, а к интуиции: "поставим в два раза больше и посмотрим". Иногда помогает, чаще - нет.

Эта статья про то, как разобраться с убийством пода по памяти правильно: прочитать нужные сигналы, отличить неверный лимит от утечки, подобрать значения по реальным метрикам и не попасть в ту же ситуацию через неделю.

Статья про resource limits в общем уже есть в нашем блоге. Здесь - только OOMKilled: конкретный сценарий, инструменты диагностики и методика подбора значений без перебора вслепую.

Что происходит под капотом

Когда контейнер запускается с выставленным memory limit, Kubernetes настраивает cgroup для этого контейнера с соответствующим ограничением. Ядро Linux следит за тем, чтобы процесс не вышел за это значение. Как только потребление памяти достигает лимита, ядро вызывает OOM Killer - механизм, который выбирает процесс для принудительного завершения.

OOM Killer смотрит на oom_score каждого процесса. Контейнерные процессы, как правило, получают высокий балл, поэтому они и оказываются первыми жертвами. Процесс завершается с кодом 137 (128 + SIGKILL). Именно этот код и фиксируется в статусе пода как OOMKilled.

Важная деталь: если лимит не выставлен вообще, контейнер может занять всю доступную память на ноде. Это приводит к тому, что OOM Killer начинает убивать уже системные процессы или процессы других подов. Ситуация хуже, потому что становится менее предсказуемой.

Диагностика: с чего начать

Первое, что нужно сделать - получить полную картину произошедшего. Не гадать, а читать данные.

Шаг 1. Смотрим статус и историю перезапусков

kubectl get pod  -n 

В выводе будет колонка RESTARTS. Если цифра растет - под падает регулярно, а не единожды. Это важно: разовый сбой при старте и бесконечная петля перезапусков требуют разного подхода.

Шаг 2. kubectl describe pod - главный источник информации

kubectl describe pod  -n 

Ищем в выводе раздел Last State и поле Reason. При проблеме с памятью там будет:

Last State:     Terminated
  Reason:       OOMKilled
  Exit Code:    137
  Started:      ...
  Finished:     ...

Также смотрим секцию Limits и Requests - именно там видно, что реально выставлено. Часто выясняется, что лимит был скопирован из какого-то шаблона и никогда не пересматривался.

Шаг 3. События в namespace

kubectl get events -n  --sort-by='.lastTimestamp'

Здесь можно увидеть события типа OOMKilling с точным временем. Если нода под давлением, дополнительно появятся события NodeHasDiskPressure или MemoryPressure - это сигнал, что проблема шире одного пода.

Шаг 4. Логи до падения

kubectl logs  -n  --previous

Флаг --previous показывает логи предыдущего запуска контейнера, то есть то, что было до падения. Иногда приложение само пишет, что исчерпывает память: Java выдает OutOfMemoryError, Go - runtime: out of memory, Node.js - FATAL ERROR: Reached heap limit. Эти сообщения помогают понять, это ошибка в коде или просто узкий лимит.

Отличаем тесный лимит от утечки памяти

Это ключевой вопрос диагностики. От ответа зависит, что делать дальше: поднять лимит или чинить приложение.

Признак Тесный лимит Утечка памяти
График потребления памяти Колеблется вокруг лимита при нагрузке Монотонно растет даже в покое
Падение при росте трафика Да, коррелирует с нагрузкой Падение через фиксированный интервал
Поведение после перезапуска Работает нормально до следующего пика Снова начинает расти, падает через N часов
Сообщения в логах Нет специфичных ошибок памяти OOM-ошибки от рантайма приложения

Если под падает строго через одинаковые промежутки времени независимо от нагрузки - это почти всегда утечка. Поднятие лимита только отодвинет момент падения, но не решит проблему.

Смотрим реальное потребление через метрики

Прежде чем менять любые значения, нужно знать, сколько памяти контейнер реально потребляет. Для этого нужен metrics-server или Prometheus с kube-state-metrics.

Через metrics-server

kubectl top pod  -n  --containers

Вывод покажет текущее потребление по каждому контейнеру в поде. Это снимок в моменте - полезно для первичной оценки, но недостаточно для принятия решений.

Ключевая метрика в Prometheus

Главная метрика для работы с памятью контейнера - container_memory_working_set_bytes. Именно по ней Kubernetes принимает решение об OOM Kill, а не по container_memory_usage_bytes, которая включает кешированные данные файловой системы.

Запрос для Grafana:

container_memory_working_set_bytes{
  namespace="",
  pod=~".*",
  container!=""
}

Смотри на этот график за период в несколько дней, включая часы пиковой нагрузки. Максимальное значение и будет отправной точкой для выставления лимита.

Не путай метрики: container_memory_usage_bytes включает page cache и может быть значительно выше реального потребления приложением. Ядро убивает процесс по working_set_bytes, поэтому именно эту метрику нужно мониторить и сравнивать с лимитом.

Подбираем значения через VPA

Ручной подбор лимитов - это всегда компромисс между "слишком мало" и "слишком щедро". Vertical Pod Autoscaler решает эту задачу на основе исторических данных о реальном потреблении.

Режимы работы VPA

Режим Что делает Когда использовать
Off Только показывает рекомендации, ничего не меняет Начальный аудит, production без автоизменений
Initial Применяет рекомендации только при создании нового пода Стабильные деплойменты с контролируемым рестартом
Auto Пересоздает поды для применения новых значений Dev/staging, нагрузки с предсказуемым поведением

Для диагностики и первичной настройки лучше всего подходит режим Off. Создаешь VPA-объект, ждешь несколько дней сбора данных, смотришь рекомендации.

Пример манифеста VPA в режиме Off

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: my-app-vpa
  namespace: production
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-app
  updatePolicy:
    updateMode: "Off"

Через несколько дней смотрим рекомендации:

kubectl describe vpa my-app-vpa -n production

В выводе будет секция Recommendation с полями lowerBound, target и upperBound для каждого контейнера. Поле target - это рекомендуемое значение request. Лимит разумно ставить как target * 1.2 - 1.5, добавляя буфер под кратковременные пики.

Пример расчета: VPA рекомендует target: 400Mi. Выставляем request: 400Mi, limit: 500Mi. Если приложение регулярно пробивает 500Mi при нагрузке - смотрим метрику за пиковые часы и корректируем. Если пробивает только в аномальных сценариях - возможно, стоит разобраться с кодом.

Memory requests и limits: разница, которую важно понимать

Эти два параметра часто путают или выставляют одинаково без понимания зачем. Коротко о главном различии:

Request - это то, что планировщик учитывает при размещении пода на ноду. Kubernetes гарантирует, что контейнер получит это количество памяти. Нода не будет перегружена, если суммарные requests не превышают её ёмкость.

Limit - это жесткий потолок потребления. Превысил - получи OOM Kill. Лимит не влияет на планирование, только на исполнение.

Распространенная ошибка: ставить limit равным request для всех подов. Это делает планирование предсказуемым, но приводит к избыточному резервированию. Гораздо хуже - ставить лимит без request или оставлять request равным нулю: планировщик может разместить 20 подов на ноду, которая физически не вытянет их суммарное потребление.

Для памяти Kubernetes не делает throttling при приближении к лимиту (в отличие от CPU, где процесс просто замедляется). Память - это kill or nothing: или процесс живет, или его убивают немедленно.

Типичные причины, которые приводят к падению

Зная, откуда чаще всего растет проблема, проще строить диагностику.

  • Лимит выставлен по умолчанию или скопирован из шаблона. Никто не смотрел на реальное потребление. Сервис вырос, лимит остался старым.
  • JVM-приложение без явного ограничения heap. Java по умолчанию берет до 25% от доступной памяти хоста. Внутри контейнера она "видит" память ноды, а не лимит cgroup. Нужно явно ставить -Xmx или использовать -XX:+UseContainerSupport (включен по умолчанию с Java 10+).
  • Memory leak в коде. Накапливаются объекты, не освобождаемые GC. Особенно часто встречается в долгоживущих воркерах и сервисах с кешем без TTL.
  • Пиковая нагрузка не была учтена при подборе лимита. Лимит выставили по среднему потреблению, а не по максимальному. При первом серьезном трафике под упал.
  • init-контейнер или sidecar потребляет больше ожидаемого. Лимиты ставятся только на основной контейнер, а Envoy или Fluent Bit незаметно съедает значительную долю.
  • Нода под давлением памяти. Даже при корректных лимитах kubelet может вытеснить под, если на ноде заканчивается физическая память. В этом случае в событиях будет Evicted, а не OOMKilled, но разобраться стоит.

Профилирование памяти в приложении

Если данные метрик указывают на утечку, нужно профилировать само приложение. Инструменты зависят от стека:

  • Java: JVM Flight Recorder, jmap -histo, VisualVM. Смотри на классы с растущим количеством экземпляров.
  • Go: встроенный pprof (/debug/pprof/heap). Легко подключить даже в production при условии закрытого порта.
  • Node.js: --inspect + Chrome DevTools heap snapshot или пакет heapdump.
  • Python: tracemalloc, memory-profiler, objgraph.

Снимай heap snapshot в двух точках: сразу после старта и через несколько часов работы. Разница покажет, какие объекты накапливаются. Если разницы нет, а память растет - смотри на нативные зависимости и C-extensions.

Что проверить на уровне ноды

Иногда проблема не в конкретном поде, а в том, что нода перегружена по памяти в целом. Проверяем:

kubectl describe node  | grep -A 5 "Allocated resources"

Ищем строки memory requests и memory limits. Если суммарные requests близки к 100% - планировщик уже работает на пределе. Если limits значительно превышают физическую память ноды (overcommit) - при одновременном пике нескольких подов нода не выживет.

Также стоит проверить события самой ноды:

kubectl get events -n kube-system | grep -i memory

Node Memory Pressure: Если kubelet видит, что свободной памяти на ноде меньше --eviction-hard порога (по умолчанию 100Mi), он начинает вытеснять поды с низким приоритетом. Это отличается от OOM Kill: под завершается с состоянием Evicted, а не OOMKilled. Но корневая причина та же - недостаток памяти на уровне инфраструктуры.

Чеклист: что сделать после каждого инцидента с памятью

  1. Зафиксируй время события и проверь корреляцию с всплеском нагрузки или деплоем.
  2. Выполни kubectl describe pod и убедись, что причина именно OOMKilled, а не Evicted.
  3. Прочитай логи предыдущего запуска через --previous. Есть ли ошибки памяти от самого приложения?
  4. Посмотри график container_memory_working_set_bytes за последние 7-14 дней. Как выглядит тренд?
  5. Сравни максимальное зафиксированное потребление с текущим лимитом.
  6. Если потребление монотонно растет - это утечка. Не поднимай лимит без анализа кода.
  7. Если потребление коррелирует с нагрузкой и не растет в покое - лимит занижен. Пересчитай по метрикам.
  8. Разверни VPA в режиме Off, собери рекомендации за несколько дней.
  9. Обнови limits с буфером 20-50% от наблюдаемого максимума.
  10. После изменения следи за метрикой минимум один полный рабочий цикл (обычно 5-7 дней).

Профилактика повторных падений

Разовая настройка лимитов - это не решение навсегда. Приложения меняются, нагрузка растет, команды добавляют фичи. Несколько практик, которые помогают не возвращаться к этой проблеме каждый месяц:

Алерт на приближение к лимиту. Настрой срабатывание при достижении 80% от лимита по метрике container_memory_working_set_bytes. Это дает время отреагировать до падения.

LimitRange в namespace. Устанавливает минимальные и максимальные допустимые значения. Не позволяет деплоить поды без явных limits или с заниженными requests.

apiVersion: v1
kind: LimitRange
metadata:
  name: memory-defaults
  namespace: production
spec:
  limits:
  - type: Container
    default:
      memory: 512Mi
    defaultRequest:
      memory: 256Mi
    max:
      memory: 2Gi

VPA в режиме Initial для новых деплойментов. Новые поды стартуют с вменяемыми значениями, основанными на истории похожих контейнеров.

Регулярный ревью значений. Раз в квартал проверяй разницу между выставленными requests и реальным потреблением по метрикам. Если потребление стабильно составляет 30% от выставленного request - деньги тратятся впустую. Если регулярно достигает 90% от лимита - нужно пересматривать.

часто задаваемые вопросы

OOMKilled - процесс убит ядром Linux, потому что контейнер превысил memory limit cgroup. Evicted - kubelet вытеснил под с ноды, потому что на самой ноде заканчивается физическая память. В первом случае смотри на лимиты конкретного пода, во втором - на загрузку ноды в целом и настройки eviction threshold.

Exit code 137 и статус OOMKilled - одно и то же событие с разных точек зрения. Kubernetes интерпретирует код 137 (SIGKILL от OOM Killer) и выставляет reason: OOMKilled . Если видишь 137 без явной причины - проверяй kubectl describe pod , там будет полная картина.

Request - это гарантия, которую Kubernetes дает поду при планировании: на выбранной ноде столько памяти точно будет доступно. Limit - жесткий потолок: превысил - контейнер будет принудительно завершен. Request влияет на то, куда под попадет, limit - на то, что будет, когда он там работает.

Технически можно. Но тогда один контейнер с утечкой или пиком нагрузки может исчерпать всю память ноды и спровоцировать падение других подов. Для production это неприемлемо. Если не знаешь правильное значение, используй LimitRange с разумными дефолтами и уточняй по метрикам.

Нет. HPA добавляет реплики пода, но каждая реплика - отдельный контейнер со своим потреблением. Если под падает из-за утечки или тесного лимита, больше реплик просто означает больше параллельных падений. Решать нужно либо лимит, либо код.

Наблюдай за графиком container_memory_working_set_bytes в сравнении с выставленным лимитом. Хороший признак: потребление стабильно ниже лимита даже в часы пиковой нагрузки, и нет событий OOMKilling в логах namespace. Смотри минимум 5-7 дней, чтобы покрыть полный цикл нагрузки.

VPA обновляет рекомендации примерно каждые несколько минут на основе данных от metrics-server, но для надежных значений нужна история за несколько дней. Чем дольше VPA наблюдает за подом, тем точнее его рекомендации по target-значению.

Итог

Падение пода по памяти - это не повод паниковать и не повод угадывать. Это повод пройти диагностику по данным: прочитать события, посмотреть метрики, понять тренд. Если потребление коррелирует с нагрузкой - пересчитай лимит по реальному максимуму с буфером. Если память растет монотонно вне зависимости от трафика - сначала найди утечку, и только потом думай о значениях.

VPA в режиме Off - самый простой способ получить обоснованные цифры без ручного перебора. LimitRange на уровне namespace гарантирует, что следующий деплой не повторит ту же ошибку. Алерт на 80% от лимита дает время среагировать до того, как под упадет.

Большинство случаев OOMKilled решаются именно так: смотришь на данные, принимаешь обоснованное решение, наблюдаешь результат. Угадывание здесь не метод.