← назад к разделу

Запустить Spring Boot в контейнере просто. Но по умолчанию JVM «смотрит» не на лимиты контейнера, а на параметры всего хоста — и это приводит к неожиданным падениям с кодом 137 или неоправданно раздутым пулам потоков. Разберём, почему так происходит и как это исправить.

Начнём с общей картины: вот как складывается память JVM при лимите контейнера 512 МБ — и что меняется, когда лимит поднимают.

Хост 32 ГБ · контейнер запущен с --memory=512m куча ≈ 8 ГБчетверть от 32 ГБ хостаполоса обрезана До Java 10лимит 512 МБ exit 137 — OOMKilledпроцесс убит снаружи, Java-исключения нет куча 384 МБ-XX:MaxRAMPercentage=75.075% от лимита: 512 → 384 МБ 128 МБостальные 128 МБ: metaspace, стеки, JIT, буферы Java 10+JVM читает cgroup: видит 512 МБ Лимит подняли до 768 МБстрока запуска та жекуча 576 МБ192 МБ75% уже от 768 МБ-Xmx512m остался бы 512Обратный счёт: нужна куча 512 МБ → 512/0,75 ≈ 700 → берут 768 MaxRAMPercentage привязывает кучу к лимиту контейнера-Xmx — фиксированное число: при смене лимита его правят руками

Куча считается от того, что JVM считает доступной памятью: до Java 10 это были 32 ГБ хоста, с Java 10 — 512 МБ лимита контейнера. При 75% внутри лимита остаётся 128 МБ на metaspace, стеки и буферы; поднимут лимит до 768 МБ — куча сама станет 576 МБ, а -Xmx пришлось бы править руками.

Историческая проблема: JVM видела хост, а не контейнер

У хоста 32 ГБ оперативной памяти, контейнер запущен с --memory=512m — и его раз за разом убивает Linux сигналом SIGKILL, потому что JVM «видит» 32 ГБ и выделяет кучу (heap) в несколько гигабайт.

Так было потому, что до Java 10 JVM определяла доступную память и число процессоров, читая параметры операционной системы напрямую — без учёта cgroups (механизма ядра Linux, которым Docker ограничивает ресурсы контейнера).

Начиная с Java 10 (и обратным портом в Java 8u191) JVM умеет читать cgroup-лимиты и корректно считать доступные ресурсы. Достаточно использовать актуальный образ — например, eclipse-temurin:21-jre.

OOMKilled: exit 137 против OutOfMemoryError

Важно не путать два разных события:

OutOfMemoryError — исключение внутри JVM. Возникает, когда Java-куча заполнена и сборщик мусора не смог освободить достаточно памяти. Приложение падает «изнутри», JVM пишет сообщение в лог.

OOMKilled (exit 137) — процесс убит снаружи операционной системой. Контейнер превысил лимит памяти, установленный в docker run --memory=... или в resources.limits.memory у Kubernetes. Никакого Java-исключения нет — просто процесс обрывается без предупреждения.

Короткая формула: OutOfMemoryError — это JVM кричит «у меня кончилось место»; OOMKilled — это ОС говорит «ты взял слишком много, я тебя выключаю».

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

docker inspect <container-id> --format '{{.State.OOMKilled}}'

Если вернулось true — виновата ОС, не JVM.

Чтобы увидеть, из чего складывается память процесса помимо кучи, включают учёт нативной памяти: -XX:NativeMemoryTracking=summary при запуске и jcmd <pid> VM.native_memory summary на работающем процессе — отчёт покажет кучу, metaspace, стеки потоков, кеш кода и внутренние структуры по отдельности:

Native Memory Tracking:
Total: reserved=1.6GB, committed=780MB
-  Java Heap (reserved=512MB, committed=512MB)
-      Class (reserved=1.1GB, committed=45MB)
-     Thread (reserved=210MB, committed=210MB)
-       Code (reserved=250MB, committed=38MB)

