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.
Requirements¶
A native build requires a GraalVM JDK: GraalVM Community Edition or Oracle GraalVM, version 21.
The Gradle plugin selects such a toolchain through the javaLauncher block shown in Build (JvmVendorSpec.matching("GraalVM Community")),
so an ordinary JDK can drive the build while native-image itself runs on GraalVM.
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:
Plugin build.gradle:
Plugin setup build.gradle:
graalvmNative {
binaries {
main {
imageName = "application"
mainClass = "ru.tinkoff.kora.example.Application"
debug = true
verbose = true
buildArgs.add("--report-unsupported-elements-at-runtime")
javaLauncher = javaToolchains.launcherFor {
languageVersion = JavaLanguageVersion.of(21)
vendor = JvmVendorSpec.matching("GraalVM Community")
}
}
}
metadataRepository {
enabled = true
}
}
Plugin build.gradle.kts:
Plugin setup build.gradle.kts:
graalvmNative {
binaries {
named("main") {
imageName.set("application")
mainClass.set("ru.tinkoff.kora.example.Application")
debug.set(true)
verbose.set(true)
buildArgs.add("--report-unsupported-elements-at-runtime")
javaLauncher = javaToolchains.launcherFor {
languageVersion = JavaLanguageVersion.of(21)
vendor = JvmVendorSpec.matching("GraalVM Community")
}
}
}
metadataRepository {
enabled.set(true)
}
}
Values added to buildArgs are passed straight to native-image. The most common ones:
--report-unsupported-elements-at-runtime— defer errors about unsupported features to runtime instead of failing the build (used in the example above).--no-fallback— never produce a fallback image (one that silently bundles a JVM); fail the build instead if something cannot be compiled ahead of time. This flag is used when invokingnative-imagedirectly (see Docker).debug/verbose— extra build diagnostics; can be dropped for release builds.
Flags that Kora itself needs are contributed automatically by its modules and do not have to be added by hand:
ru.tinkoff.kora:application-graphships--install-exit-handlersand--initialize-at-build-timefor the virtual-thread executor holder.ru.tinkoff.kora:commonships--initialize-at-run-timeforContextand the Reactor context hook.
These come from META-INF/native-image resources inside the module JARs and are merged into the build once the dependency is on the class path (see Metadata).
Fat JAR¶
native-image compiles a single class path into the binary, so a Kora application is usually assembled into one fat JAR first.
Kora relies on merged META-INF/services files (compile-time generated modules and extensions), therefore the JAR must be built with service-file merging — for example with the Shadow plugin:
The Shadow plugin produces an *-all.jar in build/libs that both the Gradle plugin and a direct native-image invocation can consume.
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 examples build their images, and it is independent of whether the application is written in Java or Kotlin:
FROM ghcr.io/graalvm/native-image-community:21 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 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"
The builder stage compiles application.jar into a native binary named application, and the runtime stage runs it as a non-root user.
Build the fat JAR first (./gradlew shadowJar), then docker build ..
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 JAR, so it is applied automatically once the dependency is on the class path.
Three kinds of files cover the common cases:
native-image.properties— build-time arguments, most importantly the class-initialization flags--initialize-at-build-timeand--initialize-at-run-time. For example, Kora'scommonmodule initializesru.tinkoff.kora.common.Contextat run time (its thread/context state must not be baked into the image), whileapplication-graphinitializes the virtual-thread executor holder at build time.reflect-config.json— classes, methods and fields accessed through reflection. For example, Kora registersThread.ofVirtual/Executors.newVirtualThreadPerTaskExecutorso Loom virtual threads work in the native image.resource-config.json— resources that must be embedded into the binary. For example, Kora bundlesreference.conf/application.confso HOCON configuration is readable at runtime.
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:
Custom metadata¶
When neither Kora nor the repository covers a class, supply the metadata by hand: drop 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 can ship a resource-config.json:
{
"resources": {
"includes": [
{ "pattern": "\\Qlogback.xml\\E" },
{ "pattern": "\\Qapplication.conf\\E" }
]
}
}
The <group>/<artifact> path segments are arbitrary but should be unique (usually your application's group and module) so that files from different dependencies do not collide.
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 application on a regular JVM with the agent attached, exercise the code paths that use reflection, resources or proxies, and the agent writes the corresponding config files:
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image/<group>/<artifact> \
-jar build/libs/application-all.jar
Commit the generated files as custom metadata. This is the usual fallback when a native build fails at runtime with a missing-reflection or missing-resource error.
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:
Then annotate the @KoraApp interface to declare the entrypoint and the resources to embed — the processor generates the matching native-image config at compile time:
import io.goodforgod.graalvm.hint.annotation.NativeImageHint;
import io.goodforgod.graalvm.hint.annotation.ResourceHint;
@ResourceHint(include = {"openapi/http-server.yaml"})
@NativeImageHint(name = "application", entrypoint = Application.class)
@KoraApp
public interface Application {
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
import io.goodforgod.graalvm.hint.annotation.NativeImageHint
import io.goodforgod.graalvm.hint.annotation.ResourceHint
@ResourceHint(include = ["openapi/http-server.yaml"])
@NativeImageHint(name = "application", entrypoint = Application::class)
@KoraApp
interface Application {
companion object {
@JvmStatic
fun main(args: Array<String>) {
KoraApplication.run(ApplicationGraph::graph)
}
}
}
Modules¶
Modules for which Kora already provides the required part of native image configuration:
- Configuration
- JSON
- Logback logging
- Probes
- Metrics
- Tracing
- HTTP server
- HTTP client
- OpenAPI code generation
- OpenAPI display
- JDBC (Postgres) database
- R2DBC (Postgres) database
- Vert.x database (Postgres)
- Cassandra database
- Kafka
- gRPC server
- gRPC client
- Resilience
- Cache
- Validation
- Scheduling
- Logging
Each of these modules ships its META-INF/native-image configuration inside its own JAR, so the settings are applied automatically once the dependency is on the class path; the core class-initialization and virtual-thread flags come from ru.tinkoff.kora:application-graph and ru.tinkoff.kora:common.
Ready-to-use examples for building with Gradle and Docker are available in the examples repository.