Привет! Я Костя Никитин, руководитель направления в одном из подразделений Т-Банка. Занимаюсь проектированием высоконагруженных систем — мы импортозамещаем автоматизированную банковскую систему. Если упростить, это автоматизация бухгалтерского учета: все банковские операции приходят к нам, нужно их сложить и посчитать. В Java-мире я уже больше двенадцати лет, начинал когда-то с 1.6.

Расскажу о проблемах, с которыми мы столкнулись, когда переехали в Kubernetes, как мы их порешали и как в итоге оптимизировали ресурсы при работе в виртуализации. Если вы хоть раз ловили OOMKilled на ровном месте и не понимали за что, может быть, узнаете себя.

Спойлер: в финале мы сократили потребление памяти на 18%, CPU — на 31%, а приложения при этом стали работать быстрее. Погнали.

Проблемы исходной конфигурации

Переезд в Kubernetes не прошел гладко. Мы ожидали упрощения оркестрации, но вместо этого столкнулись с рядом нестандартных ситуаций. Выделили пять ключевых проблем, которые тормозили разработку и эксплуатацию.

Долгий старт приложения. Стартуем приложение, оно пыхтит, что-то крутит, пытается запуститься. И тут прилетает алерт: «Извини, под перезагрузился». Оно снова стартует, снова пытается — и опять по кругу.

Убийство пода оркестратором с признаком OOMKill. Работало-работало, опа — убило. Прекрасно.

Медленный рост heap. Это мы уже потом узнали, что он медленный: смотрим на Grafana, а там за неделю он растет, растет, растет и падает. Непонятно.

Низкая утилизация CPU и оперативной памяти. Ресурсы нам выдавали на подразделение определенным образом: вот они есть, и вы должны их использовать. Используете плохо — больше не получите. Поэтому за утилизацию приходилось бороться.

Низкая, как нам казалось, производительность сервисов при выбранной конфигурации деплоймента. Вроде запихиваем туда десяток подов, а оно все равно работает медленно.

Как устроено наше окружение

Расскажу про окружение, чтобы было понятно, как все устроено:

  • Kubernetes, развернутый в двух кластерах. 

  • Ноды виртуальные, изначально на VMware, сейчас от этого отказались — куб должен разворачиваться на OpenStack.

  • Запросы ресурсов делали вручную, requests и limits прописывали самостоятельно для каждого деплоймента. 

  • Функциональность приложений разная, АБС большая — и нагруженные сервисы, и не очень, загрузка файлов, REST, Kafka, что угодно. 

  • В поде при этом один контейнер, больше ничего. 

  • Все приложения — на Spring.

Вроде все хорошо, но ничего не работает. Опущу, как мы долго это исследовали, и сразу начну с того, чего делать не надо и как до проблем не доводить.

Настройка мониторинга и сбор данных

Если мониторинга нет, его лучше завести. По мониторингу можно определить половину проблем и предотвратить их еще до того, как они наступят. Например, потребление памяти поползло вверх, значит, скоро ждем падение или другие неприятности.

Можно использовать ключ -XX:+HeapDumpOnOutOfMemoryError. Он поможет собрать дамп памяти, если под упал. По дампу можно найти утечку памяти и вообще посмотреть, что в памяти лежало. Но есть нюанс: надо подумать, куда этот дамп собирать. В кубе может не быть доступа к внутренним хранилищам, и записать дамп будет некуда. Или приложение настолько большое, что дамп на терабайт памяти получить просто не выйдет. У нас для этого есть специальная тулза RuntimeDiagnosticTools, которая отгружает дамп памяти в S3.

Еще один предварительный момент — включение логов garbage collector. Зачем это нужно, поговорим чуть позже.

Из чего состоит память приложения

Немного теории, чтобы разобраться, как устроена память нашего приложения. Начнем с состава памяти, потому что она состоит не только из heap — там много всего интересного.

Чтобы на это посмотреть, используем ключ Native Memory Tracking. После запуска приложения подключаемся к нему любым способом, вводим команду jcmd, передаем PID нашего процесса и ключ VM.native_memory summary.

