Skip to content
Streamlord

Native image

Streamlord in a GraalVM native image: the build file, the serializers, the resources and the container, in the order you need them.

I carry what I need and leave the rest at the gate.

Streamlord’s core has no reflection in it, so most of the SDK goes into a native image untouched. Three things need your attention: the serializers your codec would otherwise look up, the resources you read by name, and the flags your server framework wants. This documentation’s own search service is built exactly this way, with no hand-written reflection metadata at all.

1. The build file

// build.gradle.kts
plugins {
    id("org.graalvm.buildtools.native") version "0.11.5"
    application
}

graalvmNative {
    binaries {
        named("main") {
            imageName.set("my-service")
            mainClass.set("com.example.MainKt")

            fallback.set(false)
            sharedLibrary.set(false)

            buildArgs.addAll(
                "--initialize-at-build-time=io.ktor,kotlin",
                "--initialize-at-build-time=org.slf4j.LoggerFactory",
                "--initialize-at-build-time=org.slf4j.helpers.Reporter",
                "--initialize-at-build-time=kotlinx.io",
                "--initialize-at-build-time=kotlinx.serialization.json.Json",
                "--initialize-at-build-time=kotlinx.serialization.json.JsonImpl",
                "--initialize-at-build-time=kotlinx.serialization.json.ClassDiscriminatorMode",
                "--initialize-at-build-time=kotlinx.serialization.modules.SerializersModuleKt",
                "-H:+InstallExitHandlers",
                "-H:+ReportUnsupportedElementsAtRuntime",
                "-H:+ReportExceptionStackTraces",
            )
        }
    }
}

fallback.set(false) so a build that cannot be native fails instead of quietly producing a JVM image with none of the startup you are doing this for. sharedLibrary.set(false) because the plugin otherwise passes --shared and hands you a .so with a C header beside it. Apply io.ktor.plugin and it sets this for you.

The list of build arguments is Ktor’s, from ktor-samples/graalvm, and this is it whole. It is also, in this order, every build argument the service behind this site uses, bar the container flag that step 4 adds on Linux. Take it whole rather than adding entries one at a time against a theory of what an error means: three attempts here were spent doing that, and each theory was wrong.

Two deliberate differences from the sample. Add ch.qos.logback back with the logging backend if you use one. And where the sample names kotlinx.io.bytestring.ByteString and kotlinx.io.SegmentPool, this names the kotlinx.io package, which covers those two and the further classes Ktor reaches through the filesystem.

2. Name your serializers

install(StreamlordPlugin) {
    codec = KotlinxSignalsCodec(
        serializers = mapOf(typeOf<SearchSignals>() to SearchSignals.serializer()),
    )
}

One entry per class you read signals into. SearchSignals.serializer() is generated by the kotlinx.serialization compiler plugin and resolved where you write it, so the image can see it in the bytecode.

Add requireNamedSerializers = true beside them, and a type you forgot fails on the JVM (in a test, with the name of the class) instead of in the image on the first request that carries it:

KotlinxSignalsCodec(
    serializers = mapOf(typeOf<SearchSignals>() to SearchSignals.serializer()),
    requireNamedSerializers = true,
)

It asks only for what an image cannot resolve by itself: your own classes, and containers holding them. String, Int, List<String> and the rest are answered from a table inside kotlinx.serialization that ships in the bytecode like any other code, so strict lets them through.

It is worth turning on the day you add the second signals class, because the failure it prevents is a silent one. Leave it out and the codec falls back to asking kotlinx.serialization to find the serializer by reading the class, which native-image cannot see. The build is green, the service starts, the health check answers, and the first read fails:

Unresolved class: class SearchSignals (kind = CLASS)

With Jackson the same applies and there is no equivalent shortcut: register your signals classes for reflection, or let Spring’s AOT processing do it. See Spring WebMVC.

3. Resources you read by name

Anything loaded through getResourceAsStream is invisible to the image, because the file name is a string and nothing in the bytecode mentions it. Name it:

{
  "resources": {
    "includes": [
      { "pattern": "\\Qindex.json\\E" }
    ]
  }
}

Put that in src/main/resources/META-INF/native-image/resource-config.json and the plugin picks it up. Skip it and the image ships without the file, starts cleanly, and finds nothing.

4. The container

A native image links glibc and zlib dynamically by default. gcr.io/distroless/base-debian12 carries glibc and not zlib, so link zlib in:

// GraalVM for JDK 21
buildArgs.add("-H:+StaticExecutableWithDynamicLibC")

// GraalVM for JDK 23 and newer, where the same option was renamed
buildArgs.add("--static-nolibc")

Everything the image needs is then linked in except glibc, which the base image provides.

FROM gcr.io/distroless/base-debian12:nonroot
WORKDIR /app
COPY build/native/nativeCompile/my-service /app/my-service
USER nonroot
ENTRYPOINT ["/app/my-service"]

Check the flag name against the documentation for the GraalVM in your toolchain, not the version at /latest. Pass the wrong one and the compiler tells you plainly; pass none and nothing tells you until the container exits with libz.so.1: cannot open shared object file.

5. Check it before you ship it

Every failure above happens after the build succeeds. A native image that is wrong builds, pushes and deploys green, and then fails at startup or on the first request. So start it and call it:

docker build -t my-service .
docker run -d --name check -p 8080:8080 my-service
curl -fsS localhost:8080/health
curl -fsS -H 'Accept: text/event-stream' 'localhost:8080/search?datastar=%7B%22query%22%3A%22x%22%7D'
docker rm -f check

Those four lines belong in your pipeline, before the push. A health check alone is not enough: it answers before a single signal has been read, which is precisely the part that breaks.

What went over the wire

The frames your last search produced, encoded by the same SseEncoder the golden-file tests check. Not a description of them. The frames.

Nothing yet. Search from the top of the page, and what the server sends will appear here.