Задача
Java-приложение запущено с -Xmx1g (максимальный размер кучи — 1 ГиБ). Лимит памяти контейнера — 1.5 ГиБ, то есть запас в полгигабайта.
Приложение регулярно перезапускается с OOMKilled. Метрики JVM показывают, что куча не превышает 900 МиБ — то есть до своего потолка она не доходит.
Вопрос: что съедает недостающие 600 МиБ?
Что кажется очевидным
«Утечка памяти в приложении» или «метрики врут». Разработчики смотрят на heap dump, ничего подозрительного не находят, и расследование заходит в тупик.
Как это работает на самом деле
Ошибка в самой постановке: память процесса ≠ куча JVM, а память контейнера ≠ память процесса.
Лимит контейнера применяется к cgroup, и в этот счёт входит гораздо больше, чем куча:
Со стороны JVM (вне -Xmx):
- Metaspace — метаданные классов, десятки-сотни МиБ на больших приложениях;
- стеки потоков — примерно по 1 МиБ на поток; 300 потоков это уже ~300 МиБ;
- code cache — скомпилированный JIT-код;
- direct byte buffers — NIO-буферы, живут вне кучи;
- GC-структуры — служебные данные сборщика;
- native-память библиотек.
Со стороны ядра:
- page cache — и вот это ключевое.
Про page cache
Когда процесс читает или пишет файлы, ядро кеширует страницы в памяти. Эти страницы учитываются в cgroup контейнера. Приложение, активно пишущее логи или читающее файлы, «набирает» память, которая формально не принадлежит ему, но входит в лимит.
Обычно ядро может освободить page cache под давлением, но в ряде сценариев (много грязных страниц, интенсивная запись) освобождение не успевает — и срабатывает OOM killer.
Именно поэтому картина «в приложении всё нормально, а контейнер убивают» так распространена.
Что показывает kubectl
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:
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. Смотреть на реальное потребление
# Что видит 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 ядра по лимиту cgroup | kubelet (eviction) или OOM killer ноды |
| Что в статусе | OOMKilled, exit 137 | Evicted, под удаляется |
| Причина | Лимит занижен / утечка | Перепродажа ресурсов ноды |
| Лечение | Поднять лимит, починить приложение | Правильные requests, см. задачу 19 |
4. Практические правила
requests.memory=limits.memoryдля важных сервисов — классGuaranteed(см. задачу 3).- Запас 25–35% между лимитом и настройкой рантайма.
- Логи — в stdout, а не в файлы внутри контейнера: меньше page cache и никакого риска забить диск.
- Мониторьте отношение
container_memory_working_set_bytesк лимиту; алерт на 85% даёт время среагировать до убийства. - Перезапуски — не решение. Регулярный
OOMKilledс корректным лимитом означает утечку; поднятие лимита лишь отодвигает падение.
Дальше: Задача 6. Путь пакета: от пода к поду через kube-proxy.