Все цифры сняты с нашего реального приложения, которое отдает верхнеуровневую информацию по депозитам.

Total — общее потребление с двумя ключевыми словами: reserved и committed.

  • Reserved — выделение виртуальной памяти: и физическая память, и адреса, все, что туда ушло, поэтому цифра большая и страшная.

  • Committed — непосредственно выделение физической памяти, которую приложение потребляет сейчас.

Наше тестовое приложение потребляло около 640 МБ.

Heap — всем известный. Занимает 382 МБ — не все 640, как приложение целиком, а только 382.

Class — информация о том, сколько классов было загружено и сколько памяти нужно JVM, чтобы хранить их у себя для работы. Примерно 12 МБ ушло на загрузку классов.

Thread — потоки тоже не дешевые. По умолчанию на каждый стек отводится 1 МБ. В этом блоке видно, сколько памяти потрачено в итоге — около 16 МБ, — и сколько потоков используется сейчас.

Code — здесь хранится code cache, результат работы JIT-компилятора (Just-In-Time). При запуске JIT начинает оптимизировать код: запуски записываются, собирается статистика. Скомпилированный код нужно куда-то положить — он кладется в этот блок. Здесь видно, сколько уходит на code cache, и можно подумать над оптимизациями.

GC — несмотря на то что garbage collector борется за освобождение нашей памяти, он и сам ее потребляет. Причем немало — 62 МБ.

Internal — сюда пишется память, которую используют инструменты. Например, когда мы подключали jcmd к приложению, то, что при этом использовалось, записалось в Internal.

Other — про него в документации написано интересно: все, что не попало в другие блоки, пишется сюда. На практике в него периодически попадает память от Direct Byte Buffers, если работаем с большими ресурсами, файлами и так далее.

Symbol — таблица кодировки, она тоже загружается в JVM и потребляет память.

Shared Class Space — блок классов, которые JVM начала поддерживать с девятой версии. Нужен он вот зачем: мы запускаем приложение в одном и том же месте, останавливаем, снова запускаем, и этот блок позволяет стартовать быстрее. Shared Class Space связан с Class Data Sharing/AppCDS: JVM может использовать заранее подготовленный архив метаданных классов, чтобы ускорить старт и уменьшить часть памяти, разделяемой между процессами.

Наглядная диаграмма: heap отнял всего около 60%, а 40% занимает какая-то на первый взгляд непонятная ерунда, но без нее JVM не живет. И этот момент критически важно учитывать, когда запрашиваем ресурсы.

Мониторинг блоков памяти и тонкая настройка

Все блоки диаграммы можно отмониторить. Есть библиотека Micrometer с метрикой jvm_memory_max_bytes — у нее несколько индексов, которые покажут практически любую область памяти. Настраиваете Grafana и смотрите за всеми блоками, которые мы только что разобрали: как они растут, уменьшаются и так далее. Очень удобно, особенно когда решаете проблему.

Можно провести и тонкую настройку блоков. Например:

  • -Xss/-XX:ThreadStackSize — уменьшаем или увеличиваем память на стек потока;

  • -XX:ReservedCodeCacheSize — ограничиваем (или увеличиваем) хранилище code cache, который использует JIT.

Все эти ключи и тонкая настройка блоков идут обязательно с нагрузочным тестированием. В настройках JVM нет серебряной пули: что помогло нам, может не помочь вам. Поэтому будьте осмотрительны: не стоит применять новые параметры в рабочей среде перед выходными. Предварительное тестирование — обязательный этап.

И еще один ключ настройки — -Xshare:mode с выбором режима, это включение-отключение блока Shared Class Space. Если знаем, что JVM запустится где-то в другом месте, его можно выключить и сэкономить небольшую часть памяти.

Память контейнера: что еще крутится рядом с сервисом

Память контейнера можно посмотреть командой top (большинство работает в Linux). Увидим примерно такую картинку: под PID 24 есть процесс Java, он потребляет какое-то количество ресурсов. Чаще всего больше там ничего и не будет.

Вывод команды top
Вывод команды top