Смотреть нужно на committed, а не на reserved: зарезервированное — это адресное пространство, занятое — реальная память. Здесь потоки заняли 210 МБ — примерно две сотни потоков по мегабайту стека — при куче в 512 МБ. Если контейнер убивают, а куча далека от лимита, растёт что-то из этого списка.

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

Thread. Самая частая причина после кучи. Стек потока по умолчанию — около мегабайта (-Xss), и двести потоков это двести мегабайт. Откуда они берутся: пул веб-сервера (server.tomcat.threads.max — 200 по умолчанию), пулы клиентов к базе и к соседним сервисам, собственные исполнители. Лечится не флагом, а числом потоков: меньше потоков в пуле или переход на виртуальные, у которых стек растёт по надобности.

Metaspace и Class. Метаданные классов. Растут от числа загруженных классов: большое приложение Spring с проксированием даёт 80–150 МБ, и это нормально. Ненормально — когда растёт непрерывно: это утечка загрузчиков классов, обычно из-за перезагрузки контекста или динамической генерации классов. Предел ставят флагом -XX:MaxMetaspaceSize, чтобы получить внятную ошибку вместо смерти контейнера.

Code. Скомпилированный код. У долгоживущего приложения 40–80 МБ, и это тоже норма.

Internal и Other. Внутренние структуры и прямые буферы памяти. Здесь и живёт неприятное открытие: предел прямой памяти по умолчанию равен размеру кучи. То есть куча 512 МБ означает, что сверх неё приложение может занять ещё до 512 МБ прямыми буферами, и суммарно процесс легко превысит лимит контейнера. Прямые буферы используют сетевые библиотеки (Netty в реактивном стеке, драйверы), сжатие, некоторые кэши. Поэтому при заметной работе с сетью предел задают явно: -XX:MaxDirectMemorySize=128m — и тогда утечка буферов даёт OutOfMemoryError: Direct buffer memory вместо загадочного OOMKilled.

И главное ограничение самого отчёта: он не видит всего. Учёт нативной памяти показывает то, что выделила JVM, и не показывает то, что выделили библиотеки через системный аллокатор напрямую. Классический случай — арены glibc malloc: стандартный аллокатор в Linux создаёт отдельные области памяти на каждый поток (до восьми на ядро), и при большом числе потоков это десятки и сотни лишних мегабайт, которых в отчёте нет. Признак именно этой причины: сумма всех committed заметно меньше RSS процесса, при том что куча стабильна. Лечится двумя способами: ограничить число арен переменной окружения MALLOC_ARENA_MAX=2 (проверенная в проде мера, почти без потерь в скорости) или взять образ с другим аллокатором — jemalloc или tcmalloc. Разбор того, из чего вообще складывается память JVM и как её мерить, — в статье про сборку мусора.

Что включить заранее, чтобы первый OOM было чем расследовать

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

ENTRYPOINT ["java", \
  "-XX:MaxRAMPercentage=75.0", \
  "-XX:+HeapDumpOnOutOfMemoryError", \
  "-XX:HeapDumpPath=/dumps", \
  "-XX:+ExitOnOutOfMemoryError", \
  "-jar", "app.jar"]

HeapDumpOnOutOfMemoryError сохраняет снимок кучи в момент ошибки — единственный артефакт, по которому потом видно, кто занял память. HeapDumpPath указывает, куда его писать, и это в контейнере главный вопрос: писать надо в том, а не в слой контейнера, иначе файл исчезнет вместе с контейнером. В Kubernetes это подключённый том (часто emptyDir и последующая выгрузка в хранилище объектов), при запуске вручную — обычный том или смонтированный каталог:

docker run --memory=768m -v dumps:/dumps my-app:1.0

Размер снимка примерно равен занятой куче — для кучи 512 МБ это 400–500 МБ, и место под него должно быть. Отсюда практическое ограничение: снимок кучи имеет смысл для OutOfMemoryError внутри JVM, а при убийстве контейнера ядром за перерасход памяти (тот самый код 137) его никто записать не успеет — там помогает только учёт нативной памяти из раздела выше и метрики.

