RE:NODE

Деплой Spring Boot приложения в продакшен на Java-сервере

Автор Команда RE:NODE
21 мин чтения


Приложение на Spring Boot разворачивается как один файл: fat jar, в котором лежат ваш код, его зависимости и встроенный веб-сервер. Серверу нужны среда выполнения Java подходящей версии, сам jar, порт, который он будет слушать, и куча, помещающаяся в лимит памяти, за который вы платите. Именно последнее становится причиной почти каждого неудачного деплоя Spring Boot: JVM охотно запросит больше памяти, чем есть на небольшом тарифе, а контейнер, остановленный за превышение лимита, снаружи выглядит как необъяснимый сбой.

В этой статье разобран весь путь для Spring Boot с пометками о том, чем отличаются Quarkus и Micronaut: сборка jar через Maven или Gradle, выбор версии Java, привязка к нужному порту, подбор кучи под тариф, вынос конфигурации из jar, подключение базы PostgreSQL или MySQL через HikariCP, health-эндпоинт, работа за прокси с сохранением адресов клиентов и ошибки, которые вы увидите, если что-то настроено неверно.

Что нужно серверу, чтобы запустить fat jar#

Fat jar (его также называют uber jar или executable jar) - это обычный jar, внутри которого лежат вложенные jar-файлы зависимостей и загрузчик, умеющий их читать. Его создают плагины Spring Boot для Maven и Gradle, а вся команда запуска - java -jar app.jar. Не нужно устанавливать сервер приложений, класть WAR в Tomcat или собирать classpath вручную.

Для хостинга из этого следует немногое:

  • JRE, а не JDK. Компилируете вы на своей машине или в CI. Сервер лишь запускает результат, поэтому ему достаточно среды выполнения.
  • Правильная основная версия. Spring Boot 3 требует Java 17 или новее. Spring Boot 2.7 и старше работают на Java с 8 по 17, но уже не поддерживаются в open source. Линейка Java-приложений RE:NODE запускает любой jar на Java 17, 21 или 25, поэтому приложение на Boot 3 подойдёт для всех трёх, а приложения на Boot 2.x разумно запускать только на 17.
  • Один слушающий порт. Один встроенный сервер - один порт. Actuator можно вынести на второй порт, но на тарифе с одним выделенным портом вы оставите его на основном и защитите иначе.
  • Память не только под кучу. Размер процесса JVM складывается из кучи, metaspace, стеков потоков, code cache, direct-буферов и структур сборщика мусора. Куча в 512 МБ обычно означает процесс на 700-800 МБ. Большая часть раздела о подборе размера ниже нужна, чтобы эта фраза стала конкретной.

Выбирайте версию Java по тому, под что вы компилируете, а не по тому, что новее. Компилируйте с --release 17 или соответствующей настройкой Maven и Gradle, и jar, собранный под 17, без изменений запустится на 21 и 25. В обратную сторону это не работает и заканчивается ошибкой UnsupportedClassVersionError, о ней - в разделе об устранении неполадок. Если ваши зависимости свежие, разумный выбор по умолчанию - Java 21: доступны виртуальные потоки, сборщики мусора лучше настроены под контейнеры, и у неё долгий срок поддержки. Java 25 - более новая линия, и её стоит проверить, если вы хотите работать на последнем релизе с долгосрочной поддержкой.

Сборка jar через Maven или Gradle#

Собирайте в CI или на своей машине, запускайте тесты там же и доставляйте на сервер готовый артефакт. Сборку не стоит выполнять на небольшом продакшен-тарифе - по той же причине, по которой не собирают Rust: Maven или Gradle с прогретым кэшем зависимостей работают нормально, а холодная сборка в контейнере на 1 ГБ идёт медленно и может упереться в лимит памяти.

В Maven jar делает исполняемым родительский POM Spring Boot или spring-boot-maven-plugin:

bash
$ ./mvnw -B clean package -DskipTests=false$ ls target/*.jartarget/shop-api-1.4.0.jar  target/shop-api-1.4.0.jar.original

Файл .original - это тонкий jar до репакетирования. Разворачивайте второй. Для Gradle:

bash
$ ./gradlew clean bootJar$ ls build/libs/shop-api-1.4.0.jar

Исполняемый архив создаёт задача bootJar; обычная jar создаёт тонкий архив, который не запустится. Сборки Gradle с подключённым плагином org.springframework.boot также создают -plain.jar, который вам тоже не нужен.

Три мелочи избавят от проблем при последующих деплоях:

  1. Дайте jar постоянное имя. В Maven укажите <finalName>app</finalName> внутри <build>; в Gradle - bootJar { archiveFileName = 'app.jar' }. Команду запуска, указывающую на app.jar, не придётся править при каждом релизе.
  2. Информация о сборке для health-эндпоинта. У spring-boot-maven-plugin есть цель build-info, а у плагина Gradle - springBoot { buildInfo() }. С любым из них /actuator/info в Actuator покажет версию, которая запущена на самом деле, а это ответ на первый вопрос в любом инциденте.
  3. Проверьте манифест. unzip -p app.jar META-INF/MANIFEST.MF должен показать Main-Class вида org.springframework.boot.loader... и Start-Class вашего приложения. Если это не так, шаг репакетирования не выполнился.

Quarkus и Micronaut отличаются артефактом. Стандартная раскладка Quarkus quarkus-app - это каталог, а не один файл: рядом с quarkus-run.jar лежит папка lib. Если нужен один файл, задайте quarkus.package.jar.type=uber-jar, соберите через ./mvnw package и запускайте java -jar target/*-runner.jar. Задача Gradle shadowJar в Micronaut и конфигурация Maven Shade создают один запускаемый jar. Всё, что ниже сказано про кучу, порты и базы данных, относится к обоим фреймворкам, только имена свойств другие.

Загрузка и запуск#

От того, как jar попадает на сервер, зависит, сколько из остального можно автоматизировать. Маршрутов три: загрузить его вручную через файловый менеджер или SFTP; забрать репозиторий с GitHub и собрать на сервере; или собрать в CI и загрузить только артефакт.

На RE:NODE у каждого сервера приложений есть файловый менеджер, который распаковывает архивы на месте, и SFTP с отдельными учётными данными для каждого сервера. Оба варианта подходят для jar. Панель также поддерживает деплой с GitHub через GitHub App, в том числе для приватных репозиториев, и у неё два переключателя: забирать код при каждом запуске и деплоить при push, что перезапускает работающий сервер и запускает остановленный. Это путь «исходники - сервер», поэтому он помогает, только если самому серверу разрешено собирать; для jar, собранного в CI, проще загрузить артефакт, и сборка не расходует память продакшена. Если вы выбираете маршрут через git, закоммитьте готовый jar или используйте ветку, в которой лежат только релизные артефакты, и не рассчитывайте, что тариф будет запускать Maven.

Когда Java-сервер создан, на его адресе до деплоя вашего кода показывается небольшая рабочая страница, и она никогда не перезаписывает ваши файлы. Как только ваш jar на месте, а параметры запуска указывают на него, страницу заменяет ваше приложение. Переменные окружения задаются на вкладке Startup, а консоль показывает вывод в реальном времени и содержит кнопки запуска, остановки, перезапуска и принудительного завершения.

uploadjava -jarJDBCHTTPSHTTPВаша сборкаmvn packageКлиентыapp.jarfat jarСлот проксиTLS, X-Forwarded-ForJVM-Xmx, MaxRAMPercentageБаза данныхпул HikariCP
От сборки до работающего Spring Boot

Порты: server.port и SERVER_PORT#

Порт - первое, что ломается. Spring Boot по умолчанию использует 8080. На хостинге вам выдан конкретный порт, и приложение должно слушать именно его, а не тот, что вы использовали локально.

Spring Boot читает одно и то же свойство из нескольких мест, и порядок важен. Аргументы командной строки приоритетнее переменных окружения ОС, а те приоритетнее application.properties внутри jar. Переменная окружения SERVER_PORT - это форма server.port для relaxed binding, поэтому, чтобы задать порт через окружение, не нужно менять код:

environment
SERVER_PORT=25565SPRING_PROFILES_ACTIVE=prod

Число выше - заглушка; используйте порт, указанный для вашего сервера. В панелях на основе Pterodactyl выделенный порт обычно доступен как SERVER_PORT, поэтому, прежде чем что-то предполагать, посмотрите на вкладках Startup и Network, что именно предоставляет ваш сервер. Если переменная уже существует и содержит выделенный порт, ничего задавать не нужно: Spring подхватит её как server.port в момент запуска.

Если вы предпочитаете держать порт в файле, хороша ссылка со значением по умолчанию, потому что она работает и локально, и в продакшене:

application.properties
server.port=${SERVER_PORT:8080}

Вторая половина - адрес. Встроенный сервер Spring по умолчанию слушает на всех интерфейсах, поэтому server.address указывать не нужно. Не задавайте для продакшена 127.0.0.1 - тогда приложение будет доступно только изнутри собственного контейнера, а прокси ответит ошибкой bad gateway. Quarkus в некоторых режимах по умолчанию привязывается к localhost, и ему нужны quarkus.http.host=0.0.0.0 и quarkus.http.port=${SERVER_PORT:8080}. Micronaut читает micronaut.server.port и учитывает переменную окружения MICRONAUT_SERVER_PORT.

Подбор размера JVM под лимит памяти тарифа#

Это самый важный раздел. У контейнера жёсткий лимит памяти, а JVM, если ей позволить, сама решит, какой сделать кучу. Современные JVM узнают лимит контейнера через cgroups и по умолчанию ограничивают максимальную кучу четвертью от него. Это безопасное значение по умолчанию, но расточительное: на тарифе с 2 ГБ вы получите кучу 512 МБ, а 1,5 ГБ останутся без дела.

Что произойдёт на пределе, зависит от платформы. На RE:NODE, когда процесс достигает лимита памяти, ядро останавливает контейнер, и он чисто перезапускается вместо подкачки. Оригинальный Pterodactyl поступает наоборот и разрешает машине свопиться. Для продакшен-сервиса чистая остановка лучше: JVM в подкачке даёт паузы сборки мусора по несколько секунд и затем медленно умирает. Это также значит, что если вы задали слишком большую кучу, симптомом будет перезапуск без единой записи в логе приложения: процесс был убит снаружи, и JVM не успела записать OutOfMemoryError.

Нужно отличать два разных сбоя:

  • `java.lang.OutOfMemoryError: Java heap space`. Куча слишком мала для ваших данных. JVM записывает ошибку в лог, и кучу нужно увеличить, либо в приложении утечка.
  • Перезапуск без ошибки Java. Весь процесс превысил лимит контейнера. Куча слишком велика для тарифа, либо выросла память вне кучи. Кучу нужно уменьшить.

Три способа задать кучу, в порядке предпочтения:

bash
# 1. Процент от лимита контейнера (рекомендуется)$ java -XX:MaxRAMPercentage=65.0 -XX:InitialRAMPercentage=40.0 -jar app.jar# 2. Абсолютный предел$ java -Xms256m -Xmx768m -jar app.jar# 3. Только окружение, без изменения командыJAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=65.0 -XX:+ExitOnOutOfMemoryError"

MaxRAMPercentage масштабируется при смене тарифа, и это главная причина предпочесть его: перейдите с 2 ГБ на 4 ГБ, и куча последует за лимитом без правок. -Xmx точен и подходит, когда вы измерили нагрузку и хотите зафиксированное значение. Если заданы оба, побеждает -Xmx. JAVA_TOOL_OPTIONS читает каждая запускающаяся JVM, что удобно, когда команду запуска править нельзя; JVM печатает Picked up JAVA_TOOL_OPTIONS: ... в начале консоли, так что можно убедиться, что настройка применилась. Учтите, что она действует и на любой другой процесс Java, запущенный в том же окружении.

Какой процент выбрать, зависит от тарифа, потому что на небольшом тарифе фиксированные накладные расходы занимают большую долю:

Память тарифаНастройка кучиПричина
1 GBот -Xmx384m до -Xmx512m500 МБ и больше уходит на память вне кучи
2 GBMaxRAMPercentage=60Около 1,2 ГБ на кучу, 800 МБ на остальное
4 GBMaxRAMPercentage=65Доля накладных расходов вне кучи меньше
6 GB и большеMaxRAMPercentage=70Оставьте запас под direct-буферы и всплески

Это отправные точки, а не измерения вашего приложения. Сервису с большими кэшами или активным NIO понадобится больше запаса. Маленькому API с небольшим деревом зависимостей хватит и меньшего. Понаблюдайте за графиком памяти в консоли относительно лимита в течение суток реального трафика и скорректируйте значения.

Стоит знать ещё несколько флагов:

ФлагДействие
-XX:+ExitOnOutOfMemoryErrorЗавершать процесс при OOM кучи, чтобы платформа перезапустила его чисто
-XX:MaxMetaspaceSize=256mОграничить метаданные классов, которые могут утекать при повторных деплоях
-Xss512kМеньшие стеки потоков, если у вас сотни потоков
-XX:+UseSerialGCПри 1 CPU или менее 1,5 ГБ - сборщик с наименьшими накладными расходами

ExitOnOutOfMemoryError большинство пропускает, а зря. JVM, в одном из потоков которой случился OOM кучи, часто остаётся живой, но испорченной: часть запросов обслуживает, часть проваливает. Завершение процесса позволяет описанному выше поведению перезапуска дать вам чистый процесс. Имейте в виду, что за перезапусками следят: обнаружение сбоев опрашивает сервер каждые две минуты в поисках аптайма, который пошёл назад, или сервера, ушедшего в офлайн; перезапуски, которые вы запросили сами, не учитываются; три перезапуска за час вызывают предупреждение и автоматический тикет, а шесть - блокировку. Сервис, который перезапускается в цикле из-за слишком большой кучи, - именно то, что эта проверка ищет, поэтому исправьте размер, прежде чем оставлять его работать.

Сборщик мусора важен на небольших тарифах. Java выбирает G1 на машинах с двумя и более CPU и не менее чем 1792 МБ, а в остальных случаях переключается на serial-сборщик. G1 - хороший выбор по умолчанию начиная с 2 ГБ. Не настраивайте его, пока этого не потребует график. Статья CPU или RAM для игровых серверов делает общее замечание: прежде чем докупать один ресурс, измерьте, какой из них является узким местом, - и к JVM оно применимо без изменений.

CPU жёстко ограничен купленной долей. JVM сама определяет размеры своих пулов потоков по числу CPU, которое видит, а оно выводится из квоты контейнера и округляется вверх. Сервис на половине ядра может считать, что у него целое, и тогда потоки компилятора и сборщика мусора конкурируют с потоками запросов. Если запуск медленный или задержки растут всплесками, попробуйте на самых маленьких тарифах -XX:ActiveProcessorCount=1, который фиксирует представление JVM о машине.

Конфигурация: application.properties и переменные окружения#

Jar везде один и тот же. Между вашим ноутбуком и продакшеном меняется конфигурация, и ничто из неё не должно лежать внутри jar. Внешняя конфигурация Spring Boot даёт чёткий порядок, от низшего приоритета к высшему:

  1. application.properties или application.yml, упакованные внутри jar.
  2. Те же файлы рядом с jar, в рабочем каталоге или в папке config/ рядом с ним.
  3. Файлы профилей, например application-prod.properties.
  4. Переменные окружения ОС.
  5. Системные свойства Java (-Dkey=value) и аргументы командной строки (--key=value).

Вывод удобен: храните значения по умолчанию внутри jar, а в продакшене переопределяйте через окружение только то, что отличается. Правило привязки переменных окружения механическое - верхний регистр, а точки и дефисы становятся подчёркиваниями:

СвойствоПеременная окружения
server.portSERVER_PORT
spring.datasource.urlSPRING_DATASOURCE_URL
spring.datasource.hikari.maximum-pool-sizeSPRING_DATASOURCE_HIKARI_MAXIMUMPOOLSIZE
management.endpoint.health.show-detailsMANAGEMENT_ENDPOINT_HEALTH_SHOWDETAILS

Обратите внимание на строку с размером пула: дефис в maximum-pool-size не превращается в подчёркивание, а просто пропадает, и имя склеивается. Подчёркивания внутри имени ключа - обычная причина того, что переменная окружения якобы ничего не делает. Если есть сомнения, обратитесь к /actuator/env в тестовом окружении или один раз запустите с --debug и прочитайте отчёт об условиях в консоли.

Не храните секреты в jar и в репозитории. Пароли баз данных, ключи API и секреты для подписи должны лежать в переменных окружения, которые на RE:NODE задаются на вкладке Startup. В статье Переменные окружения и секреты разобрано, что где хранить и что не стоит писать в логи. Альтернатива - файл конфигурации рядом с jar для значений, которые вы хотите править, не думая о цикле перезапуска; редактор файлового менеджера с подсветкой синтаксиса делает это удобным. Spring не перечитывает application.properties во время работы, поэтому после любого изменения нужен перезапуск.

Всё связывает переключатель профиля. Оставьте в application.properties общие значения по умолчанию, а в application-prod.properties - то, что меняется в продакшене, и активируйте профиль через SPRING_PROFILES_ACTIVE=prod:

application-prod.properties
server.port=${SERVER_PORT:8080}server.forward-headers-strategy=nativeserver.shutdown=gracefulspring.lifecycle.timeout-per-shutdown-phase=20sspring.jpa.open-in-view=falsespring.jpa.hibernate.ddl-auto=validatespring.main.lazy-initialization=falselogging.level.root=INFO

spring.jpa.open-in-view=false не даёт Spring удерживать соединение с базой данных на протяжении всего веб-запроса. По умолчанию оно включено и при запуске пишет предупреждение, потому что часто приводит к исчерпанию пула. ddl-auto=validate заставляет приложение проверять схему, а не менять её; схемы продакшена принадлежат инструменту миграций вроде Flyway или Liquibase, а не догадкам Hibernate.

Подключение базы данных PostgreSQL или MySQL#

Spring Boot автоматически настраивает DataSource по трём свойствам и драйверу в classpath. Драйвер - это зависимость: org.postgresql:postgresql для PostgreSQL, com.mysql:mysql-connector-j для MySQL. В URL указаны хост, порт и имя базы:

environment
SPRING_DATASOURCE_URL=jdbc:postgresql://db-host:5432/shop?sslmode=preferSPRING_DATASOURCE_USERNAME=shop_appSPRING_DATASOURCE_PASSWORD=change-me

Для MySQL URL начинается с jdbc:mysql://host:3306/dbname, а класс драйвера определяется автоматически по URL. Хост, пользователь и пароль берутся оттуда, где находится база. У каждого сервера приложений есть слоты баз данных, которые создаются в панели со сгенерированными хостом, пользователем и паролем. Для отдельной базы есть хостинг баз данных с PostgreSQL и MySQL 8.4 LTS: учётные данные генерируются для каждого сервера, а подключаться нужно к хосту и порту вашего тарифа. Скопируйте значения в три переменные выше, а не в jar. В руководстве по удалённому подключению к PostgreSQL и таком же руководстве по MySQL строки подключения разобраны подробнее.

Размер пула HikariCP

Spring Boot по умолчанию использует HikariCP. Он держит пул открытых соединений и выдаёт их запросам. Значение maximum-pool-size по умолчанию - 10, и minimum-idle по умолчанию равно ему же, поэтому приложение Boot открывает десять соединений при запуске и удерживает их. Для небольшой базы это много.

application-prod.properties
spring.datasource.hikari.maximum-pool-size=6spring.datasource.hikari.minimum-idle=2spring.datasource.hikari.connection-timeout=10000spring.datasource.hikari.idle-timeout=300000spring.datasource.hikari.max-lifetime=1500000spring.datasource.hikari.keepalive-time=120000

Значения, которые стоит понимать:

  • `maximum-pool-size` - жёсткий предел числа соединений от этого процесса. Больше не значит быстрее. Пул из 6 соединений для сервиса на одном-двух ядрах обрабатывает гораздо больше трафика, чем обычно ожидают: запрос в 5 мс позволяет одному соединению обслуживать 200 запросов в секунду. Если запросы стоят в очереди за соединениями, ищите медленные запросы, прежде чем увеличивать число.
  • `connection-timeout` - сколько запрос ждёт свободное соединение, прежде чем завершиться ошибкой, в миллисекундах. Значение по умолчанию в 30 секунд слишком велико для веб-запроса; 10 секунд и меньше дают быструю и заметную ошибку.
  • `max-lifetime` должен быть короче любого ограничения времени соединения на стороне базы или сетевого устройства между вами и ней. Hikari закрывает соединение раньше, чем сервер успеет закрыть его у вас из-под рук. По умолчанию это 30 минут, и правильное значение - на несколько минут меньше реального предела.
  • `keepalive-time` пингует простаивающие соединения, чтобы stateful-файрволы не обрывали их молча.

Считайте соединения по всем, кто подключается. Если вы запускаете два экземпляра приложения или задачу миграции рядом с ним, суммы складываются и упираются в собственный лимит соединений базы данных. Статья Пулы соединений и лимиты содержит эту арифметику, а материал о лимитах соединений MySQL разбирает серверную сторону.

Добавьте Flyway или Liquibase и положите миграции в src/main/resources/db/migration. Тогда приложение само приводит свою схему в актуальное состояние при запуске, до того как начнёт принимать трафик. Запуск становится чуть дольше, а деплои - воспроизводимыми, и это главная причина выставить ddl-auto=validate.

Health-проверки с Actuator#

Добавьте spring-boot-starter-actuator и открывайте только то, что нужно. По умолчанию по HTTP доступен только health, и этот инстинкт стоит сохранить:

application-prod.properties
management.endpoints.web.exposure.include=health,infomanagement.endpoint.health.probes.enabled=truemanagement.endpoint.health.show-details=nevermanagement.health.db.enabled=true

При включённых probes Spring помимо /actuator/health открывает /actuator/health/liveness и /actuator/health/readiness. Liveness отвечает на вопрос «находится ли процесс в состоянии, в котором он может продолжать работу» и никогда не должен зависеть от базы данных. Readiness отвечает на вопрос «стоит ли отправлять на этот экземпляр трафик», и недоступная база данных относится именно сюда. Общий /actuator/health объединяет зарегистрированные индикаторы, включая источник данных, место на диске и всё, что вы добавите, и возвращает {"status":"UP"} с HTTP 200, а при неполадке - 503.

Проверьте из консоли или со своей машины:

bash
$ curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/actuator/health200

Две предосторожности. Никогда не открывайте env, heapdump, beans и mappings на публичном адресе: они раскрывают конфигурацию, а heapdump ещё и содержимое памяти, включая секреты. Если они нужны, вынесите их на отдельный порт управления через management.server.port и не пробрасывайте его, либо защитите с помощью Spring Security. И держите ответ health дешёвым: индикатор, обращающийся к трём сторонним сервисам, превращает каждый пинг мониторинга в нагрузку. Статья Корректное завершение и health-проверки объясняет, что хорошая проверка делает, а чего не делает, а server.shutdown=graceful выше - это часть той же истории, касающаяся остановки: запросы в работе успевают завершиться, когда вы нажимаете перезапуск.

За прокси: TLS и X-Forwarded-For#

Не завершайте TLS внутри приложения. В тарифы для приложений входит слот обратного прокси: направьте A-запись на показанный адрес, и сертификат будет выпущен и продлеваться автоматически, в окне в 21 день. Ваше приложение отдаёт обычный HTTP на выделенном порту, а прокси передаёт настоящий адрес клиента в X-Forwarded-For. Статья Что делает обратный прокси объясняет всю цепочку.

Spring Boot не доверяет этим заголовкам, пока вы не скажете ему об этом. Без этого request.getRemoteAddr() возвращает прокси, все редиректы строятся с http://, а request.isSecure() ложно. Исправляется это одним свойством:

application-prod.properties
server.forward-headers-strategy=native

Полезных значений два: native и framework. native позволяет встроенному серверу (Tomcat, Jetty или Undertow) самому обрабатывать X-Forwarded-*, и это правильный ответ для обычного сервлетного приложения. framework использует собственный ForwardedHeaderFilter из Spring и подходит для WebFlux или когда вы хотите, чтобы работу выполнял Spring. Не задавайте оба. При любом из них getRemoteAddr() возвращает клиента, редиректы и заголовки Location используют https, а абсолютные URL в ссылках и OAuth-колбэках формируются правильно.

Tomcat принимает заголовок только от доверенного прокси. Для этого служит свойство server.tomcat.remoteip.internal-proxies, значение по умолчанию которого - регулярное выражение, охватывающее частные и loopback-диапазоны. Если прокси обращается к вашему контейнеру с адреса за пределами этого диапазона, заголовок игнорируется, и в логе нет ни слова о причине. Проверьте запросом и выведите X-Forwarded-For и определённый удалённый адрес из отладочного эндпоинта.

Для Quarkus аналог - quarkus.http.proxy.proxy-address-forwarding=true, а в актуальных версиях ещё и quarkus.http.proxy.allow-x-forwarded=true. В Micronaut определяйте клиента через HttpClientAddressResolver, который сам читает X-Forwarded-For. WebSocket и server-sent events требуют обработки upgrade и таймаута простоя больше интервала heartbeat; об этом рассказывает статья WebSocket за обратным прокси, и она без изменений применима к WebSocket Spring и SseEmitter.

Время запуска и как его сократить#

Приложение Spring Boot с Hibernate, security и десятком стартеров обычно готово за 8-25 секунд на долях ядра, а при первом запуске после деплоя - дольше, поскольку JVM должна загрузить и проверить каждый класс. Это нормально, и важно по двум причинам: мониторы, проверяющие слишком рано, сообщают о сбое, а перезапуски идут медленно.

Что действительно меняет результат:

  • Больше доли CPU на время запуска. Запуск упирается в CPU и в основном однопоточен. Тариф с половиной ядра запускается заметно дольше, чем с целым, поэтому самое дешёвое ускорение - тариф покрупнее, а не флаг.
  • Меньше бинов и автоконфигураций. Уберите стартеры, которыми не пользуетесь. --debug печатает список сработавших автоконфигураций, и каждая стоит времени.
  • `-XX:TieredStopAtLevel=1`. Останавливает JIT на быстром компиляторе. Запуск быстрее, пропускная способность в установившемся режиме ниже. Годится для небольшого внутреннего инструмента, но не для нагруженного API.
  • `-Xshare:auto` с AppCDS. Архивы class data sharing ускоряют запуск. Spring Boot 3.3 и новее умеет собирать такой архив из jar; актуальные шаги есть в документации Spring Boot.
  • Ленивая инициализация. spring.main.lazy-initialization=true откладывает создание бинов до первого использования. Запуск быстрее, но за это платит первый запрос, а неверно настроенные бины обнаруживают пользователи, а не запуск. В продакшене оставьте её выключенной.
  • Индексы и миграции до запуска. Медленный старт часто объясняется миграцией Flyway или сканированием репозиториев, а не самой JVM.

Если главная проблема - холодный старт, Quarkus и Micronaut созданы именно для этого: оба выполняют анализ внедрения зависимостей на этапе сборки и запускаются на JVM за секунду-две. Нативный образ сокращает время до менее 100 мс, но ценой долгих сборок, которым нужно несколько гигабайт памяти, поэтому выполняйте их в CI.

Устранение неполадок#

`UnsupportedClassVersionError: ... has been compiled by a more recent version of the Java Runtime`. Jar скомпилирован под более новую Java, чем запущена на сервере. Компилируйте с --release 17 или выберите для сервера Java 21 или 25.

`no main manifest attribute, in app.jar`. Вы загрузили тонкий jar, обычно файл .original из Maven или -plain.jar из Gradle. Загрузите репакетированный.

`Web server failed to start. Port 8080 was already in use`. Приложение пытается занять порт, который вам не принадлежит, либо запущены две копии. Задайте SERVER_PORT равным выделенному порту и убедитесь, что старый процесс остановлен.

Сервер раз за разом перезапускается без стека вызовов. Процесс превысил лимит памяти контейнера. Уменьшите кучу, например MaxRAMPercentage=55, и сверьте график памяти с лимитом. Помните, что три перезапуска за час вызывают предупреждение.

`java.lang.OutOfMemoryError: Java heap space`. Куча слишком мала, либо что-то накапливается. Увеличьте кучу, если на тарифе есть запас, или снимите дамп кучи с помощью -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/container/dump.hprof и откройте его в профилировщике. Потом удалите дампы: они размером с кучу.

`HikariPool-1 - Connection is not available, request timed out after 30000ms`. Пул исчерпан. Прежде чем увеличивать maximum-pool-size, поищите медленные запросы и open-in-view, удерживающий соединения.

`Connection refused` или `UnknownHostException` для базы данных. Хост или порт в SPRING_DATASOURCE_URL указаны неверно. Скопируйте их со страницы базы данных и помните, что порты MySQL и PostgreSQL различаются.

`FATAL: password authentication failed`. Учётные данные неверны или содержат лишние пробелы после вставки. Введите переменную заново.

Редиректы ведут на `http://`, а сессии не сохраняются. Пересылаемые заголовки игнорируются. Задайте server.forward-headers-strategy=native и, если не помогло, проверьте server.tomcat.remoteip.internal-proxies.

Bad gateway от прокси, хотя консоль показывает, что приложение работает. Приложение привязано к 127.0.0.1, слушает не тот порт или всё ещё запускается. Дождитесь строки Started ... in N seconds, прежде чем делать выводы.

Переменная окружения как будто игнорируется. Проверьте relaxed binding. SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE не работает, а SPRING_DATASOURCE_HIKARI_MAXIMUMPOOLSIZE работает.

FAQ#

Сколько памяти нужно приложению Spring Boot?

Небольшому API с JPA и security обычно нужны куча 256-384 МБ и контейнер на 600-800 МБ, так что для простого сервиса подходит тариф на 1 ГБ, а 2 ГБ - с запасом. Большие кэши, объёмные выборки и множество потоков увеличивают потребность. Измеряйте по графику памяти, а не гадайте.

Что использовать: -Xmx или MaxRAMPercentage?

Используйте MaxRAMPercentage, если рассчитываете сменить тариф, потому что он следует за лимитом. Используйте -Xmx, если у вас есть измеренное число, которое нужно зафиксировать. Если заданы оба, приоритет у -Xmx.

Можно ли запускать Quarkus или Micronaut так же?

Да. Оба создают jar, который запускается через java -jar, флаги кучи те же, а различаются имена свойств: quarkus.http.port и micronaut.server.port вместо server.port. Их более быстрый запуск - преимущество на небольших тарифах.

Какую версию Java выбрать?

Безопаснее всего собирать под самую низкую версию, которую поддерживают ваши зависимости, а запускать по умолчанию лучше на Java 21. Для Boot 3 нужна как минимум 17. Jar, собранный под 17, запускается на 21 и 25, но не наоборот.

Нужен ли перед приложением обратный прокси?

Нужно что-то, что завершает TLS и продлевает сертификаты, и слот прокси это делает. Задайте server.forward-headers-strategy, чтобы приложение видело настоящий адрес клиента и правильную схему.

Почему приложение медленное первую минуту?

JVM сначала выполняет байт-код в интерпретаторе и компилирует горячие участки по мере обучения, поэтому первые запросы медленнее, а загрузка CPU во время прогрева высока. Небольшая доля CPU затягивает этот период. Если проверки readiness не проходят в это время, задайте им большую начальную задержку.