Когда мы упаковываем Spring Boot-приложение в Docker-образ, важно не только обеспечить его запуск. Нам нужен такой образ, который поможет понять причины проблем: падений, тормозов и других сбоев.
В этой статье выясним, какие нештатные ситуации могут возникнуть, какие инструменты пригодятся для диагностики и как собрать образ, в котором всё это учтено.
Возможные нештатные ситуации
В работе любого приложения можно выделить два основных типа проблем, требующих нашего внимания:
Всё упало: Приложение аварийно завершило работу. Чаще всего это связано с критическими ошибками, такими как
OutOfMemoryError
(OOM).Всё тормозит: Приложение работает, но медленно отвечает на запросы, потребляет избыточное количество CPU или памяти, наблюдаются длительные паузы.
Рассмотрим, какие инструменты и подходы помогут нам в каждой из этих ситуаций.
Всё упало: диагностика сбоев
Если приложение аварийно завершилось, наша главная задача — собрать как можно больше информации о состоянии системы в момент сбоя. Разберем порядок действий в случае возникновения OOM.
Что с этим можно делать? Анализировать crash dump'ы (если ОС их создает) и heap dump'ы JVM.
Вот основные “ключики” JVM, которые стоит использовать для диагностики OOM:
-XX:+HeapDumpOnOutOfMemoryError
: Эта опция заставляет JVM создать heap dump в момент возникновения OOM. Без heap dump’а практически невозможно понять, какие объекты заняли всю память и вызвали утечку или чрезмерное потребление.-XX:HeapDumpPath=<путь_к_файлу>
: Указывает, куда сохранить heap dump. В Docker-контейнере этот путь должен указывать на директорию, смонтированную как том (volume) (/dumps/heapdump.hprof
, например). Иначе файл дампа будет потерян при перезапуске контейнера.-XX:+ExitOnOutOfMemoryError
: Заставляет JVM корректно завершить работу. Предотвращает зависание приложения в непредсказуемом состоянии после OOM.-XX:+CrashOnOutOfMemoryError
: Вызывает немедленное аварийное завершение (crash) JVM. Может быть полезно для генерации core dump на уровне ОС. Является более "жестким" способом относительно предыдущей опции.-XX:ErrorFile=<путь_к_файлу_ошибки>
: Указывает файл для записи детального отчета об ошибке JVM (fatal error log), например,/logs/java_error_%p.log
. Этот лог содержит ценную дополнительную информацию для анализа: состояние потоков (Thread Dump на момент сбоя), версии библиотек, параметры запуска, системную информацию. Это может помочь диагностировать не только OOM, но и другие типы сбоев JVM. Путь также желательно указывать на директорию, смонтированную на том, в случае с Docker.
Всё тормозит: диагностика производительности
Если приложение работает, но медленно, нам нужны инструменты для анализа его поведения "на лету" или по собранным данным. Вот чем мы можем воспользоваться:
GC Logging. Сборка мусора (Garbage Collection) — частый источник пауз приложения. Логирование GC позволяет понять частоту, длительность и эффективность сборок. Вывод GC-логов в файл (
-Xlog:gc*:file=/logs/gc.log...
) значительно удобнее, чем вывод в консоль (стандартный вывод Docker-контейнера). Существуют тулы (например, GCViewer), которые умеют парсить и визуализировать именно файлы логов, предоставляя наглядные графики и статистику. Работать с логом в виде файла проще, он не смешивается с логами приложения, и его легко забрать с тома для анализа.Actuator (Spring Boot Actuator) — модуль для мониторинга и управления Spring‑приложениями, позволяющий быстро получать базовую информацию о состоянии приложения (health, метрики CPU, память, запросы и т. д.). Actuator удобен для оперативного мониторинга и интеграции с системами мониторинга (Prometheus, Grafana). Однако по сравнению с JFR, Actuator предоставляет менее подробные данные и не подходит для глубокого анализа производительности на уровне JVM.
Java Flight Recorder (JFR) — мощный встроенный в JVM (начиная с OpenJDK 11) инструмент профилирования с очень низкими накладными расходами. Он собирает детальную информацию о работе JVM и приложения (CPU, память, блокировки, GC, I/O и т.д.) в бинарный файл (
.jfr
). Файл.jfr
сохраняется на том (/dumps
) и анализируется с помощью JDK Mission Control (JMC). JFR выгодно отличается от других средств мониторинга, таких как Actuator, тем, что предоставляет гораздо более глубокий уровень детализации и позволяет диагностировать даже тонкие проблемы производительности, практически не влияя на производительность самого приложения.JMX (Java Management Extensions) — Предоставляет стандартный интерфейс для мониторинга и управления Java-приложением в реальном времени. Позволяет подключаться инструментами вроде JConsole или VisualVM и смотреть метрики (heap, CPU, потоки) или даже выполнять действия (вызвать GC).
Способы достижения желаемого результата
Итак, мы определили какие флаги и инструменты нам нужны. Как теперь встроить их в процесс сборки и запуска нашего Docker-образа?
Рассмотрим наиболее распространенные варианты сборки Docker-образа.
Spring Boot Maven/Gradle Plugin
Одним из самых простых способов получить Docker-образ для Spring Boot-приложения без дополнительных инструментов является команда bootBuildImage, предоставялемая плагином Spring Boot для Gradle/Maven. В базовом сценарии сборка образа с помощью Gradle или Maven выглядит так:
Gradle:
./gradlew bootBuildImage --imageName=springio/gs-spring-boot-docker
Maven:
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=springio/gs-spring-boot-docker
Важным преимуществом подхода является встроенность в Spring Boot, что избавляет от необходимости вручную создавать Dockerfile и устанавливать дополнительные утилиты. bootBuildImage
осуществляет полный цикл сборки — от исходного кода до Docker-образа, используя Cloud Native Buildpacks. Это удобно и сокращает время запуска проекта, однако может привести к определенным сложностям в долгосрочной перспективе.
Автоматизированный подход сборки образов имеет как преимущества, так и недостатки. С одной стороны, bootBuildImage
обещает переиспользование слоёв Docker-образа и работу с кешированием, благодаря чему повторные сборки могут проходить быстрее. С другой стороны, практика показывает, что время сборки часто оказывается непредсказуемым и может варьироваться значительно, особенно при изменении зависимостей или конфигурации. Кроме того, внутренний механизм Cloud Native Buildpacks представляет собой «сборщик над сборщиком», добавляя уровень абстракции, что усложняет процесс отладки и расширения. В сети несложно найти подробные видео-гайды продолжительностью более получаса, посвящённые всего лишь добавлению кастомных настроек, таких как JVM-флаги или интеграция с системами мониторинга.
Гибкость bootBuildImage
также ограничена набором доступных конфигурационных свойств. Среди наиболее часто используемых параметров:
BP_JVM_VERSION
: позволяет задать версию JVM для образа.Переменные окружения для JVM (например,
JAVA_TOOL_OPTIONS
).
Однако, если требуется более детальная настройка JVM, например, включение JMX, JFR, использование специальных флагов (-XX:HeapDumpPath
, -Xlog
и других специфичных параметров), возможности конфигурации оказываются весьма ограниченными.
В таких ситуациях пользователи часто вынуждены полностью отказаться от использования bootBuildImage
и переходят на традиционный подход с собственным Dockerfile.
Jib (Maven/Gradle Plugin)
Ещё одним достаточно популярным способом сборки Docker-образов для Spring Boot приложений является использование утилиты Jib от Google. Это плагин, доступный как для Maven, так и для Gradle, который позволяет создавать образы без установки Docker на машине разработчика или CI-сервере.
Добавить поддержку Jib в Gradle-проект достаточно просто:
plugins {
id "com.google.cloud.tools.jib" version "3.1.4"
// другие плагины
}
jib {
from {
image = 'azul/zulu-openjdk:17-jre'
}
to {
image = 'spring-boot-api-example-jib'
}
}
Jib, как и bootBuildImage
, осуществляет полный цикл сборки от исходного кода до Docker-образа, и не требует от нас создания Dockerfile. Важное преимущество Jib заключается в высокой скорости повторной сборки за счет переиспользования слоёв и эффективного кэширования.
Однако аналогично bootBuildImage
, Jib добавляет дополнительный уровень абстракции, который усложняет отладку и кастомизацию. В сети можно найти множество примеров и руководств, демонстрирующих сложности с добавлением специфических настроек JVM, расширением функциональности плагина и тонкой конфигурацией образов. Таким образом, более детальная настройка JVM, нередко сводиться к написанию не менее сложных конструкций, чем корректно написанный Dockerfile.
Dockerfile
Последним (но не по значимости!) вариантом, который мы рассмотрим, является использование чистого Dockerfile. Этот подход требует немного больше внимания и усилий: необходимо грамотно написать сам файл и в дальнейшем поддерживать его, соблюдая лучшие практики, такие как multi-stage сборка, запуск приложения от непривилегированного пользователя, минимизация числа слоев и другие.
Зато взамен мы получаем полный контроль над образом: вы сами выбираете базовый образ, устанавливаете необходимое ПО, настраиваете переменные окружения (например, JAVA_OPTS
), указываете точку входа (ENTRYPOINT
, CMD
), создаёте нужную структуру каталогов, такие как /logs
или /dumps
, и сразу настраиваете для них права доступа. Такой подход позволяет точно адаптировать окружение под нужды приложения, особенно если требуются специфические параметры запуска или настройки диагностики.
Современные инструменты разработки заметно упростили создание Dockerfile — например, в OpenIDE можно сгенерировать шаблон Dockerfile для Spring Boot приложения буквально в пару кликов.
Пример такого файла:
FROM bellsoft/liberica-openjdk-alpine:23-cds AS builder
WORKDIR /application
COPY . .
RUN --mount=type=cache,target=/root/.m2 chmod +x mvnw && ./mvnw clean install -Dmaven.test.skip
FROM bellsoft/liberica-openjre-alpine:23-cds AS layers
WORKDIR /application
COPY --from=builder /application/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted
FROM bellsoft/liberica-openjre-alpine:23-cds
VOLUME /tmp
RUN adduser -S spring-user
USER spring-user
WORKDIR /application
COPY --from=layers /application/extracted/dependencies/ ./
COPY --from=layers /application/extracted/spring-boot-loader/ ./
COPY --from=layers /application/extracted/snapshot-dependencies/ ./
COPY --from=layers /application/extracted/application/ ./
RUN java -XX:ArchiveClassesAtExit=app.jsa -Dspring.context.exit=onRefresh -jar app.jar & exit 0
ENV JAVA_RESERVED_CODE_CACHE_SIZE="240M"
ENV JAVA_MAX_DIRECT_MEMORY_SIZE="10M"
ENV JAVA_MAX_METASPACE_SIZE="179M"
ENV JAVA_XSS="1M"
ENV JAVA_XMX="345M"
ENV JAVA_CDS_OPTS="-XX:SharedArchiveFile=app.jsa -Xlog:class+load:file=/tmp/classload.log"
ENV JAVA_ERROR_FILE_OPTS="-XX:ErrorFile=/tmp/java_error.log"
ENV JAVA_HEAP_DUMP_OPTS="-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp"
ENV JAVA_ON_OUT_OF_MEMORY_OPTS="-XX:+CrashOnOutOfMemoryError"
ENV JAVA_NATIVE_MEMORY_TRACKING_OPTS="-XX:NativeMemoryTracking=summary -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics"
ENV JAVA_GC_LOG_OPTS="-Xlog:gc*,safepoint:/tmp/gc.log::filecount=10,filesize=100M"
ENV JAVA_FLIGHT_RECORDING_OPTS="-XX:StartFlightRecording=disk=true,dumponexit=true,filename=/tmp/,maxsize=10g,maxage=24h"
ENV JAVA_JMX_REMOTE_OPTS="-Djava.rmi.server.hostname=127.0.0.1 \
-Dcom.sun.management.jmxremote.authenticate=false \
-Dcom.sun.management.jmxremote.ssl=false \
-Dcom.sun.management.jmxremote.port=5005 \
-Dcom.sun.management.jmxremote.rmi.port=5005"
ENTRYPOINT java \
-XX:ReservedCodeCacheSize=$JAVA_RESERVED_CODE_CACHE_SIZE \
-XX:MaxDirectMemorySize=$JAVA_MAX_DIRECT_MEMORY_SIZE \
-XX:MaxMetaspaceSize=$JAVA_MAX_METASPACE_SIZE \
-Xss$JAVA_XSS \
-Xmx$JAVA_XMX \
$JAVA_HEAP_DUMP_OPTS \
$JAVA_ON_OUT_OF_MEMORY_OPTS \
$JAVA_ERROR_FILE_OPTS \
$JAVA_NATIVE_MEMORY_TRACKING_OPTS \
$JAVA_GC_LOG_OPTS \
$JAVA_FLIGHT_RECORDING_OPTS \
$JAVA_JMX_REMOTE_OPTS \
$JAVA_CDS_OPTS \
-jar app.jar

