kai.kzLab

OOMKilled и exit code 137: почему ядро убило контейнер и как подобрать лимит памяти

Команда KAI Lab · · 8 мин чтения

OOMKilled значит, что процесс в контейнере превысил лимит памяти своей cgroup и ядро Linux убило его сигналом SIGKILL; код выхода 137 — это 128 + 9, где 9 — номер SIGKILL. Главная причина — лимит памяти меньше, чем приложению нужно на пике, и исправляют её замером реального потребления и правильным лимитом, а не удалением лимита.

Ниже — как устроено убийство по памяти, как отличить OOMKilled от других случаев с кодом 137, где найти подтверждение в журнале ядра и как выбрать requests и limits, чтобы история не повторилась.

Что происходит при OOMKilled

Лимиты контейнера задаёт kubelet вместе с container runtime, а исполняет ядро через cgroups. Лимит памяти становится лимитом cgroup контейнера. Когда процесс пытается выделить больше, включается подсистема out-of-memory ядра и завершает процесс в контейнере. Если это основной процесс, а политика перезапуска позволяет, Kubernetes поднимает контейнер заново.

Три детали, которые объясняют странности OOMKilled:

  • Лимит работает реактивно. В документации Kubernetes прямо сказано: ядро убивает процесс, когда замечает нехватку памяти, поэтому контейнер может ненадолго выйти за лимит и не умереть сразу.
  • SIGKILL нельзя перехватить. По man 7 signal, SIGKILL и SIGSTOP нельзя поймать, заблокировать или проигнорировать. Приложение не успевает ничего записать, и лог просто обрывается на середине.
  • На cgroup v2 убивают весь контейнер. По умолчанию kubelet включает для cgroup контейнера memory.oom.group, и процессы контейнера завершаются вместе. Старое поведение cgroup v1, когда убивали один процесс, возвращает параметр singleProcessOOMKill: true в конфигурации kubelet.

OOMKilled не то же самое, что java.lang.OutOfMemoryError. Это исключение бросает сама JVM, когда кончается её куча: приложение пишет его в лог и само решает, что делать. OOMKilled приходит снаружи, от ядра.

Как выглядит OOMKilled

В Kubernetes статус пода чередуется: сразу после убийства — OOMKilled, во время паузы перед рестартом — CrashLoopBackOff.

$ kubectl get pods -n shop
NAME                      READY   STATUS      RESTARTS      AGE
reports-7f9c6d8b5-kx4wd   0/1     OOMKilled   3 (41s ago)   12m

Причина надёжнее всего видна в kubectl describe pod, в блоке Last State:

$ kubectl describe pod reports-7f9c6d8b5-kx4wd -n shop
...
    State:          Waiting
      Reason:       CrashLoopBackOff
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
      Started:      Fri, 25 Sep 2026 14:02:11 +0500
      Finished:     Fri, 25 Sep 2026 14:05:47 +0500
    Ready:          False
    Restart Count:  3
    Limits:
      memory:  512Mi
    Requests:
      cpu:     250m
      memory:  512Mi

В Docker без Kubernetes то же самое показывает docker inspect:

$ docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' reports
true 137

Чем 137 отличается от 143

Когда процесс завершается по сигналу N, код выхода равен 128 + N. SIGTERM — сигнал 15, SIGKILL — 9.

  • 143 = 128 + 15, SIGTERM. Контейнер вежливо попросили остановиться: docker stop, удаление пода, выкатка новой версии, перезапуск по liveness probe. Приложение получило шанс закрыть соединения и выйти.
  • 137 = 128 + 9, SIGKILL. Процесс убили без предупреждения. Это OOM killer, но не только он: docker kill по умолчанию шлёт SIGKILL, а docker stop и Kubernetes добивают SIGKILL процесс, который не завершился после SIGTERM за отведённое время. В Docker это 10 секунд для Linux-контейнеров, в Kubernetes — terminationGracePeriodSeconds, по умолчанию 30 секунд.

Поэтому код 137 сам по себе не доказывает нехватку памяти. Смотрите на Reason: OOMKilled в Kubernetes или флаг OOMKilled в docker inspect. В событиях Docker разница видна сразу:

$ docker events --since 30m --filter container=reports --filter event=oom --filter event=die --filter event=kill
2026-09-25T14:05:47.118532114+05:00 container oom 5e1a...c3d9 (image=registry.example.kz/shop/reports:3.1.0, name=reports)
2026-09-25T14:05:47.204761983+05:00 container die 5e1a...c3d9 (exitCode=137, image=registry.example.kz/shop/reports:3.1.0, name=reports)

Если бы контейнер не уложился в таймаут остановки, вместо события oom были бы два kill: сначала с signal=15, через 10 секунд с signal=9, и потом die с exitCode=137.

Где найти подтверждение: dmesg на ноде

Ядро записывает каждое убийство в свой журнал. На ноде Kubernetes это можно посмотреть по SSH или через отладочный под с правами администратора:

$ kubectl debug node/worker-2 -it --image=ubuntu --profile=sysadmin
root@worker-2:/# dmesg -T | grep -i -E "out of memory|oom-kill"
[Fri Sep 25 14:05:47 2026] python3 invoked oom-killer: gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=969
[Fri Sep 25 14:05:47 2026] Memory cgroup out of memory: Killed process 30817 (python3) total-vm:1893312kB, anon-rss:520544kB, file-rss:10240kB, shmem-rss:0kB, UID:1000 pgtables:1432kB oom_score_adj:969

Важна первая половина строки. Memory cgroup out of memory значит, что процесс упёрся в лимит своего контейнера. Просто Out of memory: Killed process — что память кончилась на всей ноде и ядро выбирало жертву среди всех процессов. anon-rss показывает, сколько памяти процесс занимал в момент убийства.

При нехватке памяти на всей ноде жертву выбирают по oom_score с поправкой oom_score_adj, а её kubelet выставляет по QoS-классу пода: Guaranteed получают −997, BestEffort — 1000, Burstable — значение между ними, которое тем меньше, чем больше запрошено памяти. Поэтому поды без requests умирают первыми.

Requests и limits: что на что влияет

  • Request нужен планировщику: под попадёт только на ноду, где сумма requests помещается в доступную память. Контейнер может потреблять больше request, если на ноде есть свободная память.
  • Limit — потолок cgroup. Выше него контейнер не пустят: его процесс убьёт ядро.

Если контейнер ест больше своего request, а память на ноде заканчивается, дело может закончиться не OOMKilled, а выселением всего пода kubelet-ом. Это другой механизм, мы разобрали его в статье под Evicted.

Без лимита контейнер может занять всю память ноды. Документация Kubernetes предупреждает, что это ведёт к OOM на уровне ноды, и тогда под нож идут соседние сервисы. Администратор может задать лимит по умолчанию для пространства имён через LimitRange.

Пошаговая диагностика

Шаг 1. Причина завершения. kubectl describe pod, блок Last State: Reason: OOMKilled, Exit Code: 137.

Шаг 2. Текущее потребление против лимита. При установленном metrics-server:

$ kubectl top pod reports-7f9c6d8b5-kx4wd -n shop --containers
POD                       NAME      CPU(cores)   MEMORY(bytes)
reports-7f9c6d8b5-kx4wd   reports   212m         489Mi

В Docker — docker stats --no-stream, колонка MEM USAGE / LIMIT:

$ docker stats --no-stream reports
CONTAINER ID   NAME      CPU %     MEM USAGE / LIMIT   MEM %     NET I/O         BLOCK I/O       PIDS
5e1a0b7c3d9f   reports   38.12%    498.3MiB / 512MiB   97.32%    1.2MB / 845kB   4.1MB / 0B      9

Шаг 3. Журнал ядра. dmesg -T на ноде: убийство по лимиту cgroup или по памяти всей ноды.

Шаг 4. Характер роста. На графике памяти контейнера OOM на старте, под пиковой нагрузкой и при медленной утечке выглядят по-разному: вертикальный скачок, всплеск вместе с трафиком или ровная наклонная линия до потолка.