В примере я специально собрал docker-контейнер так, чтобы там работал еще и rsyslog. Что в контейнере есть, можно посмотреть прямо в Dockerfile — пройтись по слоям, как он собирается, и проверить, не запустили ли в базовом образе какую-нибудь утилиту аудита, которая будет жрать ресурсы контейнера.

Ресурсы, которые мы запрашиваем через requests/limits, делятся между всеми процессами контейнера. Если сосед начнет тупить, приложению с бизнес-логикой достанется меньше памяти и CPU, и начнутся проблемы.

Есть ключ -XX:+UseContainerSupport — он включен по умолчанию с давних версий Java. Но Java не всегда правильно определяет выданные ей ресурсы. Убедиться в этом и посмотреть, что происходит, можно через логирование -Xlog:os+container=[level] и увидеть, как JVM работает с ресурсами ОС — запрашивает, сколько получила, сколько определила.

Сколько памяти отдавать под heap

Поговорим про два ключа: -XX:-InitialRAMPercentage и -XX:-MaxRAMPercentage. Можно задавать heap и через -Xmx, но проценты тут удобнее: учитывая состав памяти и то, что крутится в контейнере, вы должны понимать, какой процент от запрошенной памяти можно отдать под heap.

Можно избежать долгих стартов в стандартном приложении, которое использует на 70—80% примерно 1 000 mCPU и 2 ГБ RAM. Для этого не нужно ставить InitialRAMPercentage ниже 50. Иначе куб будет ждать, пока приложение стартует, не получит probe и убьет под. И так по кругу, потому что приложению тяжело: ему сначала надо запросить память у ОС, а это долгий процесс.

То же с максимумом: если поставим 95—98% — не оставим ресурсов ни блокам JVM, ни тому, что есть в контейнере. И куб начнет убивать поды с OOMKill. Это самая распространенная причина — неправильно выбранный размер heap.

Все, естественно, зависит от исходных ресурсов. Если есть терабайты данных, то и 95% может отработать. В настройках JVM все всегда зависит от приложения — нельзя просто взять и пойти деплоить.

Нюансы с garbage collector

Вот тут и пригодятся логи GC -Xlog:gc, которые мы включали в начале. Рассмотрим известные сборщики от Java 8 до 21: Serial, Parallel, G1, Z и Shenandoah.

Таблица выбора GC JVM в зависимости от полученных ресурсов
Таблица выбора GC JVM в зависимости от полученных ресурсов

JVM не настолько глупая, как кажется. Она автоматически определяет выданные ей ресурсы и, если мы не указали GC ключом, сама выбирает его в зависимости от этих ресурсов. Если GC не задан явно, JVM выбирает его через ergonomics — по доступным CPU, памяти, версии JDK и окружению. В контейнере это особенно важно: если JVM видит мало CPU или памяти, выбор может отличаться от того, что вы ожидали. Поэтому GC лучше не угадывать, а проверять по startup logs/GC logs. 

Для JDK 21 типичный дефолт на server-class окружении — G1, но при малых ресурсах возможны нюансы. Для JDK 25+ ситуация меняется в сторону G1 как дефолта во всех окружениях. Он прекрасно работает до определенного момента — на тяжелых нагруженных приложениях, скорее всего, уже не вытянет. Если ядер больше двух и heap приближается к 4 ГБ, JVM может выбрать Parallel — раз есть многопоточность, поехали.

Старшие сборщики в основном созданы для больших heap — от 4 ГБ, что в кубе бывает далеко не у всех. Особняком стоит G1: его в принципе можно нормально крутить и на 2 ГБ. Так происходит потому, что JVM пытается оптимизировать накладные расходы на GC под те ресурсы, что вы запросили.

Сколько памяти потребляет определенный GC при запуске тестового приложения и при одной и той же конфигурации
Сколько памяти потребляет определенный GC при запуске тестового приложения и при одной и той же конфигурации

Мы рассматривали блоки памяти приложения. Оно на Java 21, 2 ГБ, в контейнере, отработало около получаса:

  • Memory committed — сколько GC съедает на свою работу;

  • Heap committed — сколько всего забрали на heap;

  • Heap use — сколько потребляется прямо в моменте снятия статистики.