Кроме базовых настроек, таких как имя итогового образа и базовый base-image, в Dockerfile довольно просто можно задать и все ключевые параметры, о которых мы говорили ранее в главе «Возможные нештатные ситуации».
А что дальше?
На самом деле, Dockerfile — это лишь один из кирпичиков, на которых строится продакшен-деплой вашего приложения. За ним следуют десятки вопросов: где и как запускать контейнеры? Использовать ли Docker Compose или сразу идти в сторону Kubernetes?
Опытные разработчики сегодня всё чаще отвечают: без Kubernetes — никуда. Он стал своего рода "базой", на которой строится современная облачная инфраструктура. Но даже с этим знанием вопросов меньше не становится. Как правильно развернуть Spring Boot приложение в Kubernetes? Как организовать сборку Docker-образов? Как конфигурировать сервисы, управлять секретами, настраивать мониторинг?
На эти и многие другие вопросы ответили Рустам Курамшин, Илья Кучмин и Максим Гусев на бесплатном онлайн-митапе "Kubernetes – это база... Или как 3 разработчика Spring-приложение деплоили".
Заключение
Выбор способа сборки Docker-образа — bootBuildImage, Jib или чистый Dockerfile — зависит от ваших потребностей в гибкости и кастомизации. Однако, не так важно, как именно вы соберете своё приложение. Главное, чтобы в результате у вас был образ, который предоставляет необходимые данные и инструменты для анализа, когда всё упадет или начнет тормозить. Заблаговременная настройка опций для heap dump'ов, GC-логов, JFR или JMX сэкономит вам массу времени и нервов при решении проблем в будущем.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.