Задача 5. OOMKilled: кто и что на самом деле считает

JVM настроена на 1 ГиБ кучи, лимит контейнера 1.5 ГиБ, а под всё равно ловит OOMKilled. Разбираем, что входит в память контейнера помимо кучи и почему page cache тоже считается.
Опубликовано:

Задача

Java-приложение запущено с -Xmx1g (максимальный размер кучи — 1 ГиБ). Лимит памяти контейнера — 1.5 ГиБ, то есть запас в полгигабайта.

Приложение регулярно перезапускается с OOMKilled. Метрики JVM показывают, что куча не превышает 900 МиБ — то есть до своего потолка она не доходит.

Вопрос: что съедает недостающие 600 МиБ?

Что кажется очевидным

«Утечка памяти в приложении» или «метрики врут». Разработчики смотрят на heap dump, ничего подозрительного не находят, и расследование заходит в тупик.

Как это работает на самом деле

Ошибка в самой постановке: память процесса ≠ куча JVM, а память контейнера ≠ память процесса.

Лимит контейнера применяется к cgroup, и в этот счёт входит гораздо больше, чем куча:

Со стороны JVM (вне -Xmx):

Со стороны ядра:

Про page cache

Когда процесс читает или пишет файлы, ядро кеширует страницы в памяти. Эти страницы учитываются в cgroup контейнера. Приложение, активно пишущее логи или читающее файлы, «набирает» память, которая формально не принадлежит ему, но входит в лимит.

Обычно ядро может освободить page cache под давлением, но в ряде сценариев (много грязных страниц, интенсивная запись) освобождение не успевает — и срабатывает OOM killer.

Именно поэтому картина «в приложении всё нормально, а контейнер убивают» так распространена.

Что показывает kubectl

BASH
kubectl describe pod my-app
# ...
#     Last State:     Terminated
#       Reason:       OOMKilled
#       Exit Code:    137
Нажмите, чтобы развернуть и увидеть больше

Код 137 — это 128 + 9, то есть процесс получил SIGKILL. Важная деталь: OOM killer убивает не «под», а процесс; если это главный процесс контейнера, контейнер перезапускается.

Ответ

Оставшиеся 600 МиБ — это non-heap память JVM (metaspace, стеки потоков, code cache, direct buffers) плюс page cache от файловых операций. Всё это учитывается в лимите cgroup, но не видно в метриках кучи.

Что с этим делать

1. Считать всю память, а не только кучу

Для JVM правильнее не задавать -Xmx вручную, а отдать расчёт самой JVM — современные версии умеют читать лимиты cgroup:

YAML
env:
  - name: JAVA_OPTS
    value: "-XX:MaxRAMPercentage=70.0 -XX:InitialRAMPercentage=50.0"
Нажмите, чтобы развернуть и увидеть больше

MaxRAMPercentage берёт долю от лимита контейнера, оставляя запас на non-heap и page cache. Отправная точка — 65–75%.

Аналогичные соображения есть и для других рантаймов: Node.js (--max-old-space-size), Python (аллокатор и native-расширения), Go (GOMEMLIMIT).

2. Смотреть на реальное потребление

BASH
# Что видит Kubernetes
kubectl top pod my-app --containers

# Разбивка изнутри контейнера (cgroup v2)
kubectl exec my-app -- cat /sys/fs/cgroup/memory.current
kubectl exec my-app -- cat /sys/fs/cgroup/memory.max
kubectl exec my-app -- cat /sys/fs/cgroup/memory.stat | head -20
Нажмите, чтобы развернуть и увидеть больше

В memory.stat интересны строки anon (собственно память процесса) и file (page cache). Если file большой — виноваты файловые операции, а не утечка.

3. Различать два разных OOM

Их путают, а лечатся они по-разному:

Контейнер превысил свой лимитНа ноде кончилась память
Кто убиваетOOM killer ядра по лимиту cgroupkubelet (eviction) или OOM killer ноды
Что в статусеOOMKilled, exit 137Evicted, под удаляется
ПричинаЛимит занижен / утечкаПерепродажа ресурсов ноды
ЛечениеПоднять лимит, починить приложениеПравильные requests, см. задачу 19

4. Практические правила


Дальше: Задача 6. Путь пакета: от пода к поду через kube-proxy.

Начать поиск

Введите ключевые слова для поиска статей

↑↓
ESC
⌘K Горячая клавиша