ExitOnOutOfMemoryError завершает процесс сразу после первой такой ошибки. Звучит грубо, а поведение по умолчанию хуже: JVM продолжает работать в состоянии, где часть потоков умерла, запросы падают через один, а проверка живости отвечает «жив». Быстрая смерть плюс перезапуск понятнее и восстанавливается автоматически. Рядом стоит -XX:+CrashOnOutOfMemoryError — он дополнительно пишет аварийный файл, но для обычного сервиса достаточно первого.

И то, что делают в том же месте: включают учёт нативной памяти (-XX:NativeMemoryTracking=summary) на постоянной основе. Он стоит около одного процента скорости и превращает будущее расследование из гадания в чтение отчёта.

Как управлять памятью: MaxRAMPercentage вместо -Xmx

Раньше принято было явно задавать размер кучи через -Xmx512m. В контейнерах это неудобно: при изменении лимита контейнера нужно менять и флаг приложения.

Современный подход — -XX:MaxRAMPercentage. Флаг задаёт процент от доступной (ограниченной cgroup) памяти, которую JVM отдаст под кучу.

Одно условие обязательно: -Xmx в строке запуска быть не должно. Если он там остался — от прошлой настройки, из переменной окружения, из чужого скрипта, — процент молча не сработает, победит фиксированное число. Никакой ошибки вы не увидите: контейнеру подняли лимит до двух гигабайт, а куча как была 512 МБ, так и осталась. Поэтому «добавить -Xmx и зафиксировать размер кучи» — не решение проблемы OOMKilled, а способ отключить единственный механизм, который сам подстраивается под лимит.

FROM eclipse-temurin:21-jre

WORKDIR /app
COPY build/libs/app.jar app.jar

# JVM сама прочитает лимит контейнера и возьмёт 75% под кучу
ENTRYPOINT ["java", \
  "-XX:MaxRAMPercentage=75.0", \
  "-jar", "app.jar"]
# Контейнер получает 512 МБ; JVM выделит ~384 МБ под кучу
docker run --memory=512m myapp

Рекомендуемые значения для Spring Boot:

  • 70–75% — типичный веб-сервис без интенсивной работы с файлами или нативной памятью.
  • 50–60% — если приложение активно использует кэши вне кучи (например, Caffeine с softValues) или нативную память (DirectByteBuffer, библиотеки через JNI).

Оставшаяся память нужна JVM для метапространства (metaspace), стеков потоков, кода JIT-компилятора и буферов ввода-вывода — её нельзя занимать под кучу целиком.

Как выбрать лимит контейнера

Практическое правило для Spring Boot:

лимит контейнера = нужный размер кучи / (MaxRAMPercentage / 100)

Для приложения с ожидаемой кучей 512 МБ и MaxRAMPercentage=75 минимальный лимит контейнера: 512 / 0.75 ≈ 700 МБ. Оставшиеся 25 процентов и есть тот самый запас на всё, что живёт вне кучи: стеки потоков, метаданные классов, буферы. На практике округляют вверх и берут 768 МБ или гигабайт.

Формула перестаёт работать на совсем маленьких лимитах. Пока доступной памяти меньше примерно 256 МБ, JVM считает машину крошечной и применяет другой флаг — MinRAMPercentage, а у него значение по умолчанию 50, а не 75. Контейнер с --memory=200m получит кучу 100 МБ, сколько бы вы ни написали в MaxRAMPercentage. Для таких лимитов размер задают явно и проверяют, что получилось.

Какой сборщик мусора достанется контейнеру

Ещё один сюрприз, который прячется ровно в тех цифрах, что выше. JVM выбирает сборщик мусора сама, и на «серверный» G1 она соглашается, только если видит не меньше двух процессоров и не меньше примерно 1792 МБ памяти. Не хватило любого из двух — и молча включается SerialGC: однопоточный сборщик, который останавливает приложение на каждую уборку целиком.

Контейнеры из наших примеров — 512 МБ и 768 МБ — под это условие попадают. Проверить, что досталось вам, можно так:

docker run --rm --memory=512m eclipse-temurin:21-jre \
  java -XX:+PrintFlagsFinal -version | grep -E "UseG1GC|UseSerialGC"

Если в строке UseSerialGC стоит true, а вы этого не заказывали — либо поднимайте лимит памяти, либо задавайте сборщик явно флагом -XX:+UseG1GC.

cgroup v1 и v2: откуда JVM читает лимит

Механизм, которым ядро ограничивает контейнер, называется cgroup, и у него две несовместимые версии. Знать про них нужно ровно в одном случае: когда JVM лимит не видит и ведёт себя так, будто в её распоряжении вся машина.

Где лежит лимит. В cgroup v1 это файл /sys/fs/cgroup/memory/memory.limit_in_bytes, в v2 — /sys/fs/cgroup/memory.max. Имена разные, и код, который умеет читать одно, не читает другое. Проверить изнутри контейнера:

docker exec -it my-app cat /sys/fs/cgroup/memory.max            # v2
docker exec -it my-app cat /sys/fs/cgroup/memory/memory.limit_in_bytes   # v1

Почему это всплывает. Современные дистрибутивы (Ubuntu 22.04 и новее, RHEL 9, актуальный Docker Desktop) работают на v2, и все поддерживаемые версии Java её понимают. Проблемы бывают в двух случаях: старый образ JDK (поддержка v2 появилась в JDK 15, а в 11 приехала обновлениями — очень старые сборки 8 и 11 на v2 лимит не видят) и гибридный режим ядра, где часть контроллеров осталась в v1. Симптом всегда один: Runtime.getRuntime().maxMemory() внутри контейнера с --memory=512m показывает четверть памяти хоста, а не 128 МБ.

Как убедиться, что всё в порядке. Одна команда, которую стоит прогнать при переходе на новый базовый образ:

docker run --rm --memory=512m eclipse-temurin:21-jre \
  java -XX:+PrintFlagsFinal -version | grep -E "MaxHeapSize|ActiveProcessorCount"

MaxHeapSize должен быть около 75 процентов от 512 МБ. Если он в разы больше — лимит не прочитан, и дальше искать надо в версии JDK и в версии cgroup, а не в настройках приложения.

Отдельно стоит помнить, что то же самое касается и процессоров: в v2 квота лежит в cpu.max, и именно из неё JVM вычисляет число доступных ядер — о чём следующий раздел.

Ядра и пулы потоков: availableProcessors

JVM определяет число доступных процессоров через Runtime.getRuntime().availableProcessors(). От этого числа считают свой размер ForkJoinPool.commonPool(), потоки сборщика мусора и JIT-компилятора, пулы Netty и Reactor — то есть почти всё, что работает внутри JVM само по себе.

Пулы Tomcat сюда не относятся, и это стоит запомнить отдельно: у встроенного сервера Spring Boot server.tomcat.threads.max равен 200 всегда, сколько бы ядер ни было видно. Двести потоков на одно ядро — это не «JVM неправильно посчитала», а значение по умолчанию, которое никто не менял.

До Java 10 метод возвращал число логических процессоров хоста, а не контейнера. Хост с 32 ядрами, контейнер с одним (--cpus=1) — и общий пул ForkJoinPool заводил 31 поток, сборщик мусора запускался в несколько потоков, а работать им всем приходилось по очереди на одном ядре.

до Java 10 хост 32 ядра видит 32 ForkJoinPool 31 Java 10+ --cpus=1 видит 1 ForkJoinPool 1 пулы Tomcat --cpus=1 ядра ни при чём 200 потоков

Одно и то же число ядер в трёх строках: сравните, что видит JVM до Java 10 и после, и где размер пула от ядер не зависит вовсе.

Начиная с Java 10 availableProcessors() читает квоту CPU из cgroup и возвращает разумное число. Но «само всё правильно» — слишком сильное слово, тут есть на что посмотреть:

  • Дробная квота округляется вверх: --cpus=1.5 даёт availableProcessors() = 2, --cpus=2.4 — целых 3. Процессорного времени при этом ровно столько, сколько заказали.
  • --cpu-shares квотой не является — это вес при дележе процессора, когда его на всех не хватает. JVM его не видит вовсе: с --cpu-shares=512 на 11-ядерной машине availableProcessors() вернёт 11.
  • В Kubernetes на число ядер влияет только limits.cpu; requests.cpu — это про планировщик, а не про то, что увидит JVM.

