Лимиты ресурсов и OOM: потолок cgroup, код 137 и рантаймы, которые его игнорируют
Контейнер без лимита памяти может уронить весь узел по OOM, а рантайм, игнорирующий cgroup-лимит, убивает себя сам. --memory задаёт потолок cgroup, код 137 — это OOM-kill, а cgroup-aware рантаймы (GOMEMLIMIT, MaxRAMPercentage) держат кучу под ним.
Узел погас в 02:14. Не один контейнер — вся машина: kubelet не отвечает, SSH таймаутит, каждый под на нём флапает. Разбор нашёл единственный батч-воркер, загрузивший в память безграничную выборку. Лимита памяти у него не было, так что потолком cgroup была вся RAM узла. Проснулся OOM-killer ядра, но с хостом без памяти он начал жать по эвристическому скору, и жертвами с высшим скором оказались жирная JVM по соседству и, в итоге, сам kubelet. Один процесс без лимита уронил узел, на котором жили сорок других. Фикс — одна строка в pod spec, лимит памяти, а более глубокий урок: отсутствие лимита это не «безлимитно, но нормально», а «безлимитно, и твой радиус поражения — весь хост».
Потолок cgroup и код выхода 137
Когда ты видишь код выхода 137 в продовом контейнере — cgroup (контрольная группа процессов, механизм ядра Linux для ограничения ресурсов) говорит тебе точно, что произошло на уровне ядра, прежде чем ты начнёшь гоняться за багом в своём коде.
Контейнер — это процесс в cgroup, а --memory (Compose mem_limit, Kubernetes resources.limits.memory) пишет потолок memory.max в cgroup v2. Когда резидентная память cgroup переходит этот потолок и не может высвободиться, OOM-killer ядра убивает процесс внутри этой cgroup — обычно PID 1, что завершает контейнер. Контейнер записывает код выхода 137 = 128 + 9: процесс умер от сигнала 9, SIGKILL, посланного OOM-killer’ом. Это число — самый полезный диагностический сигнал в продовых контейнерах: 137 почти всегда означает «упёрся в потолок памяти», а не падение в твоём коде.
docker run --memory=512m --memory-swap=512m myapp
# ^ равно --memory отключает swap для контейнера;
# опусти его — и Docker даёт swap = 2× памяти, так что
# контейнер тихо съест 1 ГиБ до OOM и сначала зашуршит диском.
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' myapp
# true 137 ← ядро OOM-убило его на потолке cgroupЛокализованный случай — хороший: с лимитом умирает только твой контейнер, оркестратор его перезапускает, а узел остаётся здоровым. Без лимита (memory.max = max) cgroup может расти, пока память не кончится у хоста, и тогда системный OOM-killer выбирает жертвы по всем cgroup’ам по oom_score — а это может быть другой контейнер или критичный демон. Отсутствующий лимит не защищает твой контейнер; он подвергает опасности соседей. Поэтому продовая рекомендация — всегда ставить лимиты памяти и делать requests равными limits для памяти (память несжимаема — её нельзя троттлить как CPU, можно только убить).
Контейнер OOM-убит, его состояние показывает ExitCode 137, OOMKilled true. Что именно говорит число 137?
Рантаймы, которые игнорируют лимит cgroup
Задать потолок cgroup — лишь половина дела: рантайм внутри контейнера обязан его уважать. Многие рантаймы по умолчанию размеряют кучу по памяти хоста, а не cgroup, потому что читают /proc/meminfo (тотал хоста), а не memory.max. JVM до контейнерной осведомлённости видела бы хост 64 ГиБ, ставила бы max heap по умолчанию ~16 ГиБ (¼ хоста) и была бы OOM-убита в тот же миг, как попыталась бы вырасти в cgroup 512 МиБ — код 137 на старте, до первого запроса. Сборщик мусора Go на дефолтах срабатывает только когда куча удваивается, так что сервис, держащий 400 МиБ живых, может раздуться за 512 МиБ между сборками и быть убит посреди запроса.
Фиксы — это рантайм-специфичные ручки, привязывающие кучу к cgroup. Вместе они образуют двухслойную защиту: лимит ядра — жёсткий стоп, ручка рантайма — раннее предупреждение, срабатывающее до удара о стену. Без обоих слоёв ты либо летишь вслепую, либо без страховки.
# JVM: размер кучи как процент лимита cgroup (контейнерно-осведомлённая с JDK 10/8u191)
java -XX:MaxRAMPercentage=75.0 -jar app.jar # 75% от memory.max, не от RAM хоста
# Go (1.19+): мягкая цель по памяти, которую уважает GC, чуть ниже потолка cgroup
ENV GOMEMLIMIT=450MiB # для лимита 512 МиБ — оставь запас под не-heap память
# GC работает агрессивнее по мере приближения кучи к GOMEMLIMIT вместо ожидания удвоения
# Node: ограничь old-space V8 ниже лимита cgroup
node --max-old-space-size=400 server.js # МиБ; дефолт V8 ~2 ГиБ независимо от cgroupПаттерн одинаков по стекам: лимит cgroup — твёрдая стена, которую ядро принуждает через SIGKILL; ручка рантайма — мягкая цель, которую рантайм принуждает, собирая мусор до упора в стену. Поставь только лимит cgroup — рантайм радостно врастёт в стену и умрёт; поставь только ручку рантайма — утечка за неё всё равно без подстраховки ядра. Нужны обе — и мягкую цель ставят чуть ниже твёрдой стены, ведь не-heap память (стеки потоков, нативные буферы, metaspace) живёт в той же cgroup и тоже считается против memory.max.
Ты задал --memory=512m для Go-сервиса, но он всё равно OOM-убит под нагрузкой, сообщая лишь ~400 МиБ живой кучи. Почему и какая ручка чинит?
- 01Контейнер показывает код выхода 137 с OOMKilled true. Объясни точно, что произошло и почему число именно 137.
- 02Почему Go- или JVM-сервис может быть OOM-убит в cgroup 512 МиБ, хотя его данные приложения помещаются, и как GOMEMLIMIT / MaxRAMPercentage это предотвращают?
Контейнер — это процесс в cgroup, а лимит памяти пишет потолок memory.max этой cgroup. Когда резидентная память переходит его и не может высвободиться, OOM-killer ядра SIGKILL’ит процесс внутри cgroup — обычно PID 1 — и контейнер записывает код 137 (128 + сигнал 9) с OOMKilled true, самый ясный сигнал в проде, что ты упёрся в стену памяти, а не упал в коде. Задай —memory равным —memory-swap, чтобы отключить swap контейнера, иначе Docker даёт swap вдвое от лимита и контейнер шуршит диском перед смертью. Причина всегда ставить лимит — радиус поражения: с лимитом убивается и перезапускается только твой контейнер, а узел остаётся здоровым; без него потолок cgroup это весь хост, и когда хост кончается, OOM-killer жнёт по каждой cgroup по oom_score, возможно унося соседа или сам kubelet. Задать потолок ядра — лишь половина дела: рантайм внутри обязан его уважать, ведь JVM, Go и Node по умолчанию размеряют кучу от RAM хоста и врастут прямо в стену: JVM, выбравшая ~16 ГиБ на хосте 64 ГиБ, умирает на старте в 512 МиБ, а Go-сервис, держащий 400 МиБ живых, может раздуться за 512 МиБ между сборками мусора. Привяжи кучу к cgroup мягкой целью — MaxRAMPercentage для JVM, GOMEMLIMIT для Go, max-old-space-size для Node — и поставь её чуть ниже твёрдой стены, ведь не-heap память делит ту же cgroup и тоже считается против memory.max. Лимит cgroup — твёрдая подстраховка ядра; ручка рантайма — мягкая цель, собирающая до стены. Нужны обе. Теперь, когда ты видишь контейнер, циклящийся между кодом 137 и перезапуском, проверяй два пункта по порядку: выставлен ли лимит вообще, и есть ли у рантайма cgroup-aware мягкая цель ниже него?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.