Serial — самый дешевый GC. Мало ресурсов — JVM берет Serial: дешево и сердито. Но у сборщиков разное поведение. Сборщик Serial, взяв 533 МБ, скорее всего, уже никогда не вернет их операционной системе: даже если прошло полчаса и реальное потребление составляет всего 86 МБ, он продолжает удерживать все те же 533 МБ. У Parallel ситуация выглядит немного лучше: он периодически дергает GC ergonomics, пытаясь сообразить, не пора ли отдать хотя бы часть памяти обратно.

У старших ребят накладные расходы тоже высокие, но поведение разное. Если сравнить Z и Shenandoah, различия серьезные: Z почти под потолок себе вставляет, а Shenandoah берет с запасом, и ему хорошо. 

В последних строках Heap commited — сколько забрал у операционной системы, Heap Use — сколько используется в момент времени. Фраза «под потолок» означает, что сборщик мусора использует почти всю память, которую забрал у операционной системы. Конкретно речь идет про Z: забрал 371 и использует 316 — «под потолок».

Чтобы заработал выбранный нами сборщик мусора, нужно помочь JVM определить ресурсы — есть ключ -XX:ActiveProcessorCount=x, которым мы говорим JVM, сколько у нее сейчас ядер. Он влияет не только на количество потоков GC и его выбор (если вы не выбрали сам), но и на пулы вроде ForkJoinPool и другие внутренние.

GC я советую все-таки выбирать самому — JVM умная, но лучше самому. И только при очень большой необходимости делать тюнинг. Честно скажу: за карьеру я тюнил GC один раз. Мы поработали, потом пошли, оптимизировали код и вернулись обратно. Тема опасная, без острой нужды туда лучше не лезть, а если лезть — то с нагрузочным тестированием.

Подведем промежуточный итог. С учетом ограниченных ресурсов и правильных настроек JVM мы:

  • победили долгие старты — правильной настройкой начального heap с учетом состава памяти и контейнера;

  • победили медленный рост кучи — сменой garbage collector и правильным определением ресурсов;

  • победили OOMKill — правильным расчетом heap, корректным определением ресурсов и выбранным GC.

Оптимизация: как на самом деле работает CPU в кубе

Есть распространенное мнение: запрашиваешь 1 000 mCPU — получаешь физическое ядро. На самом деле нет. Мы всегда запрашиваем только время. 

Есть приложение, есть процессор, мы запросили 1 000 миллисекунд (цифры утрированные, чтобы проще считать). 

Серия картинок о том, как приложение потребляет выделенные ему ресурсы CPU:

Приложение начинает работать, в один момент времени — условно за секунду — приходят четыре запроса.

Приложение их обрабатывает, тратит 800 из 1 000, остается 200. Приходит еще запрос на 100 — какой-нибудь scheduler запустился, — и garbage collector такой: «Почищу-ка я память», тоже на 100.

В итоге приложение загружено полностью, процессорного времени не осталось.

Приходит следующий запрос: «Хочу 200 мс». А их нет.

Тут вступает цикл процессорного времени: вы запросили тысячу, нужно чуть-чуть подождать, и ОС в кубе снова выдаст нам следующую тысячу. Время на смену цикла небольшое — порядка 60 мс для наших расчетов, — но его придется подождать. Запрос, который пришел, пользователь будет ждать уже не 200 мс, а 260 — из-за смены цикла.

В этот момент приложение начнет троттлить. Если есть хороший мониторинг с метрикой троттлинга, мы увидим, как он растет. И если вместо HTTP-запроса в цикл попал garbage collector, он будет ждать, а память — расти. В какой-то момент троттлящее приложение упадет со всем известным OOMKill.

Эксперимент: меньше подов — выше производительность

Что мы сделали для оптимизации, учитывая историю с CPU. Провели тест: взяли то же подопытное приложение, обстреляли JMeter'ом — 40 000 запросов, 400 потоков, 5 секунд на разгон.

