Контейнер молча исчезает из списка запущенных, приложение отвечает 502, а в логах самого сервиса ровно ничего нет. Знакомая картина для любого, кто эксплуатирует Docker на плотно загруженных хостах. В девяти случаях из десяти за таким сценарием стоит oom-killer ядра Linux, который выстрелил по процессу внутри cgroup за превышение лимита памяти. Хорошая новость в том, что ядро честно расписывает каждый такой инцидент: в кольцевом буфере dmesg, в файлах событий cgroup v2 и в метаданных самого контейнера. Плохая новость в том, что мало увидеть строку "Out of memory". Нужно понять, кто именно убит, по какой причине, какой лимит сработал, и как настроить memory.high против memory.max так, чтобы получить мягкий throttling вместо жёсткой расправы над процессом. Ниже разобрана вся цепочка действий, от первой команды на горящем продакшен-хосте до профилактики повторений.

Первичная проверка факта OOM kill через dmesg и метаданные контейнера

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

\\\`bash
# Вывод буфера ядра с преобразованием меток времени в дату и время
dmesg -T | grep -i -E 'oom|out of memory|killed process'
\\\`

Типичный образец вывода с боевого хоста:

\\\`
[Ср окт 4 14:23:17 2026] worker-7f2c invoked oom-killer: gfp_mask=0x140cca(GFP_HIGHUSER_MOVABLE|__GFP_COMP), order=0, oom_score_adj=995
[Ср окт 4 14:23:17 2026] Mem-Info:
[Ср окт 4 14:23:17 2026] active_anon:1982436kB inactive_anon:52240kB isolated_anon:0kB
[Ср окт 4 14:23:17 2026] Node 0 active_anon:1982436kB inactive_anon:52240kB active_file:41280kB
[Ср окт 4 14:23:17 2026] Tasks state (memory values in pages):
[Ср окт 4 14:23:17 2026] [17331] 1000 17331 512340 402118 4198400 0 995 worker-7f2c
[Ср окт 4 14:23:17 2026] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=/,mems_allowed=0,oom_memcg=/docker/a1b2c3d4...,task_memcg=/docker/a1b2c3d4...,task=worker-7f2c,pid=17331,uid=1000
[Ср окт 4 14:23:17 2026] Memory cgroup out of memory: Killed process 17331 (worker-7f2c) total-vm:2049360kB, anon-rss:1608472kB, file-rss:8712kB, shmem-rss:0kB, UID:1000 pgrp:17331
\\\`

Разбор по строкам даёт почти всю картину. Первая строка называет процесс, из-за которого oom-killer вошёл в работу, и его oom_score_adj равный 995: это уже намёк на Kubernetes, где для подов класса BestEffort kubelet ставит значение 1000, а для Burstable считает пропорционально. Строка oom-kill с constraint=CONSTRAINT_MEMCG самая ценная: она говорит, что убийство произошло не из-за исчерпания памяти всего узла, а из-за лимита самой cgroup контейнера. Поля oom_memcg и task_memcg показывают путь cgroup внутри дерева /docker/, по хвосту которого легко вычислить контейнер. Финальная строка фиксирует жертву: total-vm около 2 Гбайт виртуальной памяти, anon-rss примерно 1,6 Гбайт анонимной (реально занятой) памяти. Учитывайте, что dmesg кольцевой: на загруженной машине буфер протухает за часы. Если хост переживал ребут, спасут journalctl -k --since "yesterday" стой же фильтрацией, либо persistent-логи в /var/log/kern.log на дистрибутивах с rsyslog.

Второй обязательный источник: docker inspect погибшего контейнера. Команда

\\\`bash
# Проверка флага OOMKilled и кода выхода контейнера
docker inspect a1b2c3d4 --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Error}}'
\\\`

вернёт "true 137", и это та самая пара, которую все видят в Kubernetes как OOMKilled с exit code 137. Код 137 складывается из 128 плюс 9, то есть SIGKILL. Значение true в поле OOMKilled ставит служба dockerd, когда exit вызван именно oom-killer внутри memcg контейнера. Если контейнер уже удалён с флагом --rm, сведения теряются вместе с ним, поэтому в бой продакшен уходит только с restart policy и без --rm.

Пример причины через memory.events в cgroup v2

Docker с версии 20.10 уверенно работает на cgroup v2, и именно там сосредоточена все диагностические данные. У каждого контейнера есть каталог в /sys/fs/cgroup, чаще всего /sys/fs/cgroup/system.slice/docker-<id>.scope. Файл memory.events внутри него ведёт счётчики всех памятных инцидентов:

\\\`bash
# Чтение счётчиков событий памяти для конкретного контейнера
cat /sys/fs/cgroup/system.slice/docker-a1b2c3d4.scope/memory.events
\\\`

\\\`
low 0
high 47
max 312
oom 3
oom_kill 3
oom_group_kill 0
\\\`

Построчно: high увеличен 47 раз, значит cgroup сорок семь раз пересекала порог memory.high, и ядро запускало активный reclaim, душа процессы до тех пор, пока потребление не вернётся ниже границы. Поле max указано 312 раз: столько раз происходило прямое касание жёсткого лимита memory.max с последующим стримительным reclaim в контексте выделения памяти. Поля oom и oom_kill равны трём: ядро признавало ситуацию неразрешимой и убивало процесс трижды, что согласуется с тремя строками в dmesg. Разница между oom и oom_kill тонкая: oom срабатывает при входе в состояние безысходности, а oom_kill фиксирует фактическое убийство. oom_group_kill равный нулю говорит, что группового убийства всей cgroup (memory.oom.group=1) не включено.

Параллельно стоит смотреть memory.current и memory.stat. Последний покажет раскладку: anon, file, kernel, slab. Особенно коварен большой kernel слой и page cache: в cgroup v1 page cache вообще не казался лимиту работоспособным для логики oom_score, а в v2 он честно входит в текущее значение и толкает к max. Если memory.stat показывает file в 1,8 Гбайт при лимите 2 Гбайт, виновник совсем не куча процесса, а страничный кеш от записи логов или чтения больших файлов без O_DIRECT.

Различие поведения memory.high и memory.max с практическими последствиями

Самая частая ошибка тюнинга состоит в том, что memory.high воспринимается как "мягкий лимит для галочки". На деле это досрочный регулятор. Когда cgroup пересекает memory.high, ядро начинает принудительный reclaim именно из этой группы: страницы выталкиваются в swap, чистые файловые страницы выкидываются, процессы попадают в direct reclaim и заметно тормозят. Зато убийства нет. memory.max это неприступная стена: при касании и провале reclaim срабатывает oom-killer.

Практическая схема, проверенная на проде: max берётся с запасом, high ставится на 70-85 процентов от него. Пример для контейнера с рабочим пиком 3,5 Гбайт:

\\\`bash
# Жёсткий потолок 4Гб, мягкий порог регулирования 3Гб
echo 4G > /sys/fs/cgroup/system.slice/docker-a1b2c3d4.scope/memory.max
echo 3G > /sys/fs/cgroup/system.slice/docker-a1b2c3d4.scope/memory.high
\\\`

Такой расклад даёт градацию рисков. Рост к 3 Гбайт включает throttling и swap, приложение работает медленнее, но живёт; перерасход выше 4 Гбайт всё ещё заканчивается kill, но это уже защита узла, а не повседневный режим. Есть нюанс: memory.high не наследуется в дочерние cgroup, потому вложенные группы внутри контейнера обходят мягкий порог, и отдельные злонамеренные процессы могут разгонять анонимную память без сдерживания. Наоборот, memory.max наследуется как потолок: дочерняя группа не получит больше, чем разрешено родителю.

Для Docker управление лимитами идёт через флаги запуска:

\\\`bash
# Лимит памяти 4Г, суммарный лимит память плюс swap 5Г
docker run -d --name worker \
--memory=4g \
--memory-swap=5g \
--memory-swappiness=40 \
registry.example.com/app/worker:2.14.3
\\\`

Флаг --memory маппится в memory.max. Ключевой момент, который подмечают не сразу: --memory-swap задаёт общий потолок "память плюс swap", а не размер swap. Значение 5g при memory 4g даёт ровно 1 Гбайт swap. Если --memory-swap не задать, контейнер получит swap вдвое больше memory, что часто маскирует утечку неделями. Значение -1 отпускает swap полностью, и это хороший способ получить внезапный oom-kill в час пик. Управление memory.high через docker run штатно недоступно: приходится писать напрямую в файловую систему cgroup через systemd drop-in или обёртку вроде ExecStartPost у systemd-сервиса.

Конфиг unit-файла выглядит так:

\\\`ini
# /etc/systemd/system/docker-a1b2c3d4.scope.d/10-memory.conf как drop-in override
# Аналог такого же drop-in для systemd-юнита, запускающего контейнер
[Service]
ExecStartPost=/bin/sh -c 'echo 3G > /sys/fs/cgroup/system.slice/docker-a1b2c3d4.scope/memory.high'
\\\`

Земля Kubernetes OOMKilled и exit code 137

В Kubernetes всё то же самое, просто обёрнуто в слои. Поле limits.memory маппится в memory.max подовой cgroup, requests в GUARANTEED-класс QoS влияет на oom_score_adj. Команда

\\\`bash
# Причина последнего рестарта контейнера в поде
kubectl get pod worker-7f2c-6d9b -o jsonpath='{.status.containerStatuses[*].lastState}'
\\\`

вернёт структуру terminated с reason OOMKilled и exitCode 137. Отличие от голого Docker в том, что kubectl describe pod даст время события и счётчик рестартов, а kubelet перезапустит контейнер по restartPolicy, и свежий dmesg будет уже от новой эпохи. Поэтому диагностику ведут до дренирования пода, либо выгребают journalctl -u kubelet на узле. Частая продуктовая ошибка: limits поставлены по requests "для красоты", скажем 512Mi там и там, а Java-процесс с Xmx2g просто не влезает. JVM до версии 8u191 вообще не видела cgroup и резервировала кучу от памяти хоста; современные версии с -XX:MaxRAMPercentage=75.0 послушно держат кучу в пределах лимита, оставляя запас под metaspace, стеки и нативные буферы.

Пошаговый план действий при OOM kill в продакшене

Когда алерт привёл на хост, действия идут в строгой последовательности:

  1. Зафиксировать факт и время через dmesg -T | grep -i oom, сохранив вывод целиком в файл инцидента; параллельно выполнить docker inspect контейнера и записать OOMKilled с ExitCode.
  2. Снять текущие счётчики: cat memory.events, memory.current, memory.stat, memory.swap.current для дочерних cgroup контейнера до рестарта.
  3. Сопоставить тайминг с метриками Node Exporter или cAdvisor, найти виновный рост на графике container_memory_working_set_bytes.
  4. Определить тип памяти по memory.stat: преобладание anon указывает на утечку или жирную кучу, доминирование file кладёт вину на страничный кеш и логирование без ротации.
  5. Временно поднять memory.max до вдвое большего значения (напрямую через echo или docker update --memory), чтобы сервис встал, и взять дамп кучи или perf-профиль процесса.
  6. Выставить рассчитанные memory.high и memory.max, прокатить нагрузочный тест с k6 или hey, проверить, что high отрабатывает throttle без kill.
  7. Включить мониторинг метрик oom_kill в kubelet и cAdvisor и добавить правило алертинга на рост счётчика memory.events oom.

Каждый шаг занимает минуты, но пропуск первых двух обесценивает расследование: после пары рестартов артефакты размываются.

Своп, swappiness и профилактика повторных инцидентов

Swap в контейнерном мире отношение сложное. Kubernetes до недавнего времени требовал его выключения, Docker позволяет гибко. vm.swappiness контролирует охоту ядра вытеснять анонимные страницы: значение 60 по умолчанию на десктопах избыточно для серверов. Для контейнерных хостов годится swappiness от 10 до 30, а в cgroup v2 можно задать поведение точечно через флаг --memory-swappiness контейнера. Полный запрет swap (swappiness=0) с цепочкой memory.max логичен для latency-чувствительных сервисов: лучше чистый перезапуск, чем десятикратная задержка на вытеснении страниц.

Профилактика строится на трёх опорах. Первая: лимиты ставятся всегда, без исключений, вместе с мониторингом working_set и раздельным наблюдением anon и file. Вторая: приложения учат лимиты, Java через MaxRAMPercentage, Go через GOMEMLIMIT, Node.js через --max-old-space-size, чтобы рантайм притормаживал до касания стены, а не после. Третья: регулярный выгреб dmesg и memory.events в observability-стек, потому что высокий счётчик high при нулевом oom_kill это сирена о приближающемся перерасходе за две недели до инцидента.

Отдельно стоит сказать о коварных сценариях. tmpfs внутри контейнера учитывается в memcg лимите, и пара гигабайт в /dev/shm тихо съедает запас. Наследование oom_score_adj от родительского процесса иногда делает жертвой не того, кто ест память, а того, у кого выше score: memory hog с oom_score_adj=-999 выживет, а убьют соседний sidecar. Наконец, swap на zram ведёт себя честнее дискового swap на дешёвых NVMe: reclaim быстрее, и throttling через memory.high переносится мягче.

При грамотной настройке связки memory.high и memory.max хост перестаёт быть рулеткой: мягкий порог работает как заблаговременный тормоз, жёсткий потолок стоит за полосой отчуждения, и oom-killer остаётся крайней мерой, о которой администраторы узнают из отчётов, а не из пейджера в четыре утра.