Запустить Spring Boot в контейнере просто. Но по умолчанию JVM «смотрит» не на лимиты контейнера, а на параметры всего хоста — и это приводит к неожиданным падениям с кодом 137 или неоправданно раздутым пулам потоков. Разберём, почему так происходит и как это исправить.
Начнём с общей картины: вот как складывается память JVM при лимите контейнера 512 МБ — и что меняется, когда лимит поднимают.
Куча считается от того, что 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 поток, сборщик мусора запускался в несколько потоков, а работать им всем приходилось по очереди на одном ядре.
Одно и то же число ядер в трёх строках: сравните, что видит 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.
Что почитать дальше
- Упаковка Spring Boot в Docker-образ — первый шаг: как собрать и запустить приложение в контейнере.
- Многоэтапная сборка и слои образа — как уменьшить размер образа и ускорить пересборку.
- Лучшие практики Docker-образов — безопасность, минимальный размер, правильный порядок слоёв.