Всего ресурсов — 3 000 mCPU (≈3 CPU) и 4,5 ГБ оперативки. Разделили их тремя способами:

  • 6 реплик — как было заведено, по 500 mCPU и 750 МБ на реплику;

  • 4 реплики — по 750 mCPU и 1 125 МБ;

  • 2 реплики (экспериментальный) — по 1 200 mCPU и 1 750 МБ. Здесь мы уже экономим: 0,6 CPU и целый гигабайт оперативки.

Результаты. По пропускной способности без прогрева варианты примерно равны, но шесть реплик хуже четырех и тем более двух. С прогревом (когда приложение уже несколько раз обстреляли) два инстанса почти вдвое обгоняют шесть. По latency то же самое: без прогрева двойка немного впереди, с прогревом — заметно серьезнее.

Почему два инстанса приложения работают быстрее, чем шесть с экономией ресурсов. На каждый деплоймент тратится те самые ~40% памяти на обслуживание JVM, а в переключениях цикла — процессорное время. У множества мелких подов больше ресурсов уходит на обслуживание, чем на реальную работу. А JVM всегда любила большие ресурсы — это стоит учитывать.

Проведя тест, мы все перераспределили и в итоге:

  • снизили потребление памяти на 18% — освободили около 20 ГБ;

  • снизили потребление CPU на 31% — около 15 000 mCPU;

  • увеличили общую утилизацию почти на 30%.

Мы сопоставляли не абстрактные показатели «до и после», а абсолютно идентичные сервисы, окружение и нагрузочные профили. Замеры метрик выполнялись по следующим фиксированным параметрам: RSS/container_memory_working_set_bytes, CPU throttling, latency p95/p99, throughput, GC pauses, а также heap committed/used.

При этом приложения стали работать быстрее.

Прикинули и ориентир для выбора между горизонтальным и вертикальным масштабированием — серединка примерно 1 000 mCPU и 2 ГБ оперативки. Меньше — лучше идти в вертикальное масштабирование. Больше — смотрим по обстоятельствам, возможно, уже горизонтальное.

Аллокаторы памяти: еще один способ сэкономить

Можно было бы закончить, но нам было мало. Посмотрели в сторону аллокаторов памяти. В двух словах — это библиотеки уровня ОС, которые управляют оперативной памятью и выдают приложению блоки под работу. Стандартный аллокатор у нас — glibc.

Как работает аллокатор. При работе приложения он выделяет арену 64 МБ и складывает туда объекты. Положили два объекта по 10 и 20 МБ — осталось ~34 МБ.

Серия картинок о том, как работает аллокатор памяти:

Приходит объект на 20 — кладет, занято 50, осталось 14.

Приходят следующие 20 — а места нет: 14 < 20.

Аллокатор отрезает еще один чанк 64 МБ и кладет объект туда.

А те 14 МБ улетучились — лежат в дефрагментированной памяти и числятся за приложением. Мегабайты тут небольшие, но ситуация частая.

Мы решили посмотреть альтернативные аллокаторы. Самые известные: jemalloc, tcmalloc от Google и mimalloc от Microsoft. Их вообще десятки, на любой вкус. Мы взяли jemalloc, потому что на нем работает Cassandra. На сайтах у всех красивые бенчмарки, доказывающие, что «мой аллокатор всех порвет». Так что, если планируете заносить аллокатор к себе, советуем проводить нагрузочное тестирование: не факт, что он поможет.

Какие преимущества у альтернативных аллокаторов:

  • Многопоточная работа с памятью. Стандартный раньше работал в один поток: все к нему заходят, а он по очереди раздает память. Сейчас в glibc это тоже завезли, он развивается.

  • Своя арена под каждый поток. В многопоточном режиме аллокатор нарезает не одну арену на всех, а по арене на поток — получается быстрее.

  • Кэширование свободной памяти и free-lists. Кэширование — аллокатор забирает свободную память у ОС и держит у себя, чтобы отдать, когда понадобится. Free-lists — это что-то вроде карты адресов, откуда забрать свободную память, аллокатор сам ее обновляет на уровне ОС, и получить память можно быстрее.

  • Больше размерных классов под объекты. Объекты обычно «некруглого» размера, и аллокаторы сжимают их в подходящий размерный класс перед укладкой в арену. У альтернативных таких классов больше.