Если результат не нравится, число задаётся вручную — флагом -XX:ActiveProcessorCount.

# Контейнер видит 2 ядра; JVM сообщит availableProcessors() = 2
docker run --cpus=2 myapp

Пример такого переопределения:

java -XX:ActiveProcessorCount=2 -jar app.jar

Откуда берётся «нужный размер кучи»

Формула выше считает лимит контейнера от размера кучи, и остаётся вопрос, откуда взялся сам размер. Правильный ответ — из измерения, а не из круглого числа.

Порядок такой.

Снять занятость кучи под нагрузкой. Приложение под реальной или близкой к реальной нагрузкой, метрика jvm_memory_used_bytes с разбивкой по областям (её отдаёт Actuator через Micrometer), интервал — сутки или хотя бы полный рабочий день, чтобы попали пики и фоновые задачи. Смотреть надо на занятость сразу после полной сборки мусора: это и есть объём живых данных, всё остальное — мусор, который сборщик уберёт. Удобнее всего по графику: нижняя граница «зубьев» пилы.

Взять запас. Рабочее правило — живые данные умножить на два-три: сборщику нужно свободное место, иначе он работает непрерывно и съедает процессор. Куча, в которой живых данных 90 процентов, формально влезает, а практически даёт постоянные паузы и в итоге OutOfMemoryError: GC overhead limit exceeded.

Проверить, а не поверить. Выставили размер — снова посмотрели на метрику и на время, которое приложение проводит в сборке мусора (jvm_gc_pause_seconds). Ориентир: доля времени в сборке — считанные проценты, длинные паузы редкие. Если после уменьшения кучи паузы выросли, а процессор стал занят — вы сэкономили память за счёт скорости, и это осознанный выбор, а не удача.

Не задавать одно значение на все сервисы. Одинаковый лимит для десяти разных приложений означает, что половина из них голодает, а половина держит неиспользуемую память. Это дороже, чем пять минут на замер каждого.

Что обычно выясняется при первом же замере: обычному веб-сервису на Spring Boot достаточно куда меньше, чем ему выдали. Живых данных 150–250 МБ, куча 512 МБ, лимит контейнера 768 МБ — типичный правильный расклад для сервиса, которому «на всякий случай» поставили два гигабайта.

Полная конфигурация: минимальный рабочий пример

FROM eclipse-temurin:21-jre

WORKDIR /app
COPY build/libs/app.jar app.jar

ENTRYPOINT ["java", \
  "-XX:MaxRAMPercentage=75.0", \
  "-XX:+UseContainerSupport", \
  "-jar", "app.jar"]
# Запуск с явными лимитами — JVM автоматически адаптируется
docker run \
  --memory=768m \
  --cpus=2 \
  myapp

Флаг -XX:+UseContainerSupport включён по умолчанию начиная с Java 10 (а в Java 8 появился с обновлением 191), но его полезно указывать явно в качестве документации — сразу понятно, что образ рассчитан на запуск в контейнере.

Холодный старт: за что борются секунды

В контейнере время старта становится заметным: выкат с постепенной заменой экземпляров, автоматическое масштабирование под нагрузкой, перезапуск после сбоя — всё это ждёт, пока приложение поднимется. Spring Boot стартует 8–30 секунд, и половина этого времени уходит на работу, которую можно выполнить заранее.

Разделяемый архив классов (CDS). JVM при старте загружает и разбирает тысячи классов. Архив сохраняет их уже в разобранном виде, и следующий запуск читает готовое отображение памяти. Базовый архив для классов платформы в образах уже есть, а интересен архив для классов приложения (его исторически называют AppCDS). Начиная с JDK 19 он делается одной командой без пробного запуска:

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY build/libs/app.jar app.jar
RUN java -XX:ArchiveClassesAtExit=app.jsa -Dspring.context.exit=onRefresh -jar app.jar
ENTRYPOINT ["java", "-XX:SharedArchiveFile=app.jsa", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]