Шаг 5. Что изменилось. Новая версия, новый тип отчёта, выросший объём данных, изменённые параметры рантайма.

Причины OOMKilled: таблица

ПричинаКак узнатьКак исправить
Лимит ниже рабочего пикаOOMKilled под нагрузкой, потребление у самого лимитазамерить пик и поднять limits.memory с запасом
Утечка памятипотребление растёт ровной линией, перезапуски через похожие интервалыпрофилировать и чинить код; больший лимит только отодвигает падение
Куча рантайма больше лимитаOOMKilled без OutOfMemoryError в логахсогласовать кучу с лимитом: -XX:MaxRAMPercentage для JVM, --max-old-space-size для Node.js
Данные в памяти через tmpfsтом emptyDir с medium: Memory растётsizeLimit для тома или обычный диск
Память кончилась на всей нодев dmesg Out of memory без слов Memory cgrouprequests по реальному потреблению, резерв system-reserved и kube-reserved, больше нод
Код 137 без OOMOOMKilled: false, в событиях kill с signal=15, затем с signal=9обрабатывать SIGTERM, проверить terminationGracePeriodSeconds

Как подобрать лимит памяти

Лимит выбирают по замерам, а не по ощущениям. Снимите потребление под реальной нагрузкой за период, где были пики: утренний трафик, ночные отчёты, конец месяца. Request ставьте около обычного потребления, limit — на наблюдаемый пик с запасом.

Для критичных сервисов удобно ставить request равным limit по памяти и CPU. Такой под получает класс Guaranteed: его выселяют в последнюю очередь, а при нехватке памяти на ноде ядро выбирает его последним.

Убрать лимит — плохое решение. Сервис перестанет падать сам, но однажды займёт память соседей по ноде, и упадут уже они.

В Docker лимит задают флагом -m (--memory) или mem_limit в Compose. Есть нюанс со swap: если --memory-swap не задан, а на хосте есть swap, контейнер может использовать столько же swap, сколько памяти. Флаг --oom-kill-disable документация Docker советует включать только вместе с -m, иначе при нехватке памяти ядру придётся убивать системные процессы хоста.

Если контейнер с restart: always падает по памяти, снаружи это выглядит как сервис, который работает через раз. Как ловить такие перезапуски, мы разобрали в статье контейнер Docker постоянно перезапускается. В Kubernetes повторяющиеся OOMKilled приводят к CrashLoopBackOff.

Частые вопросы

Exit code 137 всегда означает нехватку памяти? Нет. 137 — любое завершение по SIGKILL: OOM killer, docker kill, принудительная остановка после таймаута. Про память говорит только Reason: OOMKilled в Kubernetes или "OOMKilled": true в docker inspect.

Почему в логах приложения нет ни одной ошибки? SIGKILL нельзя перехватить: процесс не получает шанса записать сообщение или сбросить буферы. Лог обрывается на середине, а объяснение есть только в журнале ядра и в статусе контейнера.

Чем OOMKilled отличается от Evicted? OOMKilled — ядро убило контейнер за превышение его собственного лимита, и kubelet перезапускает его в том же поде. Evicted — kubelet сам остановил весь под, потому что на ноде кончается память или диск. Такой под на этой ноде больше не запускается, замену создаёт контроллер.

Можно ли просто убрать лимит памяти? Технически да, но тогда контейнер может занять всю память ноды. При нехватке памяти на ноде пострадают соседние поды, а без requests ваш под станет первым кандидатом и на выселение, и на OOM killer.

Как понять, какой лимит нужен? Замерить потребление под реальной нагрузкой за период с пиками, поставить limit на пик с запасом и настроить алерт, когда потребление подходит к лимиту. Это частый вопрос на собеседовании по Kubernetes, и хороший ответ всегда начинается с замера.

Потренироваться на практике

В симуляторе DevOps KAI Lab есть модули по Docker и Kubernetes: консоль в браузере отдаёт реальные выводы docker events, dmesg и kubectl describe, а разбираться с перезапусками и лимитами приходится по живым тикетам. Проверить себя можно в бесплатном тесте навыков DevOps.

Источники

Читать дальше