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

Запустить 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-образов — безопасность, минимальный размер, правильный порядок слоёв.