GraalVM Native
GraalVM Native Image — это инструмент для AOT-компиляции, который собирает Java-приложение заранее в отдельный нативный образ для целевой платформы.
Такой образ запускается без обычного прогрева JVM, но требует, чтобы часть сведений о коде, ресурсах и отражении была известна уже во время сборки.
Kora создает вспомогательные классы во время компиляции,
не использует Reflection API во время выполнения,
не использует динамические прокси,
не использует генерацию байт-кода во время компиляции и во время выполнения.
Это упрощает сборку приложений Kora в нативный образ, который быстрее запускается и обычно потребляет меньше памяти, чем приложение на обычной JVM.
Основные ограничения при такой сборке чаще связаны не с Kora, а со сторонними библиотеками, которым могут понадобиться дополнительные настройки отражения, ресурсов или инициализации классов.
Поэтому со стороны самой Kora обычно не требуется дополнительная настройка для сборки нативного образа:
модули, которые зависят от таких библиотек, поставляют нужные метаданные достижимости прямо внутри своих артефактов.
Требования¶
Для нативной сборки требуется JDK GraalVM — GraalVM Community Edition или Oracle GraalVM — той же мажорной версии, под которую скомпилировано приложение.
Артефакты Kora 2.0 собраны под Java 25, а native-image отказывается читать классы более нового class-file формата, чем его собственный JDK,
поэтому для нативной сборки нужен launcher GraalVM 25. Официальные примеры проверены на GraalVM CE 25 (native-image 25.x), установка — например, sdk install java 25.2.4-graalce.
Мажорная версия должна совпадать сразу в трёх местах, и забытое место — самая частая причина сломанной нативной сборки:
- toolchain модуля (
java { toolchain { … } }илиkotlin { jvmToolchain { … } }); javaLauncherбинарного файла в блокеgraalvmNative— см. Сборка;- базовый образ этапа-сборщика в Dockerfile.
Само приложение компилировать через GraalVM не обязательно: toolchain модуля может быть обычным JDK,
а Gradle-плагин выбирает launcher GraalVM через JvmVendorSpec.matching("GraalVM Community") только для native-image.
Если подходящей установки GraalVM в системе нет, nativeCompile падает на поиске toolchain — это проблема окружения, а не приложения.
При сборке вне плагина (например, командой native-image внутри сборочного образа Docker) инструмент native-image должен быть доступен в PATH — официальные контейнерные образы GraalVM уже содержат его.
Сборка¶
Пример сборки нативного образа с помощью Gradle-плагина:
build.gradle:
plugins {
id "application"
id "com.gradleup.shadow" version "9.4.1"
id "org.graalvm.buildtools.native" version "1.1.7" //(1)!
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25) //(2)!
vendor = JvmVendorSpec.ADOPTIUM
}
}
application {
mainClass = "io.koraframework.example.Application"
}
graalvmNative {
binaries {
main {
imageName = project.name //(3)!
mainClass = application.mainClass //(4)!
javaLauncher = javaToolchains.launcherFor {
languageVersion = JavaLanguageVersion.of(25) //(5)!
vendor = JvmVendorSpec.matching("GraalVM Community")
}
}
}
metadataRepository {
enabled = true //(6)!
}
}
- Версия плагина, который собирает
нативный образ. Для многомодульного проекта требуется 1.1.7 или новее: более ранние версии регистрировали один общий build service на весь build, и на Gradle 9 задачаcollectReachabilityMetadataвторого и каждого следующего нативного модуля падает при резолве конфигурации чужого проекта. - JDK, под который компилируются классы приложения, — здесь достаточно обычного JDK.
- Имя итогового бинарного файла в
build/native/nativeCompile(по умолчанию: имя проекта). - Полное имя класса с методом
main(обязательно). Присваивайте сам провайдер, а не строковую интерполяцию от него:application.mainClass— этоProperty<String>, и"$application.mainClass"отправит в командную строкуnative-imageотладочное представление (property(java.lang.String, fixed(...))) вместо имени класса. Проявляется это как main class not found при сборке образа, а не как ошибка конфигурации Gradle. - JDK, на котором работает сам
native-image, — вот он обязан быть GraalVM, см. Требования. - Включает репозиторий метаданных достижимости (по умолчанию:
false).
Собрать бинарный файл:
build.gradle.kts:
plugins {
id("application")
kotlin("jvm") version "2.4.10"
id("com.google.devtools.ksp") version "2.3.11"
id("com.gradleup.shadow") version "9.4.1"
id("org.graalvm.buildtools.native") version "1.1.7" //(1)!
}
kotlin {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(25)) //(2)!
vendor.set(JvmVendorSpec.ADOPTIUM)
}
}
application {
mainClass.set("io.koraframework.example.ApplicationKt")
}
graalvmNative {
binaries {
named("main") {
imageName.set(project.name) //(3)!
mainClass.set(application.mainClass) //(4)!
javaLauncher.set(javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(25)) //(5)!
vendor.set(JvmVendorSpec.matching("GraalVM Community"))
})
}
}
metadataRepository {
enabled.set(true) //(6)!
}
}
- Версия плагина, который собирает
нативный образ. Для многомодульного проекта требуется 1.1.7 или новее: более ранние версии регистрировали один общий build service на весь build, и на Gradle 9 задачаcollectReachabilityMetadataвторого и каждого следующего нативного модуля падает при резолве конфигурации чужого проекта. - JDK, под который компилируются классы приложения, — здесь достаточно обычного JDK.
- Имя итогового бинарного файла в
build/native/nativeCompile(по умолчанию: имя проекта). - Полное имя класса с методом
main(обязательно); для Kotlin это класс с суффиксомKt. Передавайте сам провайдер, а не собранную из него строку —application.mainClassэтоProperty<String>, и егоtoString()возвращает отладочное представление (property(java.lang.String, fixed(...))), которое и уезжает в командную строкуnative-imageвместо имени класса. Проявляется это как main class not found при сборке образа, а не как ошибка конфигурации Gradle. - JDK, на котором работает сам
native-image, — вот он обязан быть GraalVM, см. Требования. - Включает репозиторий метаданных достижимости (по умолчанию:
false).
Собрать бинарный файл:
Дополнительные флаги для native-image передаются через список buildArgs того же блока, например buildArgs.add("--no-fallback") — он запрещает создавать резервный образ (в который незаметно встраивается JVM) и вместо этого прерывает сборку, если что-то не удаётся скомпилировать заранее.
Свойства debug и verbose того же блока включают дополнительную диагностику сборки.
Не выключайте задачу jar
nativeCompile строит classpath из артефактов самого проекта, поэтому обычная задача jar должна оставаться включённой.
С jar.enabled = false в classpath попадают только зависимости, но не классы приложения,
и сборка падает на ненайденном классе Application, хотя compileJava прошёл успешно.
Fat JAR здесь не спасает — он собирается для пути через Docker и в classpath nativeCompile не участвует.
Fat JAR¶
native-image компилирует в бинарный файл единый classpath, поэтому для пути через Docker приложение Kora сначала собирают в один fat JAR.
Собирать fat JAR нужно с объединением файлов META-INF/services: наивный архив перезаписывает одноимённые сервисные файлы вместо их склейки,
а на них опираются и сама Kora (io.koraframework:common поставляет сервисный файл io.opentelemetry.context.ContextStorageProvider), и её зависимости (провайдер XNIO у HTTP-сервера, драйверы JDBC, SLF4J).
В нативном образе потерянный сервисный файл фатален, потому что соответствующий провайдер просто никогда не находится.
Объединение делает плагин Shadow:
shadowJar {
mergeServiceFiles() //(1)!
manifest {
attributes "Main-Class": application.mainClass //(2)!
}
}
assemble.dependsOn shadowJar
- Склеивает файлы
META-INF/servicesвсех зависимостей вместо их перезаписи (обязательно). - Точка входа архива — тот же провайдер, который передаётся в
mainClass.
tasks.shadowJar {
mergeServiceFiles() //(1)!
manifest {
attributes["Main-Class"] = application.mainClass //(2)!
}
}
tasks.assemble {
dependsOn(tasks.shadowJar)
}
- Склеивает файлы
META-INF/servicesвсех зависимостей вместо их перезаписи (обязательно). - Точка входа архива — тот же провайдер, который передаётся в
mainClass.
Плагин Shadow создаёт *-all.jar в каталоге build/libs:
Docker¶
В CI и на проде нативный образ обычно создают двухэтапной сборкой Docker: этап-сборщик (builder) на GraalVM компилирует fat JAR в бинарный файл, а компактный этап выполнения поставляет только этот бинарный файл.
Именно так собирают свои образы официальные примеры, и это не зависит от того, написано приложение на Java или Kotlin:
FROM ghcr.io/graalvm/native-image-community:25 as builder
ARG TARGET_DIR=/opt/app
ARG SOURCE_DIR=build/libs
WORKDIR $TARGET_DIR
COPY $SOURCE_DIR/*-all.jar $TARGET_DIR/application.jar
RUN native-image --no-fallback -classpath $TARGET_DIR/application.jar
FROM ubuntu:noble-20240212 as runner
ARG TARGET_DIR=/opt/app
WORKDIR $TARGET_DIR
COPY --from=builder $TARGET_DIR/application $TARGET_DIR/application
ARG DOCKER_USER=app
RUN groupadd -r $DOCKER_USER && useradd -rg $DOCKER_USER $DOCKER_USER
RUN chmod +x application
USER $DOCKER_USER
EXPOSE 8080/tcp
EXPOSE 8085/tcp
CMD "/opt/app/application"
Сначала соберите fat JAR, затем образ:
Два проброшенных порта — это два HTTP-сервера Kora: httpServer.port (по умолчанию: 8080) для API приложения и httpServer.system.port (по умолчанию: 8085) для системного сервера, который отдаёт пробы и метрики.
Этап-сборщик называет бинарный файл application, хотя -H:Name в командной строке не передаётся: имя и точка входа берутся из файла native-image.properties внутри JAR, сгенерированного по подсказкам через аннотации, которые используют примеры.
Без такого файла компилируемый класс нужно указывать явно, например native-image --no-fallback -classpath application.jar io.koraframework.example.Application.
Два пути сборки читают метаданные по-разному
nativeCompile сам подкладывает в сборку репозиторий метаданных, а native-image -classpath application.jar внутри Docker ничего не знает про Gradle и читает только то, что физически лежит в JAR.
Если образ из Gradle работает, а из Docker — нет, первый подозреваемый именно обвязка из раздела Репозиторий, а не код приложения.
Проверка¶
Успешная сборка нативного образа не является доказательством того, что образ работает.
Нехватка метаданных достижимости сборку не ломает: native-image завершается успешно, регистрации просто никогда не применяются, и отказ проявляется только во время выполнения.
Успешными должны оказаться три разные вещи, и проверять каждую нужно отдельно:
- сборка под JVM —
./gradlew buildпроходит и приложение работает на обычном JDK; - сборка нативного образа —
./gradlew nativeCompile(илиdocker build) создаёт бинарный файл; - работа бинарного файла — скомпилированное приложение действительно стартует и обслуживает запросы.
Минимальный набор проверок для третьего пункта:
- бинарный файл стартует и не падает через секунду;
GET /system/readinessна системном сервере отвечает200— значит, граф зависимостей инициализировался целиком;GET /metricsотдаёт метрики, а не заглушку и не500;- сценарий модуля отрабатывает против реальной зависимости (базы данных, брокера), а не на моках;
- в стартовом логе нет стектрейсов — в
нативном образеони часто единственный признак отвалившейся подсистемы.
Самый дешёвый способ закрепить это в CI — черноящичный тест, который собирает образ по Dockerfile и запускает его через Testcontainers; именно так протестированы все три нативных примера:
waitingFor(Wait.forHttp("/system/readiness")
.forPort(8085)
.forStatusCode(200)
.withStartupTimeout(Duration.ofSeconds(60)));
Ждите пробу, а не строку в логе
Готовность контейнера стройте на HTTP-пробе, а не на поиске стартового сообщения: формулировки стартовых логов Kora не являются контрактом и могут меняться между версиями, тогда как ответ 200 на GET /system/readiness — стабильный признак поднятого графа.
Метаданные¶
Некоторым библиотекам требуется дополнительная конфигурация для нативного образа, и native-image видит только то, что объявлено как метаданные достижимости.
Kora поставляет метаданные для собственных модулей в виде ресурсов META-INF/native-image/<group>/<artifact>/ внутри артефакта каждого модуля, поэтому они применяются автоматически, как только зависимость оказывается в classpath — см. Модули.
native-image читает в таком каталоге только файлы с каноническими именами:
native-image.properties— аргументы времени сборки, в первую очередь флаги инициализации классов--initialize-at-build-timeи--initialize-at-run-time. Например,io.koraframework:logging-commonинициализируетio.koraframework.logging.common.MDCво время сборки, аio.koraframework:netty-commonинициализирует весь пакетio.nettyво время выполнения.reflect-config.json— классы, методы и поля, к которым обращаются через отражение. Например,io.koraframework:database-jdbcрегистрирует конструктор HikariCPMicrometerMetricsTrackerFactory(MeterRegistry), который пул ищет рефлексивно при включённых метриках.resource-config.json— ресурсы, которые нужно встроить в бинарный файл. Например,io.koraframework:config-hoconвключаетreference.conf/application.conf, аio.koraframework:config-yaml—application.yaml/reference.yaml, чтобы конфигурация была доступна для чтения во время выполнения.proxy-config.json,serialization-config.json,jni-config.json,reachability-metadata.json— остальные виды: для динамических прокси, сериализации, JNI и объединённого современного формата.
Файл с любым другим именем игнорируется молча
Классическая ошибка — назвать файл reflection-config.json вместо reflect-config.json.
Такого имени native-image не знает вовсе: ошибки нет, предупреждения нет, сборка проходит успешно, а регистрации просто никогда не применяются — и приложение падает во время выполнения в месте, никак не связанном с этим файлом.
Проверка проекта — одна команда, и каждое попадание это мёртвый файл:
Репозиторий¶
Если приложение использует сторонние библиотеки, которым нужны метаданные достижимости, не поставляемые ими самими, включите их загрузку из репозитория метаданных достижимости GraalVM.
Включённого metadataRepository достаточно для nativeCompile, но не для пути через Docker: голый native-image -classpath application.jar читает только то, что лежит в JAR.
Чтобы оба пути видели одни и те же метаданные, соберите их в ресурсы до упаковки:
graalvmNative {
metadataRepository {
enabled = true //(1)!
}
}
processResources.dependsOn tasks.collectReachabilityMetadata //(2)!
sourceSets.main { resources.srcDirs += "$buildDir/native-reachability-metadata" } //(3)!
- Включает загрузку метаданных из репозитория (по умолчанию:
false). - Заставляет обработку ресурсов дождаться загрузки метаданных.
- Добавляет загруженные метаданные в ресурсы, чтобы они попали внутрь fat JAR.
graalvmNative {
metadataRepository {
enabled.set(true) //(1)!
}
}
tasks.processResources {
dependsOn(tasks.collectReachabilityMetadata) //(2)!
}
sourceSets.main {
resources.srcDir(layout.buildDirectory.dir("native-reachability-metadata")) //(3)!
}
- Включает загрузку метаданных из репозитория (по умолчанию:
false). - Заставляет обработку ресурсов дождаться загрузки метаданных.
- Добавляет загруженные метаданные в ресурсы, чтобы они попали внутрь fat JAR.
Пользовательские метаданные¶
Когда класс не покрыт ни Kora, ни репозиторием, задайте метаданные вручную: положите native-image.properties, reflect-config.json и/или resource-config.json в каталог src/main/resources/META-INF/native-image/<group>/<artifact>/ вашего собственного приложения — native-image объединяет все такие файлы, найденные в classpath.
Например, чтобы встроить конфигурацию Logback и файл конфигурации HOCON в бинарный файл, приложение поставляет resource-config.json:
{
"resources": {
"includes": [
{ "pattern": "\\Qlogback.xml\\E" },
{ "pattern": "\\Qapplication.conf\\E" }
]
}
}
и reflect-config.json для аппендера и энкодера, которые Logback создаёт по имени из этого XML:
[
{
"name": "io.koraframework.logging.logback.ConsoleTextRecordEncoder",
"allDeclaredConstructors": true,
"allPublicMethods": true
},
{
"name": "io.koraframework.logging.logback.KoraAsyncAppender",
"allDeclaredConstructors": true,
"allPublicMethods": true
},
{
"name": "ch.qos.logback.core.status.NopStatusListener",
"allDeclaredConstructors": true
}
]
Сегменты пути <group>/<artifact> на чтение файлов не влияют — это пространство имён, и оно должно быть уникальным (обычно это group и модуль вашего приложения), чтобы файлы из разных зависимостей не конфликтовали внутри одного JAR.
Работающее на JVM приложение ничего не доказывает про метаданные
JVM не читает META-INF/native-image вообще, поэтому нельзя счесть метаданные ненужными на том основании, что без них всё работает под обычным JDK.
Обратная ошибка так же частая: при смене группы приложения каталог META-INF/native-image/<group>/ переименовывается вручную, а имена классов внутри файлов трогать нельзя — там перечислены классы сторонних библиотек, которые не переименовывались.
Агент¶
Для сторонних библиотек, которые не покрыты репозиторием, стандартный способ обнаружить необходимые метаданные — агент трассировки GraalVM.
Запустите на обычной JVM с подключённым агентом тот же fat JAR, который идёт в образ, пройдите по путям кода, которые используют отражение, ресурсы или прокси, и остановите приложение штатно (SIGTERM) — иначе конфигурация не запишется:
java -agentlib:native-image-agent=config-output-dir=/tmp/native-image-config \
-jar build/libs/application-all.jar
Агент видит только те ветки, которые были выполнены, — это ограничение подхода, а не дефект. Его вывод — гипотеза, а не готовый патч: там будут сотни записей про сторонние библиотеки, место которым внутри самих этих библиотек. Сравните вывод с тем, что приложение уже поставляет, и перенесите только те записи, которые относятся к приложению:
Зафиксируйте отобранные записи как пользовательские метаданные.
Подсказки через аннотации¶
Официальные примеры генерируют часть метаданных из аннотаций с помощью сторонней библиотеки GraalVM Hint Processor. Это не API Kora — это внешнее, необязательное удобство, взаимозаменяемое с написанными вручную пользовательскими метаданными выше.
Добавьте процессор и аннотации:
plugins {
kotlin("kapt")
}
dependencies {
kapt("io.goodforgod:graalvm-hint-processor:1.2.0")
compileOnly("io.goodforgod:graalvm-hint-annotations:1.2.0")
}
Процессор работает через kapt, поэтому в Kotlin-приложении его придётся запускать рядом с KSP-процессором Kora.
Если это нежелательно, напишите те же файлы вручную как пользовательские метаданные — результат будет идентичным.
Затем разметьте интерфейс @KoraApp, чтобы объявить точку входа и ресурсы для встраивания — процессор сгенерирует соответствующую конфигурацию native-image во время компиляции:
import io.goodforgod.graalvm.hint.annotation.NativeImageHint;
import io.goodforgod.graalvm.hint.annotation.ReflectionHint;
import io.goodforgod.graalvm.hint.annotation.ResourceHint;
import io.netty.channel.socket.nio.NioDatagramChannel;
@ResourceHint(include = {"openapi/http-server.yaml"}) //(1)!
@ReflectionHint(types = NioDatagramChannel.class) //(2)!
@NativeImageHint(name = "application", entrypoint = Application.class) //(3)!
@KoraApp
public interface Application extends
HoconConfigModule,
LogbackModule,
UndertowPublicHttpServerModule {
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
- Ресурсы, встраиваемые в бинарный файл, — генерирует
resource-config.json. - Классы, регистрируемые для отражения, — генерирует
reflect-config.json. - Имя итогового бинарного файла и класс, метод
mainкоторого является точкой входа, — генерируетnative-image.properties, благодаря которому сборка через Docker может вызыватьnative-image, передав только classpath.
import io.goodforgod.graalvm.hint.annotation.NativeImageHint
import io.goodforgod.graalvm.hint.annotation.ReflectionHint
import io.goodforgod.graalvm.hint.annotation.ResourceHint
import io.netty.channel.socket.nio.NioDatagramChannel
@ResourceHint(include = ["openapi/http-server.yaml"]) //(1)!
@ReflectionHint(types = [NioDatagramChannel::class]) //(2)!
@NativeImageHint(name = "application", entrypoint = Application::class) //(3)!
@KoraApp
interface Application : HoconConfigModule, LogbackModule, UndertowPublicHttpServerModule {
companion object {
@JvmStatic
fun main(args: Array<String>) {
KoraApplication.run { ApplicationGraph.graph() }
}
}
}
- Ресурсы, встраиваемые в бинарный файл, — генерирует
resource-config.json. - Классы, регистрируемые для отражения, — генерирует
reflect-config.json. - Имя итогового бинарного файла и класс, метод
mainкоторого является точкой входа, — генерируетnative-image.properties, благодаря которому сборка через Docker может вызыватьnative-image, передав только classpath. Точкой входа должен быть класс со статическимmain, поэтому методmainздесь живёт в companion-объекте с@JvmStatic, аmainClassв build-файле указывает наio.koraframework.example.Application, а не на…ApplicationKt.
Диагностика¶
Нативный образ, который успешно собрался, но неправильно работает, — это нормальный режим отказа, и текст исключения при этом часто вводит в заблуждение:
библиотеки ловят Throwable при поиске провайдеров и репортят вторичную ошибку.
Канонический пример — java.lang.IllegalArgumentException: No XNIO provider found при старте HTTP-сервера: он означал не отсутствие провайдера, а невозможность создать его логгер — jboss-logging грузит реализацию <интерфейс>_$logger рефлексивно.
Три шага, каждый из которых даёт факт, а не гипотезу:
- Запустите агент трассировки и посмотрите, что реально грузится рефлексивно. Берите тот же fat JAR, что идёт в образ, запускайте на JDK GraalVM и прогоняйте сценарий целиком.
- Изолируйте проблему минимальным нативным пробником. Отдельный
main, который дёргает только подозреваемую библиотеку без Kora, собранный тем жеnative-image. Если пробник падает, причина в библиотеке или в метаданных модуля, и искать её в коде приложения бессмысленно. - Проверьте, что метаданные вообще читаются. Этот шаг пропускают чаще всего, а он единственный отличает запись неполная от файл не читается: добавьте регистрацию с наблюдаемым эффектом, пересоберите образ и посмотрите, изменилось ли поведение; если нет — положите ту же запись в файл с каноническим именем и повторите.
Частые симптомы и куда смотреть:
- main class not found при сборке образа — в
mainClassприсвоена строковая интерполяция вместо провайдера, см. Сборка. - класс
Applicationне найден, хотяcompileJavaпрошёл, — выключена задачаjar, см. Сборка. nativeCompileпадает на выборе toolchain — Gradle не видит GraalVM нужной мажорной версии, см. Требования.- образ из Gradle работает, а из Docker — нет, — метаданных нет внутри JAR, см. Репозиторий.
- приложение стартует, но логи пусты или идут мимо конфигурации, — потеряны метаданные Logback, см. Пользовательские метаданные.
Модули¶
Модули Kora, которые поставляют собственную конфигурацию нативного образа внутри своих артефактов:
- Конфигурация —
config-common,config-hocon,config-yaml - Логирование Logback —
logging-logback - Логирование —
logging-common - HTTP-сервер —
http-server-undertow - Netty —
netty-common - Метрики —
micrometer-module - Трассировка —
opentelemetry-tracing - База данных JDBC —
database-jdbc - База данных Cassandra —
database-cassandra - Кэш —
cache-caffeine - Kafka —
kafka - gRPC-сервер —
grpc-server - Отображение OpenAPI —
openapi-management
Настройки применяются автоматически, как только зависимость оказывается в classpath, и от приложения никаких действий не требуется.
Модули Kora, которых нет в этом списке, не поставляют конфигурацию нативного образа, потому что она им не нужна: их код генерируется во время компиляции и достижим статически.
Готовые примеры сборки через Gradle и Docker вместе с черноящичными тестами, которые запускают полученный бинарный файл, можно посмотреть в репозитории с примерами:
kora-java-graalvm-crud-jdbc— нативный CRUD HTTP-сервис на JDBC с кэшем Caffeinekora-java-graalvm-crud-cassandra— нативный CRUD HTTP-сервис на Cassandra с кэшем Rediskora-java-graalvm-kafka— нативный сервис с консьюмером и продюсером Kafka