Здесь приложение запускается один раз во время сборки, доводит контекст до готовности и выходит, оставив архив; финальный запуск его использует. Выигрыш для типичного приложения Spring Boot — 20–40 процентов времени старта. Условие: архив должен соответствовать тому же jar и той же версии JVM, иначе он просто игнорируется (с предупреждением в логе — его стоит проверить, а не предполагать, что всё работает).

Меньше компиляции на старте. -XX:TieredStopAtLevel=1 останавливает компилятор на первом уровне: код компилируется быстро и грубо, приложение стартует быстрее. Это правильный флаг для короткоживущих процессов — задач по расписанию, конвейерных шагов, тестов — и неправильный для сервиса, который потом часами обрабатывает трафик: без полной оптимизации он будет работать в разы медленнее. Соседний -Xshare:auto и так включён, а -XX:+UseSerialGC для коротких задач экономит ещё немного на инициализации.

Что даёт больше всего. Если старт критичен по-настоящему (масштабирование за секунды, запуск по событию), инструменты другие: заранее вычисленный контекст Spring (spring-context-indexer, AOT-обработка в Spring Boot 3) и компиляция в машинный код через GraalVM Native Image — старт в десятки миллисекунд ценой длинной сборки и ограничений на отражение. Это отдельная большая тема, и начинать стоит не с неё: архив классов и честный замер того, на что уходят секунды (лог старта Spring Boot показывает время по шагам), обычно дают достаточный выигрыш за час работы.

Коротко

  • До Java 10 JVM читала параметры хоста, игнорируя cgroup-лимиты контейнера — это приводило к OOMKilled. Лимит JVM читает из cgroup: в v2 это memory.max, в v1 — memory.limit_in_bytes; проверка одной командой PrintFlagsFinal с MaxHeapSize при смене базового образа.
  • OOMKilled (exit 137) — процесс убит ОС за превышение лимита памяти контейнера; OutOfMemoryError — Java-исключение внутри JVM, разные причины и лечение.
  • Используйте -XX:MaxRAMPercentage=75.0 вместо -Xmx — JVM сама считает нужный объём от лимита контейнера. MaxRAMPercentage работает, только пока в строке запуска нет -Xmx; на лимитах меньше ~256 МБ вместо него действует MinRAMPercentage (50%).
  • Оставшиеся 25–30% лимита нужны вне кучи — metaspace, стеки, JIT, буферы ввода-вывода: при лимите 512 МБ и 75% это 128 МБ.
  • availableProcessors() с Java 10+ читает CPU-квоту контейнера, но дробную округляет вверх, а --cpu-shares не замечает совсем; размер пула Tomcat от него не зависит — там свои 200 потоков по умолчанию.
  • Актуальный базовый образ — eclipse-temurin:21-jre; UseContainerSupport включён по умолчанию.
  • В отчёте нативной памяти после кучи смотрят Thread (мегабайт на поток), Metaspace, Code и прямые буферы, у которых предел по умолчанию равен куче — отсюда -XX:MaxDirectMemorySize; арены glibc отчёт не видит, их ограничивает MALLOC_ARENA_MAX=2.
  • Заранее включают HeapDumpOnOutOfMemoryError с путём в том, ExitOnOutOfMemoryError и учёт нативной памяти: иначе первый инцидент нечем расследовать.
  • Размер кучи берут из измерения: живые данные после полной сборки умножить на два-три, затем проверить долю времени в сборке мусора, а не назначать круглое число всем сервисам одинаково.
  • Старт ускоряют архивом классов приложения (ArchiveClassesAtExit плюс SharedArchiveFile) — 20–40 процентов; TieredStopAtLevel=1 только для короткоживущих процессов, а для секундного старта нужны AOT и native image.

Что почитать дальше