JVM-сервис можно упаковать в 300-мегабайтный образ с полным JDK, а можно — в компактный native-бинарник со стартом в миллисекундах. Между этими крайностями — набор осознанных компромиссов: скорость сборки, размер, время старта, потребление памяти и совместимость библиотек.
Вводная статья серии уже показала общую картину контейнеризации Java и упомянула GraalVM native вскользь, с ориентировочными цифрами для Spring Boot и Quarkus. Здесь — тот же вопрос вглубь: механика сборки Gradle/Maven, пять разных способов упаковать рантайм одного и того же сервиса и измеренные числа по каждому — startup, пиковая память, размер образа.
Числа в этой статье получены не на Spring Boot, а на осознанно минимальном сервисе: голый com.sun.net.httpserver (только java.base + jdk.httpserver, ни одной внешней зависимости). Решение сознательное — GraalVM native-image для Spring Boot требует reflection- и resource-конфигов и AOT-обработки, а цель стенда — честно сравнить упаковку, а не бороться с чужими ограничениями. Отсюда абсолютные цифры (startup в единицы-десятки миллисекунд для native, а не 30–80 мс, как в таблице вводной статьи для полноценного Spring Boot native) меньше, чем можно ожидать по вводной статье — там мерился фреймворк, здесь — только эффект способа упаковки.
По аналогии с минимальными образами Rust и C++готовится, с 11 ноября, но с JVM-спецификой: JVM не линкуется статически, поэтому даже «минимальный» JVM-образ тянет за собой рантайм, а не только бинарник.
В статье
- Сборка: Gradle и Maven
- Упаковка рантайма: fat-jar, layered, AppCDS, jlink
- GraalVM native image
- Бенчмарк: startup, память и размер образа на пяти режимах
- Минимальный и надёжный образ
- Как выбирать
Сборка: Gradle и Maven
Maven и Gradle решают одну задачу разными моделями. Maven — декларативный XML поверх фиксированного жизненного цикла (validate → compile → test → package → … → deploy): плагины подключаются к заранее известным фазам, порядок сборки предсказуем, но негибок. Gradle — граф задач (task graph) с явными зависимостями между ними, который сам решает, что можно пропустить или выполнить параллельно; Kotlin DSL (build.gradle.kts) добавляет типизацию и автодополнение в IDE вместо динамического Groovy.
Стенд к этой серии использует оба инструмента не ради демонстрации — так сложилось по факту: основной Maven-реактор (java-deep-dive/pom.xml) собирает большинство подмодулей, а Kotlin-часть — отдельный Gradle-модуль вне реактора, потому что Kotlin-тулчейн (плагин kotlin("jvm"), shadow-плагин) естественнее живёт в Gradle.
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
<parent>
<groupId>tech.khorost</groupId>
<artifactId>java-deep-dive</artifactId>
<version>0.1.0</version>
</parent>
<artifactId>build-packaging</artifactId>
<packaging>jar</packaging>
<build>
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<mainClass>tech.khorost.buildpackaging.app.App</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>plugins {
kotlin("jvm") version "2.2.0"
application
id("com.gradleup.shadow") version "9.5.1"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
testImplementation(kotlin("test"))
}
kotlin {
jvmToolchain(25)
}
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
// Kotlin 2.2.0 не умеет target JVM 25 (проверено живьём) — явный JVM_24
jvmTarget.set(JvmTarget.JVM_24)
}
}Живой нюанс тулчейна из этого же стенда: Kotlin 2.2.0 компилируется на JDK 25 (jvmToolchain(25)), но байткод-target — JVM 24, не 25. Живой лог compileKotlin честно об этом предупреждает: «Kotlin does not yet support 25 JDK target, falling back to Kotlin JVM_24 JVM target». Без явного jvmTarget.set(JvmTarget.JVM_24) Gradle 9 дополнительно падает на несогласованности между compileJava (унаследовавшим target 25 от toolchain) и compileKotlin — их приходится выравнивать вручную.
Управление зависимостями в обеих экосистемах решает одну и ту же проблему — версии транзитивных зависимостей должны совпадать по всему графу — но по-разному: Maven использует BOM (<dependencyManagement>, импорт spring-boot-dependencies и подобных), Gradle 7+ — version catalogs (libs.versions.toml) с единым источником версий для всех модулей. Порядок объявления BOM в Maven имеет значение сильнее, чем кажется: если несколько BOM пинуют одну и ту же зависимость на разные версии, побеждает первое объявление в <dependencyManagement>, а не «более специфичное» — это реальный подводный камень, встреченный в стенде статьи про data-access этой серии (Hibernate 7 требовал jakarta.persistence-api:3.2.0, а унаследованный spring-boot-dependencies BOM пинул 3.1.0; фикс — явная версия прямо на зависимости, она сильнее унаследованного BOM).
Кеширование в CI — тоже разная модель. Gradle build cache (локальный и удалённый) кеширует результаты задач по хешу входов и переиспользует их между сборками и даже между машинами при настроенном remote cache; Maven инкрементальность беднее из коробки (в основном — кеш зависимостей ~/.m2, инкрементальная компиляция ограничена). На практике для CI обоих инструментов главный рычаг — не путать кеш зависимостей с кешем Docker-слоёв: они решают разные проблемы (первый ускоряет саму сборку, второй — доставку готового артефакта), и об этом — следующий раздел.
Упаковка рантайма: fat-jar, layered, AppCDS, jlink
Дальше — не про то, как собрать jar, а про то, что с ним сделать дальше внутри Docker-образа. Один и тот же байткод одного и того же сервиса упакован здесь четырьмя JVM-способами плюс GraalVM native (следующий раздел) — и первые три JVM-режима специально устроены так, чтобы разница между ними была видна на цифрах, а не только в теории.
Fat-jar — самый простой случай: один исполняемый jar с Main-Class в манифесте на стоковом eclipse-temurin:25-jre.
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY target/app.jar /app/app.jar
ENV MODE=fat-jar
EXPOSE 8080
CMD ["java", "-jar", "/app/app.jar"]Layered раскладывает тот же байткод на два COPY-слоя вместо одного jar-файла — редко меняющийся lib/ (в стенде — ResponseFormatter) отдельно от часто меняющегося app/ (HTTP-хендлеры). У Spring Boot тот же приём реализован через java -Djarmode=tools -jar app.jar extract --layers (см. Dockerfile в вводной статье серии) — здесь то же самое сделано вручную, потому что зависимостей нет и слой dependencies разводить не от чего.
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY target/classes/tech/khorost/buildpackaging/lib /app/classes/tech/khorost/buildpackaging/lib
COPY target/classes/tech/khorost/buildpackaging/app /app/classes/tech/khorost/buildpackaging/app
ENV MODE=layered-jar
EXPOSE 8080
CMD ["java", "-cp", "/app/classes", "tech.khorost.buildpackaging.app.App"]Важно понимать заранее, что именно измеряет layered-режим: в бенчмарке ниже он практически неотличим от fat-jar по startup и памяти (это ожидаемо и подтверждено измерением) — исполняется тот же байткод в том же JRE, разница в раскладке файлов на диске самой JVM не видна вообще. Смысл layered — не скорость старта, а поведение docker build/push: при правке только app/-кода пересобирается и перекачивается в registry один слой, а не весь jar целиком. Экономия — на CI-времени и трафике при частых редеплоях, а не на рантайм-профиле.
AppCDS (Application Class-Data Sharing) ускоряет именно старт JVM — за счёт того, что классы не парсятся и не верифицируются заново при каждом запуске, а подгружаются из готового архива. Стенд использует динамический AppCDS: тренировочный прогон происходит прямо на этапе сборки образа — поднимается сервис с -XX:ArchiveClassesAtExit, дожидается живого /health, получает SIGTERM, и штатное завершение JVM дампит архив классов в app.jsa, который переезжает в финальный слой.
FROM eclipse-temurin:25-jre AS trainer
WORKDIR /app
COPY target/app.jar /app/app.jar
ENV MODE=appcds-train
RUN bash -c '\
java -XX:ArchiveClassesAtExit=/app/app.jsa -jar /app/app.jar & \
PID=$!; \
# ... опрос /health через /dev/tcp, затем ...
kill $PID; wait $PID || true; \
test -s /app/app.jsa'
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=trainer /app/app.jar /app/app.jar
COPY --from=trainer /app/app.jsa /app/app.jsa
ENV MODE=appcds
EXPOSE 8080
CMD ["java", "-XX:SharedArchiveFile=/app/app.jsa", "-jar", "/app/app.jar"]Тренировка на этапе сборки — не случайный выбор: продовый образ всегда несёт уже готовый архив, без двусмысленности «первый запуск медленнее, второй быстрее», которая была бы при генерации архива на лету (-XX:+AutoCreateSharedArchive). На стенде AppCDS ускоряет старт примерно на 20% (детали — в бенчмарке ниже) — эффект реальный, но скромный, потому что сервис крошечный: два класса приложения плюс jdk.httpserver. AppCDS тем ценнее, чем больше классов реально грузит приложение при старте — на «толстом» Spring Boot-сервисе с десятками автоконфигураций выигрыш был бы заметно больше, чем на этом минималистичном стенде.
jlink решает другую задачу — не startup, а размер рантайма. jdeps печатает точный список JDK-модулей, которые реально использует байткод сервиса (для этого стенда — всегда java.base,jdk.httpserver), а jlink строит урезанный JRE только из них, вместо полного eclipse-temurin:25-jre (~200 МБ).
FROM eclipse-temurin:25-jdk AS linker
WORKDIR /build
COPY target/app.jar /build/app.jar
RUN jdeps --print-module-deps --ignore-missing-deps /build/app.jar > /build/modules.txt \
&& jlink --add-modules "$(cat /build/modules.txt)" \
--strip-debug --no-man-pages --no-header-files --compress zip-6 \
--output /build/customjre
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=linker /build/customjre /opt/customjre
COPY --from=linker /build/app.jar /app/app.jar
ENV MODE=jlink
EXPOSE 8080
CMD ["/opt/customjre/bin/java", "-jar", "/app/app.jar"]Здесь стоит честно предупредить заранее, а не только в бенчмарке: на этом стенде jlink уменьшает образ (в разы), но увеличивает startup — и причина не в том, что custom runtime «медленный сам по себе». Диагностика через -Xlog:cds показывает разгадку прямо: стоковый eclipse-temurin:25-jre везёт дефолтный CDS-архив lib/server/classes.jsa (~14.5 МБ), которым автоматически пользуются fat/layered/appcds-режимы (все три — на том же temurin:25-jre). Custom runtime, собранный jlink, по умолчанию такого архива не содержит — JVM внутри него честно пишет в лог «Specified shared archive file not found». К этому добавляется декомпрессия сжатых --compress zip-6 модулей при загрузке классов. Итог: jlink жмёт образ, но теряет CDS и потому стартует медленнее «жирного» JRE со встроенным архивом. Лечится это не отказом от jlink, а добавлением CDS к custom runtime: плагин jlink --generate-cds-archive (доступен начиная с JDK 24 — подтверждено живьём и на JDK 25: плагин виден в выводе jlink --list-plugins как «Generate CDS archive if the runtime image supports the CDS feature», хотя в базовом jlink --help его не видно) либо отдельный AppCDS-архив поверх custom runtime, как в предыдущем режиме. В этом стенде jlink оставлен «наивным», без CDS — как поучительный контрпример: jlink сам по себе — это инструмент про размер, а не про скорость.
GraalVM native image
GraalVM native-image компилирует JVM-байткод в нативный исполняемый файл заранее (ahead-of-time), а не во время выполнения — на выходе получается один бинарник без JVM внутри. Механизм — Substrate VM: замкнутый мир классов, известный на момент сборки (closed-world assumption), собственный минималистичный рантайм и GC (в стенде — Serial GC, эргономический выбор для маленького сервиса). Именно замкнутость мира — источник главного ограничения native-image: всё, что определяется в рантайме через reflection, динамические прокси или classpath-сканирование, должно быть явно объявлено конфигами (reflect-config.json, resource-config.json и подобные) — иначе компиляция либо падает, либо собирает бинарник, который падает при первом обращении к нерасчитанному классу.
Демо-сервис стенда сознательно этого избегает: только java.base + jdk.httpserver, ноль внешних зависимостей, ноль reflection — native-image --no-fallback компилируется без единого reflect-конфига.
FROM ghcr.io/graalvm/native-image-community:25 AS builder
WORKDIR /build
COPY target/classes /build/classes
RUN native-image --no-fallback -cp /build/classes tech.khorost.buildpackaging.app.App -o /build/app
# Бинарник — динамически слинкованный ELF (glibc), не статический:
# ldd подтверждает зависимость на /lib64/ld-linux-x86-64.so.2 — нужен glibc-рантайм
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /build/app /app/app
ENV MODE=native
EXPOSE 8080
CMD ["/app/app"]Тулчейн стенда — ghcr.io/graalvm/native-image-community:25, GraalVM CE 25.0.2+10.1, собран на JVMCI JDK 25.0.2. Образ тянется с ghcr.io без прокси, и native-image для JDK 25 собрался из коробки — fallback на альтернативный тулчейн (например, Liberica NIK) не понадобился. Цена, которую не видно в рантайм-числах, — время сборки: на этом стенде компиляция заняла примерно 25–29 секунд при пиковой памяти сборщика около 1.55 ГБ на 16 потоках — для крошечного двухклассового сервиса. Для сервиса с реальным графом зависимостей время и память сборки растут заметно, и это отдельная статья расходов, которую нужно закладывать в CI (native-сборка — не то же самое, что mvn package, и по времени, и по требованиям к раннеру).
Важная оговорка о бинарнике: он динамически линкован с glibc, не статический — ldd подтверждает зависимость на /lib64/ld-linux-x86-64.so.2. Поэтому финальный образ построен на debian:bookworm-slim, а не на scratch/distroless-static — эти базы без glibc бинарник просто не запустят. GraalVM умеет собирать и статические бинарники (флаг --static, обычно вместе с musl libc), но этот путь на стенде не проверялся — цифр по нему в этой статье нет, и специально ничего не придумано.
Бенчмарк: startup, память и размер образа на пяти режимах
Методика одна для всех пяти строк: один и тот же байткод одного и того же plain-Java сервиса (com.sun.net.httpserver, только java.base + jdk.httpserver, без внешних зависимостей — тот же стенд, что и в разделах выше), пять прогонов на режим, каждый режим — отдельный контейнер. Startup и peak RSS измеряются внутри контейнера, без публикации порта на хост:
# port-forwarder Docker Desktop на Windows добавляет к первому коннекту
# случайные 20-80 секунд — артефакт хоста, а не JVM/native. Обходится
# переносом измерения внутрь контейнера (bash /dev/tcp опрос /health).
t0=$(date +%s%N)
eval "$START_CMD & echo \$! > /tmp/svc.pid"
PID=$(cat /tmp/svc.pid)
for _ in $(seq 1 4000); do
if exec 3<>/dev/tcp/127.0.0.1/8080 2>/dev/null; then
printf 'GET /health HTTP/1.0\r\n\r\n' >&3
resp=$(head -1 <&3 2>/dev/null || true)
case "$resp" in *200*) ok=1; break;; esac
fi
sleep 0.002
done
t1=$(date +%s%N)
startup_ms=$(( (t1 - t0) / 1000000 ))
# peak RSS — VmHWM (историчный пик резидентной памяти) из /proc/$PID/status
rss_kb=$(awk '/VmHWM/{print $2}' "/proc/$PID/status")Это не косметика: без переноса измерения внутрь контейнера port-forwarder Docker Desktop/Windows штрафовал первый коннект на 2–87 секунд — для JVM, стартующей за сотни миллисекунд, это полностью топит числа. Размер образа измерялся снаружи (docker image inspect, несжатый размер со слоями).
| Режим | startup (мс) | peak RSS (МБ) | размер образа (МБ) |
|---|---|---|---|
| fat (JVM fat-jar) | ~150 | ~65 | 111.4 |
| layered (JVM) | ~139 | ~65 | 111.4 |
| appcds (JVM+AppCDS) | ~118 | ~64 | 111.8 |
| native (GraalVM) | ~12 | ~16 | 32.1 |
| jlink (custom runtime) | ~320 | ~68 | 48.7 |
Четыре честных вывода по цифрам, по порядку убывания эффекта:
Native — самый быстрый и самый лёгкий по памяти, с большим отрывом. Порядок величины важнее точного числа: сырые значения пяти прогонов — 11, 10, 9, 17, 12 мс, разброс на глаз кажется большим (9–17 мс), но это ожидаемый шум опроса /dev/tcp в цикле с шагом 2 мс, а не нестабильность самого бинарника. Правильная формулировка — «native стартует на порядок быстрее» (единицы-десятки миллисекунд против полутора сотен у fat-jar), а не «native стартует ровно за 12 мс». По памяти разрыв тоже кратный — около 16 МБ против около 65 МБ у fat-jar, то есть примерно в 4 раза меньше.
Размер образа — не apples-to-apples сравнение, и это стоит держать в голове. Native (32.1 МБ) и jlink (48.7 МБ) заметно компактнее трёх JVM-режимов (111.4–111.8 МБ) — но не только за счёт способа упаковки как такового. native- и jlink-образы построены на debian:bookworm-slim, а fat/layered/appcds — на eclipse-temurin:25-jre, который сам по себе весит существенно больше голого Debian. Часть выигрыша размера — это разница базовых ОС-образов, а не чистый эффект AOT-компиляции или урезанного JRE. Апельсины с апельсинами здесь были бы — например — native на distroless-static (не собрано в этом стенде, бинарник динамически линкован с glibc и такую базу не запустит) против JVM-режима на минимально возможном JRE-образе того же дистрибутива. Тезис «native даёт меньший образ» в целом верен, но конкретные 3.5× в этих цифрах — комбинация двух эффектов, а не один.
AppCDS ускоряет старт примерно на 20% — умеренно, но реально: ~118 мс против ~150 мс у fat-jar, при том же RSS и почти том же размере образа (архив классов добавляет около 0.4 МБ). Скромность эффекта — не недостаток AppCDS, а следствие крошечного размера демо-сервиса: он грузит буквально пару классов приложения плюс jdk.httpserver. Чем больше классов реально парсит и верифицирует JVM при старте — а у полноценного Spring Boot-сервиса с автоконфигурациями их сотни, — тем заметнее выигрыш AppCDS.
Layered неотличим от fat по startup и RSS — и не должен отличаться. ~139 мс против ~150 мс — в пределах шума между прогонами (fat: 177/131/178/137/142, layered: 141/139/131/141/145 — диапазоны пересекаются), тот же ~65 МБ RSS, тот же 111.4 МБ образ. Исполняется тот же байткод в том же JRE — раскладка файлов на диске самой JVM не видна. Единственная реальная разница — в поведении docker build/push при частых редеплоях, не в рантайм-профиле, что и подтверждает бенчмарк.
Jlink уменьшает образ более чем вдвое, но неожиданно проигрывает по startup — 48.7 МБ против 111.4 МБ у fat-jar (в ~2.3 раза меньше), но ~320 мс против ~150 мс (заметно медленнее, а не быстрее). Причина — не сам jlink, а отсутствие CDS-архива в custom runtime по умолчанию, разобранная выше: eclipse-temurin:25-jre везёт готовый classes.jsa, jlink-рантайм — нет. Это единственный режим из пяти, где меньший размер образа куплен ценой худшего, а не лучшего startup, — и именно поэтому он в статье не «плохой пример», а поучительный: jlink закрывает вопрос размера рантайма, но не автоматически ускоряет старт, и об этом легко забыть, глядя только на размер образа.
Минимальный и надёжный образ
Из бенчмарка выше следует практическое правило: минимальный образ — это всегда комбинация двух независимых решений, «чем упаковать код» (fat/layered/appcds/jlink/native) и «на какой ОС-базе». JVM-режимы в этом стенде сидят на eclipse-temurin:25-jre — это уже минимальный JRE-образ (без компилятора, без инструментов разработки), но не distroless: в нём остаётся полноценная Debian-подобная система с shell и пакетным менеджером. native и jlink — на debian:bookworm-slim, потому что native-бинарник динамически линкован с glibc и не запустится на scratch/distroless-static; для jlink это ограничение не обязательно (custom runtime можно было бы попробовать и на более урезанной базе), но стенд держит обе базы одинаковыми ради единообразия и простоты сравнения, не ради минимума.
Практические правила, которые не зависят от режима упаковки:
- Непривилегированный пользователь. Ни один из пяти Dockerfile в стенде не создаёт отдельного
USER— это упрощение демо-стенда, не рекомендация для прод-образа. В вводной статье серии Spring Boot-пример уже показывает паттерн (addgroup/adduser/USER app) — тот же приём применим к любому из пяти режимов здесь: непривилегированный пользователь не влияет на startup/RSS/размер, но заметно сужает поверхность атаки при компрометации процесса. - Фиксация версий, а не плавающих тегов.
eclipse-temurin:25-jreиdebian:bookworm-slim— теги с движущейся точкой (патч-версии внутри тега меняются), а не иммутабельные digest. Для воспроизводимой сборки в CI и предсказуемого патчинга безопасности лучше пинить по digest (@sha256:...) там, где повторяемость важнее автоматических патчей, и обновлять его сознательно, а не полагаться на то, что:25-jreсегодня и:25-jreчерез месяц — один и тот же набор байт. - JDK на сборке, JRE/native на рантайме. Все пять Dockerfile в стенде уже следуют этому принципу через multi-stage:
eclipse-temurin:25-jdk(или GraalVM-образ, или Maven-образ снаружи) участвует только в build-стадии, финальный слой не тянет за собой компилятор и инструменты разработки — они не нужны в рантайме и только увеличивают поверхность образа.
Упаковка — только один из слоёв эксплуатационной готовности; конфигурация из env, graceful shutdown под оркестратором и observability разобраны отдельно в статье «JVM в production» этой же серии.
Как выбирать
Пять режимов этой статьи закрывают разные точки на шкале «скорость сборки — startup — память — размер образа», и универсального победителя нет:
- Fat-jar — разумный дефолт, если ничего из остального не критично: самый простой Dockerfile, самая понятная отладка, никаких сюрпризов с CDS или reflection.
- Layered — включать, когда важен CI/CD-трафик и время
docker build/push при частых редеплоях (много мелких изменений в коде на фоне стабильных зависимостей), а не рантайм-профиль — на рантайм он не влияет вовсе. - AppCDS — включать почти всегда, когда startup имеет значение, а reflection-heavy фреймворк не даёт применить native без серьёзной AOT-подготовки: цена (тренировочный прогон на этапе сборки) минимальна, выигрыш растёт вместе с количеством классов приложения.
- GraalVM native — оправдан, когда startup и память — не «желательно», а требование: serverless (холодный старт биллится напрямую), CLI-утилиты, sidecar-контейнеры с жёстким лимитом памяти. Цена — время и память сборки, и совместимость: библиотеки с интенсивным reflection/динамическими прокси (полноценный Spring Boot без AOT-профиля, часть ORM) требуют явных конфигов или вовсе не соберутся с
--no-fallback. - Jlink — стоит рассматривать, когда размер образа важнее startup (полоса пропускания registry, частая доставка образов в ограниченные по трафику окружения), и обязательно вместе с
jlink --generate-cds-archiveили отдельным AppCDS поверх custom runtime — иначе выигрыш в размере компенсируется проигрышем в старте, как показал бенчмарк выше.
Тот же вопрос — «минимальный и надёжный образ» — стоит на повестке и в других языках, просто с другими инструментами: сравнение подходов в C++, Rust, Java, C#, Python, Node и Scala — в статье «Сборка и минимальные образы в разных языках»Скоро.
Документация и первоисточники
- Gradle User Manual
- GraalVM Native Image
- JEP 282: jlink: The Java Linker
- JEP 310: Application Class-Data Sharing
- JEP 350: Dynamic CDS Archives
- Spring Boot: Container Images
- Стенд:
digital-cookbook/java/deep-dive/build-packaging(5 Dockerfile +bench.sh/bench-inside.sh)
Комментарии