GraalVM Native
GraalVM Native Image is a tool for AOT compilation that builds a Java application ahead of time into a standalone native image for the target platform.
Such an image starts without regular JVM warmup, but requires part of the information about code, resources, and reflection to be known at build time.
Kora creates its helper classes at compile time, does not use the Reflection API at runtime, does not use dynamic proxies, does not generate bytecode at compile time or runtime. This makes it easier to build Kora applications into a native image that starts faster and usually consumes less memory than a regular JVM application. The main limitations of this kind of build are usually related not to Kora itself, but to third-party libraries that may require additional reflection, resource, or class initialization settings.
Therefore, Kora itself usually does not require additional configuration to build a native image: the modules that depend on such libraries ship the required reachability metadata inside their own artifacts.
Requirements¶
A native build requires a GraalVM JDK — GraalVM Community Edition or Oracle GraalVM — of the same major version the application is compiled for.
Kora 2.0 artifacts are compiled for Java 25, and native-image refuses to read class files of a newer class-file format than its own JDK,
so the native build requires a GraalVM 25 launcher. The official examples were verified on GraalVM CE 25 (native-image 25.x), installed for example with sdk install java 25.2.4-graalce.
The major version has to match in three places at once, and forgetting any of them is the most common cause of a broken native build:
- the module toolchain (
java { toolchain { … } }orkotlin { jvmToolchain { … } }); - the
javaLauncherof thegraalvmNativebinary — see Build; - the base image of the builder stage in the Dockerfile.
The application itself does not have to be compiled by GraalVM: the module toolchain can be an ordinary JDK,
while the Gradle plugin picks a GraalVM launcher through JvmVendorSpec.matching("GraalVM Community") only for native-image.
If no matching GraalVM installation is visible to Gradle, nativeCompile fails while selecting the toolchain — this is an environment problem, not an application problem.
When building outside the plugin (for example, the native-image command inside a Docker builder), the native-image tool must be available on PATH — the official GraalVM container images already ship it.
Build¶
Example of building a native image using the Gradle plugin:
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)!
}
}
- Version of the plugin that builds the native image. 1.1.7 or newer is required for a multi-module build: earlier versions registered a single shared build service for the whole build, and on Gradle 9 the
collectReachabilityMetadatatask of the second and every subsequent native module fails while resolving a configuration of another project. - JDK the application classes are compiled for — an ordinary JDK is fine here.
- Name of the resulting binary in
build/native/nativeCompile(default: project name). - Fully qualified name of the class with the
mainmethod (required). Assign the provider itself, not a string interpolation of it:application.mainClassis aProperty<String>, and"$application.mainClass"puts its debug representation (property(java.lang.String, fixed(...))) on thenative-imagecommand line instead of the class name. The failure surfaces as main class not found during image build, not as a Gradle configuration error. - JDK used by
native-imageitself — this one must be GraalVM, see Requirements. - Enables the reachability metadata repository (default:
false).
Build the binary:
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)!
}
}
- Version of the plugin that builds the native image. 1.1.7 or newer is required for a multi-module build: earlier versions registered a single shared build service for the whole build, and on Gradle 9 the
collectReachabilityMetadatatask of the second and every subsequent native module fails while resolving a configuration of another project. - JDK the application classes are compiled for — an ordinary JDK is fine here.
- Name of the resulting binary in
build/native/nativeCompile(default: project name). - Fully qualified name of the class with the
mainmethod (required); for Kotlin this is a class with theKtsuffix. Pass the provider itself, not a string built from it —application.mainClassis aProperty<String>, and itstoString()is a debug representation (property(java.lang.String, fixed(...))), which reaches thenative-imagecommand line instead of the class name. The failure surfaces as main class not found during image build, not as a Gradle configuration error. - JDK used by
native-imageitself — this one must be GraalVM, see Requirements. - Enables the reachability metadata repository (default:
false).
Build the binary:
Extra flags for native-image are passed through the buildArgs list of the binary, for example buildArgs.add("--no-fallback") — it forbids producing a fallback image (one that silently bundles a JVM) and fails the build instead if something cannot be compiled ahead of time.
The debug and verbose properties of the same block turn on additional build diagnostics.
Do not disable the jar task
nativeCompile builds its class path from the project's own artifacts, so the plain jar task must stay enabled.
With jar.enabled = false the class path contains only the dependencies and none of the application classes,
and the build fails on a missing Application class even though compileJava succeeded.
The fat JAR does not help here — it is assembled for the Docker path and does not participate in the nativeCompile class path.
Fat JAR¶
native-image compiles a single class path into the binary, so for the Docker path a Kora application is assembled into one fat JAR first.
A fat JAR must be built with META-INF/services merging: a naive archive overwrites same-named service files instead of concatenating them,
and both Kora (io.koraframework:common ships an io.opentelemetry.context.ContextStorageProvider service file) and its dependencies (the XNIO provider of the HTTP server, JDBC drivers, SLF4J) rely on them.
In a native image a lost service file is fatal, because the corresponding provider is simply never discovered.
The Shadow plugin does the merging:
shadowJar {
mergeServiceFiles() //(1)!
manifest {
attributes "Main-Class": application.mainClass //(2)!
}
}
assemble.dependsOn shadowJar
- Concatenates the
META-INF/servicesfiles of all dependencies instead of overwriting them (required). - Entry point of the archive — the same provider that is passed to
mainClass.
tasks.shadowJar {
mergeServiceFiles() //(1)!
manifest {
attributes["Main-Class"] = application.mainClass //(2)!
}
}
tasks.assemble {
dependsOn(tasks.shadowJar)
}
- Concatenates the
META-INF/servicesfiles of all dependencies instead of overwriting them (required). - Entry point of the archive — the same provider that is passed to
mainClass.
The Shadow plugin produces an *-all.jar in build/libs:
Docker¶
In CI and production the native image is usually produced with a two-stage Docker build: a GraalVM builder stage compiles the fat JAR into a binary, and a slim runtime stage ships only that binary. This is how the official examples build their images, and it does not depend on whether the application is written in Java or 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"
Build the fat JAR first, then the image:
The two exposed ports are the two Kora HTTP servers: httpServer.port (default: 8080) for the application API and httpServer.system.port (default: 8085) for the system server that serves probes and metrics.
The builder stage names the binary application even though no -H:Name is passed on the command line: the name and the entry point come from a native-image.properties file inside the JAR, generated from the annotation hints used by the examples.
Without such a file the class to compile has to be given explicitly, for example native-image --no-fallback -classpath application.jar io.koraframework.example.Application.
Two build paths read metadata differently
nativeCompile adds the metadata repository to the build itself, whereas native-image -classpath application.jar inside Docker knows nothing about Gradle and reads only what physically lies in the JAR.
If the image built by Gradle works and the one built by Docker does not, the metadata plumbing described in Repository is the first suspect, not the application code.
Verification¶
A successful native image build is not evidence that the image works.
Missing reachability metadata does not fail the build: native-image finishes green and the registrations are simply never applied, so the failure surfaces only at runtime.
Three different things have to succeed, and each one has to be checked separately:
- the JVM build —
./gradlew buildpasses and the application runs on a regular JDK; - the native image build —
./gradlew nativeCompile(ordocker build) produces a binary; - the binary at runtime — the compiled application actually starts and serves traffic.
A minimal checklist for the third point:
- the binary starts and does not die a second later;
GET /system/readinesson the system server answers200— meaning the whole dependency graph was initialized;GET /metricsreturns metrics rather than a stub or a500;- the module scenario works against a real dependency (database, broker), not against mocks;
- there are no stack traces in the startup log — in a native image they are often the only sign of a subsystem that silently fell off.
The cheapest way to keep this in CI is a black-box test that builds the image from the Dockerfile and runs it with Testcontainers — this is how all three native examples are tested:
waitingFor(Wait.forHttp("/system/readiness")
.forPort(8085)
.forStatusCode(200)
.withStartupTimeout(Duration.ofSeconds(60)));
Wait for the probe, not for a log line
Base container readiness on the HTTP probe rather than on matching a startup message: the wording of Kora's startup logs is not part of its contract and can change between versions, while GET /system/readiness returning 200 is a stable signal that the graph is up.
Metadata¶
Some libraries need additional configuration for a native image, and native-image can only see what is declared as reachability metadata.
Kora ships the metadata for its own modules as META-INF/native-image/<group>/<artifact>/ resources inside each module artifact, so it is applied automatically once the dependency is on the class path — see Modules.
native-image reads only files with canonical names in such a directory:
native-image.properties— build-time arguments, most importantly the class-initialization flags--initialize-at-build-timeand--initialize-at-run-time. For example,io.koraframework:logging-commoninitializesio.koraframework.logging.common.MDCat build time, whileio.koraframework:netty-commoninitializes the wholeio.nettypackage at run time.reflect-config.json— classes, methods and fields accessed through reflection. For example,io.koraframework:database-jdbcregisters the HikariCP constructorMicrometerMetricsTrackerFactory(MeterRegistry), which the pool looks up reflectively when metrics are enabled.resource-config.json— resources that must be embedded into the binary. For example,io.koraframework:config-hoconbundlesreference.conf/application.conf, andio.koraframework:config-yamlbundlesapplication.yaml/reference.yaml, so the configuration is readable at runtime.proxy-config.json,serialization-config.json,jni-config.json,reachability-metadata.json— the remaining kinds, for dynamic proxies, serialization, JNI and the combined modern format.
A file with any other name is ignored silently
A classic mistake is naming the file reflection-config.json instead of reflect-config.json.
native-image does not know that name: there is no error and no warning, the build succeeds, and the registrations are simply never applied — the application then fails at runtime in a place that has nothing to do with the file.
Checking a project takes one command, and every hit is a dead file:
Repository¶
If the application uses third-party libraries that need reachability metadata they do not ship themselves, enable loading it from the GraalVM Reachability Metadata Repository.
Enabling metadataRepository is enough for nativeCompile, but not for the Docker path: a bare native-image -classpath application.jar reads only what lies in the JAR.
To make both paths see the same metadata, collect it into the resources before packaging:
graalvmNative {
metadataRepository {
enabled = true //(1)!
}
}
processResources.dependsOn tasks.collectReachabilityMetadata //(2)!
sourceSets.main { resources.srcDirs += "$buildDir/native-reachability-metadata" } //(3)!
- Enables downloading metadata from the repository (default:
false). - Makes resource processing wait for the metadata to be downloaded.
- Adds the downloaded metadata to the resources, so it ends up inside the 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)!
}
- Enables downloading metadata from the repository (default:
false). - Makes resource processing wait for the metadata to be downloaded.
- Adds the downloaded metadata to the resources, so it ends up inside the fat JAR.
Custom metadata¶
When neither Kora nor the repository covers a class, supply the metadata by hand: put native-image.properties, reflect-config.json and/or resource-config.json under src/main/resources/META-INF/native-image/<group>/<artifact>/ in your own application — native-image merges every such file found on the class path.
For example, to embed the Logback configuration and the HOCON config file into the binary, an application ships a resource-config.json:
{
"resources": {
"includes": [
{ "pattern": "\\Qlogback.xml\\E" },
{ "pattern": "\\Qapplication.conf\\E" }
]
}
}
and a reflect-config.json for the appender and encoder Logback instantiates by name from that 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
}
]
The <group>/<artifact> path segments do not affect how the files are read — they are a namespace, and they should be unique (usually your application's group and module) so that files from different dependencies do not collide inside one JAR.
The application working on the JVM proves nothing about its metadata
The JVM does not read META-INF/native-image at all, so metadata cannot be declared unnecessary on the grounds that everything works without it under a regular JDK.
The reverse mistake is just as common: when the group of an application changes, the META-INF/native-image/<group>/ directory has to be renamed by hand, while the class names inside the files must stay untouched — they name classes of third-party libraries, which were not renamed.
Agent¶
For third-party libraries the repository does not cover, the standard way to discover the required metadata is the GraalVM tracing agent.
Run the same fat JAR that goes into the image on a regular JVM with the agent attached, exercise the code paths that use reflection, resources or proxies, and stop the application normally (SIGTERM) — otherwise the configuration is not written:
java -agentlib:native-image-agent=config-output-dir=/tmp/native-image-config \
-jar build/libs/application-all.jar
The agent sees only the branches that were actually executed — that is a limitation of the approach, not a defect. Its output is a hypothesis, not a ready-made patch: it contains hundreds of entries about third-party libraries whose proper place is inside those libraries. Compare it with what the application already ships and move over only the entries that belong to the application:
Commit the selected entries as custom metadata.
Annotation hints¶
The official examples generate part of the metadata from annotations with the third-party GraalVM Hint Processor library. This is not a Kora API — it is an external, optional convenience that is interchangeable with the hand-written custom metadata above.
Add the processor and the annotations:
plugins {
kotlin("kapt")
}
dependencies {
kapt("io.goodforgod:graalvm-hint-processor:1.2.0")
compileOnly("io.goodforgod:graalvm-hint-annotations:1.2.0")
}
The processor is a kapt annotation processor, so for a Kotlin application it has to run next to the Kora KSP processor.
If that is undesirable, write the same files by hand as custom metadata — the result is identical.
Then annotate the @KoraApp interface to declare the entrypoint and the resources to embed — the processor generates the matching native-image configuration at compile time:
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);
}
}
- Resources embedded into the binary — generates a
resource-config.json. - Classes registered for reflection — generates a
reflect-config.json. - Name of the resulting binary and the class whose
mainmethod is the entry point — generates anative-image.properties, which is what lets the Docker build callnative-imagewith nothing but a class path.
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() }
}
}
}
- Resources embedded into the binary — generates a
resource-config.json. - Classes registered for reflection — generates a
reflect-config.json. - Name of the resulting binary and the class whose
mainmethod is the entry point — generates anative-image.properties, which is what lets the Docker build callnative-imagewith nothing but a class path. The entry point must be a class with a staticmain, which is why themainmethod here lives in a companion object with@JvmStaticandmainClassin the build file isio.koraframework.example.Applicationrather than…ApplicationKt.
Troubleshooting¶
A native image that builds successfully but misbehaves at runtime is the normal failure mode, and the text of the exception is often misleading:
libraries catch Throwable while looking up providers and report a secondary error.
The canonical case is java.lang.IllegalArgumentException: No XNIO provider found on HTTP server startup, which meant not that the provider was absent but that its logger could not be created — jboss-logging loads a <interface>_$logger implementation reflectively.
Three steps, each producing a fact rather than a guess:
- Run the tracing agent to see what is really loaded reflectively. Use the same fat JAR that goes into the image, on a GraalVM JDK, and exercise the whole scenario.
- Isolate with a minimal native probe. A separate
mainthat touches only the suspect library, without Kora, compiled by the samenative-image. If the probe fails, the cause is in the library or in a module's metadata, and looking for it in the application code is pointless. - Check that the metadata is read at all. This step is skipped most often, and it is the only one that separates the entry is incomplete from the file is not read: add a registration whose effect is observable, rebuild the image, and see whether the behaviour changes; if it does not, put the same entry into a file with a canonical name and repeat.
Frequent symptoms and where to look:
- main class not found while building the image —
mainClasswas assigned a string interpolation instead of the provider, see Build. - the
Applicationclass is not found althoughcompileJavasucceeded — thejartask was disabled, see Build. nativeCompilefails while selecting a toolchain — no GraalVM of the required major version is visible to Gradle, see Requirements.- the Gradle image works and the Docker one does not — the metadata is not inside the JAR, see Repository.
- the application starts but the logs are empty or ignore the configuration — the Logback metadata was lost, see Custom metadata.
Modules¶
Kora modules that ship their own native image configuration inside their artifacts:
- Configuration —
config-common,config-hocon,config-yaml - Logback logging —
logging-logback - Logging —
logging-common - HTTP server —
http-server-undertow - Netty —
netty-common - Metrics —
micrometer-module - Tracing —
opentelemetry-tracing - JDBC database —
database-jdbc - Cassandra database —
database-cassandra - Cache —
cache-caffeine - Kafka —
kafka - gRPC server —
grpc-server - OpenAPI display —
openapi-management
The settings are applied automatically once the dependency is on the class path, and no action is required from the application. Kora modules that are not on this list ship no native image configuration because they do not need any: their code is generated at compile time and is statically reachable.
Ready-to-use examples of building with Gradle and Docker, together with black-box tests that run the resulting binary, are available in the examples repository:
kora-java-graalvm-crud-jdbc— native CRUD HTTP service on JDBC with a Caffeine cachekora-java-graalvm-crud-cassandra— native CRUD HTTP service on Cassandra with a Redis cachekora-java-graalvm-kafka— native Kafka consumer and producer service