Развиваются все аллокаторы, включая стандартный, — стоит заходить и смотреть, что нового.

Чтобы включить jemalloc в качестве аллокатора, нужно завести переменную и написать путь до либы.

ENV
LD_PRELOAD=”/usr/local/lib/libjemallock.so.2”

Проверить, что аллокатор заработал, можно командой pmap — она покажет карту памяти приложения, и в некоторых адресах видно, что их забронировала библиотека. Значит, приложение работает с новым аллокатором. Кстати, можно настраивать разные аллокаторы на разные приложения в одной ОС.

Вывод команды pmap
Вывод команды pmap

Настройки jemalloc под профиль нагрузки

У jemalloc есть бонус — настройки под то, как работает приложение. Разберем три профиля.

Высокое потребление ресурсов с приоритетом на CPU. Отдаем работу с памятью на фоновые процессы. Поток в Java закончил, ему надо вернуть память — раньше он делал это сам, а jemalloc говорит: «Забей, я сам». Включаем автоматическое определение метаданных — jemalloc сам посчитает ресурсы и поймет, как выстроить работу. И ставим высокое время затухания (decay) — это время, после которого поток отдает память обратно ОС. Раз потребление высокое, память хочется приберечь, а высокое время затухания экономит CPU: при отдаче памяти он как раз и расходуется.

Высокое потребление с уклоном в оперативку — тоже фоновые процессы для отдачи памяти. Добавляем кэш. Время затухания понижаем, но не до нуля: делиться памятью с ОС все-таки надо, а CPU тут не жалко. Арен делаем немного, лучше по количеству физических ядер: меньше разделения на арены и меньше дефрагментации.

Низкое потребление (что-то вызывается раз в год). Ставим кэш, работаем в одной арене — многопоточность не нужна, приложение низконагруженное. Время затухания минимальное: память, скорее всего, больше не понадобится — поработали и сразу отдали.

Как добавить настройки jemallock. Переменная MALLOC_CONF, через запятую прописываем нужные опции, и приложение начинает работать с ними.

Все эти настройки важно всегда тестировать под свою нагрузку, функциональность и приложение.

Итоги

Чек-лист для борьбы с проблемами запуска в Kubernetes:

  • Добавить ключ на сбор дампа памяти.

  • Включить логи garbage collector — для разбора проблем (особенно если утечка действительно есть) и чтобы убедиться, что работает тот GC, который мы хотели.

  • Оценить состав памяти приложения — сколько уйдет на GC, сколько на все остальное — и запросить грамотные ресурсы.

  • Настроить мониторинг, в высоконагруженном мире без него тяжело.

  • Выставить память в контейнере с учетом всего вышесказанного.

  • Помочь JVM определить ресурсы ключами, если она не справляется.

  • Выбрать garbage collector. В крайнем случае настроить его, но я не советую.

По оптимизации важно: JVM любит много ресурсов. Не нужно пытаться сузить и наделать много мелких подов — лучше сделать один жирный (в зависимости от требований к надежности). У нас, например, схема 2+2 — минимум четыре пода в любом случае. А еще важно правильно выбирать количество инстансов в зависимости от нагрузки и ресурсов и присмотреться к альтернативным аллокаторам.

Полезные ссылки

  • Спецификация Java — про все ключи там есть подробное описание, в том числе про диагностические инструменты: Native Memory Tracking, jcmd и прочее.

  • Документация jemalloc — там все конфигурации, про которые я рассказывал.

Комментарии (1)


  1. kirik
    28.08.2026 11:05

    tldr:

    • выяснили, что heap — это далеко не вся память Java-процесса;

    • правильно настроили размер heap (InitialRAMPercentageMaxRAMPercentage), CPU, GC и другие JVM-параметры;

    • обнаружили, что много маленьких Java-подов менее эффективно, чем меньшее число более жирных — из-за overhead каждой JVM;

    • попробовали jemalloc вместо стандартного allocator

    даа.. поисписался хабр раз для такого надо 10 страниц текста.. да еще и от тинька..