Команда видит в кластере знакомую и пугающую картину. Под падает с пометкой об убийстве по нехватке памяти и кодом завершения 137, перезапускается, снова падает и уходит в петлю перезапусков. Самое сбивающее с толку, что куче выделили пятьсот мегабайт, а лимит контейнера выставлен в гигабайт. Запаса вроде бы вдвое больше, чем нужно. Почему же ядро убивает процесс. Ответ кроется в том, что куча это лишь часть памяти виртуальной машины Java, а контейнерный лимит считает всё. Разберём, как правильно рассчитать запросы и лимиты для Java под Kubernetes с учётом накладных расходов виртуальной машины.
Что вообще означает код 137 и кто убивает под
Сначала о механике. Код завершения 137 означает, что процесс получил сигнал принудительного завершения, который складывается из базового числа и номера сигнала. Когда причиной указано убийство по нехватке памяти, этот сигнал пришёл от события нехватки памяти. И тут важно различать два совершенно разных сценария.
Первый сценарий это превышение контейнером собственного лимита памяти, заданного в описании пода. Ядро Linux убивает именно этот процесс в его группе контроля, а Kubernetes рапортует о завершении с причиной убийства по нехватке памяти. Никакого предупреждения, никакого корректного завершения работы: процесс убивается мгновенно жёстким сигналом. Второй сценарий это нехватка памяти на самом узле. Тогда служба узла начинает упреждающе вытеснять поды по давлению памяти, а если узел исчерпает память до завершения вытеснения, убийца по нехватке памяти всё равно прикончит процессы. Различать их просто: если в статусе контейнера стоит причина убийства по нехватке памяти, это путь превышения лимита, а если видны сообщения о вытеснении и давлении памяти узла, это перегрузка узла.
Почему куча это не вся память виртуальной машины
Теперь к сути загадки про вдвое меньшую кучу. Распространённейшая ошибка сводится к тому, что выставляют максимальный размер кучи и считают дело сделанным. Но виртуальная машина Java использует далеко не только кучу. Общая память контейнера складывается из множества частей: куча, область метаданных классов, стеки потоков, кэш кода, нативная память и прямые буферы.
Установить кучу в полтора гигабайта может показаться безопасным при лимите контейнера в два гигабайта, но область метаданных и нативные выделения легко съедают остаток и выталкивают контейнер за предел. В том показательном случае с кучей в пятьсот мегабайт и лимитом в гигабайт виновата была именно эта неучтённая память за пределами кучи. Кучу можно ограничить одним параметром, а вот для памяти за пределами кучи единого ограничителя нет, она складывается из нескольких независимых областей, каждая со своим поведением.
Из чего на самом деле складывается аппетит виртуальной машины
Разберём части по очереди, потому что каждая требует внимания. Область метаданных хранит метаданные классов, и по умолчанию её максимум не ограничен, что опасно: для приложений с тяжёлыми фреймворками она разрастается и валит контейнер. Её обязательно ограничивают явно. Кэш кода хранит скомпилированный код. Стеки потоков съедают память за пределами кучи, и по умолчанию стек одного потока на Linux занимает около мегабайта, так что при сотнях потоков набегает заметный объём. Прямая память, используемая сетевыми библиотеками, без явного ограничения по умолчанию приравнивается к размеру кучи и способна устроить переполнение.
Есть практическая формула для оценки лимита контейнера. К максимальному размеру кучи добавляют область метаданных, кэш кода, память под потоки из расчёта около мегабайта на поток, буферы и общий запас на накладные расходы. Для приложения с кучей в два гигабайта и сотней потоков сумма выходит примерно в три с половиной гигабайта. То есть лимит контейнера должен быть заметно больше кучи, и разрыв этот тем шире, чем больше потоков и нативного кода.
resources:
requests:
memory: "3Gi"
cpu: "1000m"
limits:
memory: "3.5Gi"
cpu: "2000m"
env:
- name: JAVA_OPTS
value: >-
-Xms2g -Xmx2g
-XX:MaxMetaspaceSize=256m
-XX:ReservedCodeCacheSize=256m
-XX:MaxDirectMemorySize=256m
-Xss256k
-XX:+UseG1GC
Уменьшение размера стека потока вдвое заметно срезает накладные расходы на потоки, а явные потолки на область метаданных, кэш кода и прямую память не дают им разрастись бесконтрольно.
Почему фиксированный размер кучи это устаревший подход
Жёстко прописывать максимальный размер кучи числом неудобно и хрупко. Если позже изменить лимит контейнера, настройка кучи устареет и перестанет ему соответствовать. Современный подход начиная с десятой версии Java это задавать кучу как процент от доступной памяти. Виртуальная машина сама вычисляет размер кучи от лимита группы контроля, обнаруженного во время выполнения.
env:
- name: JAVA_OPTS
value: >-
-XX:+UseContainerSupport
-XX:InitialRAMPercentage=50.0
-XX:MaxRAMPercentage=75.0
При лимите контейнера в четыре гигабайта и проценте кучи в семьдесят пять виртуальная машина отведёт под кучу три гигабайта, оставив гигабайт на память за пределами кучи. Прелесть подхода в том, что если позже поднять лимит до шести гигабайт, куча автоматически вырастет до четырёх с половиной, и менять флаги не придётся. Это особенно ценно в облаке, где лимиты ресурсов могут меняться политиками масштабирования.
Но и тут есть тонкость, о которой забывают. Слишком агрессивный процент на маленьком контейнере вреден. При лимите в гигабайт и проценте кучи в семьдесят остаётся всего около семисот мегабайт кучи, чего может не хватить виртуальной машине для эффективной работы, а логика автовыбора сборщика мусора может повести себя неожиданно. Для маленьких контейнеров иногда честнее вычислять размер кучи вручную через шаблоны развёртывания.
Почему старые версии Java вообще игнорируют лимиты контейнера
Отдельная мина для тех, кто работает со старыми версиями. До определённой сборки восьмой версии Java, вышедшей в 2018 году, виртуальная машина игнорировала лимиты памяти контейнера. Она вычисляла лимиты, исходя из оперативной памяти всей хост-машины, а не контейнера. Представьте, что приложение в контейнере с лимитом в гигабайт крутится на узле с шестьюдесятью четырьмя гигабайтами: виртуальная машина решает, что в её распоряжении вся память узла, и раздувает кучу далеко за контейнерный лимит, после чего ядро её убивает.
Лечится это либо обновлением до современной версии Java, где поддержка контейнеров включена и работает, либо явным флагом поддержки контейнеров на промежуточных версиях. Для версий семнадцать и новее поддержка контейнеров работает из коробки, и достаточно просто не забыть выставить лимиты на контейнеры.
Как классы качества обслуживания решают, кого убьют первым
Тут вступает в игру механизм, который многие недооценивают. Kubernetes присваивает каждому поду класс качества обслуживания, исходя исключительно из того, как настроены запросы и лимиты ресурсов. Напрямую его не задают, система выводит его из описания пода.
Класс гарантированного обслуживания получают поды, у которых для каждого контейнера запрос памяти равен лимиту, и то же по процессору. Это высший класс защиты. Класс с возможностью всплеска получают поды, где запросы и лимиты заданы, но не равны. Класс по остаточному принципу получают поды без запросов и лимитов вовсе. Под давлением памяти узла служба узла вытесняет сначала поды по остаточному принципу, затем поды со всплеском, отсортированные по тому, насколько их потребление превысило запрос, и в последнюю очередь гарантированные поды.
Под капотом это управляется корректировкой привлекательности процесса для убийцы по нехватке памяти. Гарантированные поды получают значение, делающее их наименее привлекательной целью, поэтому их убивают последними. Поды по остаточному принципу получают значение, делающее их первой жертвой. Практический вывод однозначен: команда, которая пропускает запросы и лимиты ради мнимого упрощения конфигурации, на деле ставит свои поды под максимальный риск вытеснения. Для критичных, чувствительных к задержкам и хранящих состояние нагрузок вроде баз данных и важных интерфейсов запрос памяти выставляют равным лимиту, чтобы получить гарантированный класс. Накладные расходы реальны, но защита от вытеснения и стабильность того стоят.
Где провести границу между запросом и лимитом
Возникает вопрос, как соотносить запрос и лимит. Запрос определяет, на узел с какой свободной памятью под вообще попадёт планировщиком, а лимит определяет, при каком превышении ядро его убьёт. Разумно выставлять запрос близко к устойчивому базовому потреблению, чтобы планирование отражало реальную потребность. А вот по поводу лимита есть развилка. Если хочется гарантированного класса и предсказуемости, запрос делают равным лимиту. Если же приложение склонно к редким всплескам и есть запас на узле, лимит ставят выше запроса, получая класс со всплеском.
Важно не путать решения автомасштабирования с этим выбором. Вертикальный автомасштабировщик подгоняет запросы под фактическое потребление, и это напрямую влияет на класс качества обслуживания и риск вытеснения. То есть настройка ресурсов и автомасштабирование не независимы, их планируют вместе.
Какой стратегии придерживаться
Падения Java-подов по нехватке памяти почти всегда сводятся к одной первопричине: лимит контейнера считали по куче, забыв про память за её пределами. Несколько правил закрывают почти весь риск. Никогда не приравнивать лимит контейнера к размеру кучи, оставляя двести пятьдесят-пятьсот мегабайт запаса как минимум, а лучше считать по формуле со всеми областями. Использовать процентное задание кучи на современной Java вместо жёстких чисел, чтобы настройка не устаревала при смене лимита. Явно ограничивать область метаданных, кэш кода и прямую память, не давая им расти бесконтрольно. Для критичных нагрузок делать запрос равным лимиту ради гарантированного класса. И обязательно следить за фактическим потреблением через инструменты виртуальной машины и вертикальный автомасштабировщик, проверяя, что расчёты верны.
Загадка пода, который падает при вдвое меньшей куче, перестаёт быть загадкой, как только осознаёшь, что контейнерный лимит считает всю память процесса, а не только кучу. Тот, кто выставляет максимум кучи и забывает про область метаданных, стеки и прямые буферы, обречён ловить петли перезапусков в самый неподходящий момент. А тот, кто учитывает полный аппетит виртуальной машины и оставляет честный запас, получает стабильные Java-поды, которые держат и нагрузку, и всплески, не вылетая по сигналу ядра.