Заметка о том, как на самом деле работает лимит памяти в Kubernetes: cgroups v2, overcommit и суровый OOM Killer
В мире Kubernetes принято считать, что requests и limits - это надежные границы, которые полностью изолируют приложения. По факту же, когда память на ноде заканчивается, абстракции кубера отходят на второй план, и в игру вступают механизмы ядра Linux.
Решил разобраться в деталях и провел серию тестов в песочнице (ALT Linux 11, Minikube на Proxmox). Ниже - что из этого получилось.
Важно сразу разделить три разных сценария:
memcg OOM - контейнер упёрся в собственный memory limit.
kubelet eviction - kubelet заметил давление по ресурсам на ноде и начал выселять pod’ы.
global OOM - памяти на ноде не хватило быстрее, чем kubelet успел что-либо сделать, и сработал kernel OOM Killer.
Если смешать эти три механизма, легко случайно сделать неправильные выводы.
1. Лимит контейнера и cgroup v2: что происходит при memcg OOM
Самый частый сценарий: приложение внутри контейнера выходит за свой limits.memory.
В Kubernetes memory limit контейнера в итоге превращается в ограничение на уровне cgroup. В cgroup v2 жёсткий лимит задаётся через memory.max. Если потребление памяти в этой cgroup доходит до лимита и ядро не может освободить достаточно памяти, возникает memcg OOM.
На ALT Linux 11 используется cgroup v2 - как и в большинстве современных дистрибутивов Linux по умолчанию. Для Kubernetes это важный нюанс: в типовой конфигурации kubelet на cgroup v2 для container cgroup выставляется memory.oom.group=1.
Проверить это можно прям на ноде:
cat /sys/fs/cgroup/.../memory.oom.group
Если там 1, то при OOM внутри конкретного контейнера ядро рассматривает процессы этого контейнера как единую группу и убивает их вместе. Это отличается от привычного поведения cgroup v1, где мог умереть один worker-процесс, а основной процесс контейнера продолжал жить, оставляя приложение в полуживом состоянии.
Но тут есть важная оговорка: для multi-container pod это не обязательно означает мгновенную смерть всех контейнеров pod’а.
Если OOM произошёл на уровне cgroup конкретного контейнера, будет убит именно этот контейнер. Если же давление по памяти возникло выше по иерархии cgroup или дошло до node/global OOM, поведение уже зависит от лимитов, QoS, oom_score_adj и того, кого ядро выберет жертвой.
Для диагностики полезно смотреть не только memory.max, но и memory.events:
cat /sys/fs/cgroup/.../memory.max cat /sys/fs/cgroup/.../memory.events
В memory.events можно увидеть счётчики вроде:
high max oom oom_kill oom_group_kill
Они помогают понять, что именно произошло: контейнер приблизился к лимиту, упёрся в memory.max, словил OOM или был убит группой.
2. А что насчёт memory.high?
В cgroup v2 есть не только memory.max, но и memory.high.
memory.max - это жёсткая граница. Если контейнер дошёл до неё и память нельзя освободить, будет OOM.
memory.high - это мягкий порог. При его превышении ядро начинает троттлить процессы в cgroup и заставляет их проходить через reclaim, то есть пытаться освобождать память до того, как ситуация дойдёт до убийства.
Звучит конечно красиво, но в Kubernetes есть нюанс: сам факт использования cgroup v2 ещё не означает, что memory.high реально настроен для ваших контейнеров.
Обычно memory limit контейнера мапится в memory.max. А вот активное использование memory.high связано с MemoryQoS и конкретной конфигурацией kubelet/runtime. Если MemoryQoS не включён или runtime не выставляет этот параметр, memory.high может оставаться равным max, то есть фактически не работать как предварительный тормоз перед OOM.
Проверять надо на живой ноде:
cat /sys/fs/cgroup/.../memory.high
Если там max, никакого троттлинга на этом уровне нет.
3. QoS-классы: кто на самом деле защищён?
Когда память заканчивается на всей ноде, важную роль играет oom_score_adj. Это поправка, которую Kubernetes выставляет процессам контейнеров, чтобы повлиять на выбор жертвы kernel OOM Killer’ом.
QoS-классы в Kubernetes такие:
Guaranteed
Pod получает Guaranteed, только если для каждого контейнера заданы и CPU, и memory request/limit, и при этом:
cpu request == cpu limit memory request == memory limit
Если забыли задать CPU request/limit - это уже не Guaranteed.
Для обычных пользовательских pod’ов это стандартный способ получить сильную защиту от OOM Killer’а:
cat /proc/$PID/oom_score_adj -997
BestEffort
Если у pod’а нет ни requests, ни limits, он получает BestEffort.
Такие процессы получают:
cat /proc/$PID/oom_score_adj 1000
Это первый кандидат на вылет при node/global OOM.
Burstable
Всё остальное - Burstable.
Для Burstable pod’ов oom_score_adj считается по формуле:
oom_score_adj = 1000 - (1000 × memoryRequestBytes) / nodeMemoryCapacityBytes
Результат зажимается в диапазоне:
[2, 999]
То есть чем больше memory request относительно памяти ноды, тем ниже oom_score_adj и тем меньше вероятность быть выбранным OOM Killer’ом.
В моей лабе это хорошо видно:
# Guaranteed pod
cat /proc/$(pgrep stress-ng)/oom_score_adj
-997
# BestEffort pod
cat /proc/$(pgrep alpine)/oom_score_adj
1000
Отдельный нюанс: системные процессы могут быть защищены ещё сильнее. В моей песочнице, например, kubelet имел oom_score_adj=-999, а sshd - -1000.
То есть Guaranteed - это не имба для пода. Это сильная защита по сравнению с обычными workload-процессами, но не абсолютная гарантия жизни.
4. QoS и eviction - не одно и то же
Тут легко ошибиться.
oom_score_adj важен для kernel OOM Killer’а, когда ядро уже само выбирает, кого убить.
А kubelet eviction работает иначе. Если kubelet успевает заметить memory pressure до global OOM, он выселяет pod’ы по своей логике. Там важны:
превышает ли pod свои requests;
PriorityClass;
насколько сильно usage превышает request.
QoS-класс коррелирует с этим поведением, но не является единственным алгоритмом eviction.
Например, pod с низким priority, но потреблением в пределах request, не обязательно будет выселен раньше pod’а с более высоким priority, который сильно вышел за request. Поэтому для анализа инцидента надо понимать, что именно произошло:
контейнер умер из-за своего memory limit;
pod был выселен kubelet’ом;
процесс был убит kernel OOM Killer’ом при global OOM.
Это разные события, и следы у них разные.
5. Global OOM: когда kubelet не успел
Если память на ноде закончилась резко, kubelet может не успеть сделать eviction. Тогда срабатывает обычный kernel OOM Killer.
Для проверки я запускал простой Python-скрипт, который агрессивно захватывал память:
import time
data = []
while True:
data.append(bytearray(100 * 1024 * 1024))
time.sleep(0.1)
В dmesg после этого можно увидеть что-то вроде:
Out of memory: Killed process 1841 (python3) total-vm:10GB, anon-rss:3.7GB, oom_score_adj:0
Здесь важно правильно читать поля.
total-vm - это виртуальное адресное пространство процесса.
anon-rss - реально резидентные анонимные страницы в RAM.
Разница между total-vm и anon-rss хорошо показывает, почему нельзя смотреть только на VIRT в top/ps и делать вывод, что процесс реально занял столько RAM. Но это ещё не вся история overcommit. Для анализа overcommit лучше смотреть глобальные счётчики:
grep -E 'CommitLimit|Committed_AS' /proc/meminfo
Committed_AS показывает объём памяти, который ядро уже пообещало процессам.
CommitLimit показывает предел, после которого новые аллокации в strict mode должны начать отклоняться.
Ещё один важный момент при разборе OOM-логов: не путайте строки invoked oom-killer и Killed process.
Строка вида:
python3 invoked oom-killer
описывает процесс, который наткнулся на нехватку памяти.
А строка:
Out of memory: Killed process ...
описывает уже выбранную жертву.
Иногда это один и тот же процесс, иногда нет.
6. Опасные игры с vm.overcommit_memory
В Linux есть три режима overcommit:
0 — эвристика ядра
1 — always overcommit
2 — strict overcommit
В моей лабе на ALT Linux 11 после старта Minikube/kubelet значение vm.overcommit_memory переключалось в 1.
Проверяется так:
sysctl vm.overcommit_memory
Важно: это node-level sysctl, а не настройка конкретного pod’а или cgroup. Он влияет на поведение всей ноды.
Режим 1 разрешает агрессивный overcommit: процессы могут успешно получать виртуальную память «про запас», а реальные проблемы проявятся позже - когда память начнут фактически трогать и страницы станут резидентными.
Самая опасная ситуация - вручную переключить ноду в strict mode:
sysctl vm.overcommit_memory=2
В режиме 2 ядро начинает проверять, не превышают ли обещанные аллокации общий commit limit.
Упрощённая формула такая:
CommitLimit = SwapTotal + RAM × overcommit_ratio / 100
Более точная формула учитывает huge pages:
CommitLimit = SwapTotal + (RAM - HugeTLB) × overcommit_ratio / 100
В моей лабе было 4 ГБ RAM, swap выключен, overcommit_ratio=50. Поэтому CommitLimit оказался около 2 ГБ:
sysctl vm.overcommit_memory=2
cat /proc/meminfo | grep CommitLimit
CommitLimit: 2005936 kB
Если нода уже нагружена и Committed_AS выше нового CommitLimit, такое переключение может быстро превратить систему в кирпич: новые процессы, fork, SSH-сессии и служебные демоны могут начать получать отказ на выделение памяти.
Перед включением strict mode надо хотя бы проверить:
grep -E 'CommitLimit|Committed_AS' /proc/meminfo
Если Committed_AS уже выше будущего CommitLimit, включать strict mode нельзя без подготовки.
Более безопасный порядок такой:
sysctl vm.overcommit_ratio=80
sysctl vm.overcommit_memory=2
Но и это не рекомендация «делать в проде». Это настройка, которую надо тестировать под конкретный workload. Kubernetes-кластер с контейнерами, JVM, Python, Go-сервисами, базами данных и sidecar’ами может очень неприятно отреагировать на строгий overcommit.
7. Что реально помогают настроить kube-reserved, system-reserved и evictionHard
Чтобы нода не доходила до global OOM, Kubernetes даёт несколько механизмов резервирования.
kube-reserved - ресурсы для kubelet, container runtime и компонентов Kubernetes.
system-reserved - ресурсы для системных демонов ОС.
evictionHard - аварийный порог, при котором kubelet начинает выселять pod’ы.
Например:
kubeReserved:
memory: "512Mi"
systemReserved:
memory: "512Mi"
evictionHard:
memory.available: "500Mi"
Эти параметры не делают pod’ы магически безопасными. Они уменьшают Node Allocatable и создают буфер, чтобы kubelet успел начать eviction до того, как ядро сорвётся в global OOM.
Но если memory spike слишком резкий, kubelet всё равно может не успеть. Тогда решение будет принимать уже kernel OOM Killer.
8. Что делать в целях диагностики
Проверить версию cgroup
stat -fc %T /sys/fs/cgroup
Для cgroup v2 будет:
cgroup2fs
Найти cgroup процесса
cat /proc/$PID/cgroup
Проверить лимиты контейнера
cat /sys/fs/cgroup/.../memory.max
cat /sys/fs/cgroup/.../memory.high
cat /sys/fs/cgroup/.../memory.oom.group
cat /sys/fs/cgroup/.../memory.events
Проверить приоритет для OOM Killer’а
cat /proc/$PID/oom_score
cat /proc/$PID/oom_score_adj
Проверить overcommit
sysctl vm.overcommit_memory
sysctl vm.overcommit_ratio
grep -E 'CommitLimit|Committed_AS' /proc/meminfo
Проверить события Kubernetes
kubectl describe pod <pod>
kubectl get events --sort-by=.lastTimestamp
Если контейнер умер из-за собственного лимита, обычно будет видно OOMKilled.
Если pod выселил kubelet, будет Evicted.
Если был global OOM на ноде, следы надо искать уже в dmesg/journal:
dmesg -T | grep -i -E 'out of memory|oom|killed process'
journalctl -k | grep -i -E 'out of memory|oom|killed process'
Итоги
requests и limits - это важные механизмы, но они не отменяют реальность Linux memory management.
Ключевые выводы всего вышеописанного:
Memory limit контейнера - это cgroup-лимит, а не предварительно зарезервированная RAM.
На cgroup v2 при memory.oom.group=1 процессы внутри контейнера обычно убиваются как группа. Но для multi-container pod это не всегда означает смерть всех контейнеров pod’а.
memory.high - полезный механизм cgroup v2, но не надо считать, что Kubernetes всегда его использует. Проверяйте реальное значение в cgroup.
QoS влияет на oom_score_adj, но kubelet eviction и kernel OOM Killer - разные механизмы.
Guaranteed - это сильная защита, но не гарантия бессмертия для пода. Системные процессы могут быть защищены сильнее, а при тяжёлом global OOM ядро всё равно будет кого-то убивать.
Strict overcommit mode опасен без расчёта Committed_AS и CommitLimit. Особенно на Kubernetes-нодах, где много процессов активно резервируют виртуальную память.
kube-reserved, system-reserved и evictionHard нужны не для красоты. Они дают kubelet шанс выселить pod’ы раньше, чем нода попадёт в global OOM.




