Запустить Spring Boot в контейнере просто. Но по умолчанию JVM «смотрит» не на лимиты контейнера, а на параметры всего хоста — и это приводит к неожиданным падениям с кодом 137 или неоправданно раздутым пулам потоков. Разберём, почему так происходит и как это исправить.
Историческая проблема: JVM видела хост, а не контейнер
До Java 10 JVM определяла доступную память и число процессоров, читая параметры операционной системы напрямую — без учёта cgroups (механизм ядра Linux, которым Docker ограничивает ресурсы контейнера).
Представьте: у хоста 32 ГБ оперативной памяти, а контейнер запущен с --memory=512m. JVM «видела» 32 ГБ и выделяла кучу (heap) в несколько гигабайт. Контейнер превышал лимит, и Linux убивал процесс сигналом SIGKILL.
Начиная с 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.
Как управлять памятью: MaxRAMPercentage вместо -Xmx
Раньше принято было явно задавать размер кучи через -Xmx512m. В контейнерах это неудобно: при изменении лимита контейнера нужно менять и флаг приложения.
Современный подход — -XX:MaxRAMPercentage. Флаг задаёт процент от доступной (ограниченной cgroup) памяти, которую JVM отдаст под кучу.
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% — куча + ~200–300 МБ запас для остального
Для приложения с ожидаемой кучей 512 МБ и MaxRAMPercentage=75 минимальный лимит контейнера: 512 / 0.75 ≈ 700 МБ — берите 768 МБ или 1 ГБ с запасом.
Ядра и пулы потоков: availableProcessors
JVM определяет число доступных процессоров через Runtime.getRuntime().availableProcessors(). Большинство пулов потоков — в том числе пулы Tomcat (встроенный сервер Spring Boot) и ForkJoinPool.commonPool() — настраивают свой размер именно по этому значению.
До Java 10 метод возвращал число логических процессоров хоста, а не контейнера. Если хост имеет 32 ядра, а контейнеру выделено 1 ядро (--cpus=1), Tomcat мог создать 200 потоков, которые конкурируют за одно ядро — это деградация производительности.
Начиная с Java 10 availableProcessors() читает cgroup-квоту CPU и возвращает корректное значение. Больше ничего настраивать не нужно — достаточно Java 10+ и задать --cpus или CPU-квоту в Kubernetes.
# Контейнер видит 2 ядра; JVM сообщит availableProcessors() = 2
docker run --cpus=2 myapp
Если нужно переопределить вручную (редкий случай):
java -XX:ActiveProcessorCount=2 -jar app.jar
Полная конфигурация: минимальный рабочий пример
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 11, но его полезно указывать явно в качестве документации — сразу понятно, что образ рассчитан на запуск в контейнере.
Коротко
- До Java 10 JVM читала параметры хоста, игнорируя cgroup-лимиты контейнера — это приводило к
OOMKilled. OOMKilled(exit 137) — процесс убит ОС за превышение лимита памяти контейнера;OutOfMemoryError— Java-исключение внутри JVM, разные причины и лечение.- Используйте
-XX:MaxRAMPercentage=75.0вместо-Xmx— JVM сама считает нужный объём от лимита контейнера. - Оставляйте 200–300 МБ сверх кучи на metaspace, стеки, JIT и буферы ввода-вывода.
availableProcessors()с Java 10+ читает CPU-квоту контейнера — пулы потоков (Tomcat,ForkJoinPool) настраиваются корректно автоматически.- Актуальный базовый образ —
eclipse-temurin:21-jre;UseContainerSupportвключён по умолчанию.
Что почитать дальше
- Упаковка Spring Boot в Docker-образ — первый шаг: как собрать и запустить приложение в контейнере.
- Многоэтапная сборка и слои образа — как уменьшить размер образа и ускорить пересборку.
- Лучшие практики Docker-образов — безопасность, минимальный размер, правильный порядок слоёв.