Mastering Minimalism: Crafting the Ultimate Slim Docker Image for JVM Spring Apps

Mastering Minimalism: Crafting the Ultimate Slim Docker Image for JVM Spring Apps

Shrinking JVM App Images to Their Tiniest Forms

Hey folks! It's been a while since I last dropped a blog post – work and family shenanigans have kept me pretty tied up. But, this topic is intriguing enough that it warrants a blog post.

The Why

In my recent work, I've been developing APIs using Kotlin and Spring and came across the need to reduce the size of my Docker images for these applications. The motivations for this endeavor are manifold:

  • Accelerated deployment times, as smaller images are quicker to download, transfer, and launch within the container runtime.

  • Lower network bandwidth usage during transfers between hosts and within container orchestration environments.

  • Improved storage efficiency.

  • By minimizing image size and eliminating unnecessary files, we potentially reduce the exposure to security vulnerabilities, although there's ongoing debate about whether smaller image sizes actually lead to a decreased attack surface.

That's the rundown of why I embarked on this journey to streamline Docker images.

It's extremely important to understand image size is just one among many dimensions of building an artifact. Just because the AOT compilation (which we would need to reduce the docker image size) may result in a smaller size, quick startup times, and less memory footprint, it does not mean higher performance (on the contrary it may even reduce performance in the long run compared to traditional JVM due to absence of JIT optimizations).

The Project Setup

Before starting with the optimizations, we need a base app to do the optimizations on. I build a basic web API app to test these on. It is a small and simple Spring App to fetch random dog facts (using the dogApi) written in Kotlin and running on Java 21. I tried using features that I use in my regular app (like HTTP interfaces).

Application Code:

@SpringBootApplication
class DogFactsApplication{
    @Bean
    fun dogFactClient(): DogFactClient {
        val restClient = RestClient.builder().baseUrl("https://dogapi.dog/api/v2").build()
        val factory = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient)).build()
        return factory.createClient()
    }
}

@RequestMapping("/v1")
@RestController
class DogFactsController(private val dogFactClient: DogFactClient) {
    @GetMapping("/dog-facts")
    fun facts(@RequestParam(value = "limit", defaultValue = "1") limit: Int): List<DogFact> {
        return dogFactClient.dogFacts(mapOf("limit" to limit.toString())).data.map {
            DogFact(it.attributes.body)
        }
    }
}

data class DogFact(
    val fact: String
)

interface DogFactClient {
    @GetExchange("/facts")
    fun dogFacts(@RequestParam map: Map<String, String> = mapOf("limit" to "1")): DogFactJsonApiResponse
}

data class DogFactJsonApiResponse(
    val data: List<Data>
){
    data class Data(
        val id: String,
        val type: String,
        val attributes: Attr
    ){
        data class Attr(
            val body: String
        )
    }
}

fun main(args: Array<String>) {
    runApplication<DogFactsApplication>(*args)
}

Gradle build File:

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "3.2.4"
    id("io.spring.dependency-management") version "1.1.4"
    kotlin("jvm") version "1.9.23"
    kotlin("plugin.spring") version "1.9.23"
}

group = "org.dripto.spring"
version = "0.0.1-SNAPSHOT"

java {
    sourceCompatibility = JavaVersion.VERSION_21
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs += "-Xjsr305=strict"
        jvmTarget = "21"
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

Now that we have the project. lets create some docker images.

Docker Images

Level 1: the basics

First, we need to create to create a basic docker image that works. This will give us a base to compare everything else with. For this reason, we are creating a multistage Dockerfile with eclipse-temurin as Base Image.

FROM eclipse-temurin:21 as builder
WORKDIR /app
COPY . .

RUN ./gradlew build

FROM eclipse-temurin:21
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

This is short and simple. also works great. If we run docker images , however, we can see that the resultant image takes 709MB of storage space. The startup time for the docker container is at 1.695 sec. we will use this as a base to compare others.

LevelSize(MB)Startup Time (sec)
1: Vanilla7091.695

Level 2: Use Alpine JRE image

Although we need the JDK to build the app, we really don't need the entire temurin image to run it. So, we can trim the container image to a smaller JRE which would only contain the Runtime. On top of that, we can opt to use a slimmer alpine based image to even reduce the size further.

FROM eclipse-temurin:21 as builder
WORKDIR /app
COPY . .

RUN ./gradlew build

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

The image size was reduced to 325MB (more than 50% size reduction!), although the startup time increased slightly. This is promising.

LevelSize(MB)Startup Time (sec)
2: Alpine JRE3251.805

Level 3: Using Layered JAR

We can optimize it even further by using a layered jar over a fat jar while creating the docker image.

FROM eclipse-temurin:21 as builder
ARG APP_NAME=dog-facts
ARG ARG_VERSION=0.0.1-SNAPSHOT
ARG JAR_FILE=build/libs/${APP_NAME}-${ARG_VERSION}.jar
WORKDIR /app
COPY . .

RUN ./gradlew build
RUN cp ${JAR_FILE} app.jar
RUN java -Djarmode=layertools -jar app.jar extract

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app

COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./

EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

This results in a docker image of same size but slightly improved startup time.

LevelSize(MB)Startup Time (sec)
3. Layered Jar3251.751

Level 4: Distroless Java Image

Distroless Docker images are stripped-down container images that contain only the application and its runtime dependencies. They exclude package managers, shells, and any other unnecessary files typically found in a standard Linux distribution. This minimalist approach reduces the attack surface, minimizes the image size, and improves security and performance.

FROM eclipse-temurin:21 as builder
ARG APP_NAME=dog-facts
ARG ARG_VERSION=0.0.1-SNAPSHOT
ARG JAR_FILE=build/libs/${APP_NAME}-${ARG_VERSION}.jar
WORKDIR /app
COPY . .

RUN ./gradlew build
RUN cp ${JAR_FILE} app.jar
RUN java -Djarmode=layertools -jar app.jar extract

FROM gcr.io/distroless/java21-debian12
WORKDIR /app

COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./

EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Using a distroless Java base image didn't help much because it still contains the JVM runtime. There's not a lot we can do to reduce the size unless we take a radical new approach.

LevelSize(MB)Startup Time (sec)
4: Distroless3201.591

Level 5: GraalVM Native Image with Buildpacks

GraalVM Native Image is a technology facilitating ahead-of-time (AOT) compilation of Java codebases into self-contained native executables. These executables encapsulate the application logic, dependent libraries, and a subset of the GraalVM's runtime, eliminating the need for a conventional JVM at runtime. This methodology significantly enhances startup time, reduces runtime memory footprint, and optimizes resource utilization, catering to the stringent demands of microservices architectures and serverless computing paradigms.

We would use the GraalVM CE to compile our app to a native executable and package it in a docker executable. For this, we would leverage the Spring support for native docker image building with buildpack.

At first, we would need to extend the gradle build file to include GraalVM native building plugin.

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "3.2.4"
    id("io.spring.dependency-management") version "1.1.4"
    // The GraalVM native build plugin
    id("org.graalvm.buildtools.native") version "0.9.28"
    kotlin("jvm") version "1.9.23"
    kotlin("plugin.spring") version "1.9.23"
}

group = "org.dripto.spring"
version = "0.0.1-SNAPSHOT"

java {
    sourceCompatibility = JavaVersion.VERSION_21
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs += "-Xjsr305=strict"
        jvmTarget = "21"
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

graalvmNative {
    toolchainDetection.set(true)
    binaries.all {
        javaLauncher.set(javaToolchains.launcherFor {
            languageVersion.set(JavaLanguageVersion.of(21))
        })
    }
}

Since we are using spring in built buildpack support we don't need to worry about our Dockerfile. Let's execute our build process by, ./gradlew bootBuildImage .

It will take a little longer to build the image. In the end, you will be left with an image that is sufficiently leaner(251 MB) and faster(0.05 sec) than the previous ones. This is a significant improvement but can we do better?

LevelSize(MB)Startup Time (sec)
5: GraalVM Native Image(BuildPack)2510.05

Level 6: GraalVM Native Image with distroless

Now we are reaching the deep end. Although what we have is good enough, we can still strip down the runtime base image to something more barebones like a distroless. We can do this safely because the native image we have compiled contains almost everything necessary to run it.

That would also mean that we have to start tinkering with the Dockerfile again.In this example, we will use the static-debian12 image from distroless. This is a very lightweight image created to be used as a generic Base.

💡
IMPORTANT: since the distroless image uses musl-libc, we also have to make sure that we build our base image on musl-libc based platform. For this reason, I will change our build image from ghcr.io/graalvm/native-image-community:21 to ghcr.io/graalvm/native-image-community:21-muslib
FROM ghcr.io/graalvm/native-image-community:21-muslib as build
WORKDIR /app
COPY . .
RUN microdnf install -y findutils
RUN ./gradlew nativeCompile

FROM gcr.io/distroless/static-debian12
COPY --from=build /app/build/native/nativeCompile/dog-facts /app
ENTRYPOINT ["/app"]

The docker image size now is 133MB which again is a significant reduction. The startup time remained virtually similar.

LevelSize(MB)Startup Time (sec)
6: GraalVM Native Image (Distroless)1330.052

This is great and all. But can we do even better?

Level 7: Compressed Native Image with UPX

Native executable compression with UPX (Ultimate Packer for eXecutables) involves reducing the size of compiled binary files without altering their functionality or performance. UPX achieves this by compressing the executables and then decompressing them in memory at runtime. This process can significantly decrease the disk space and bandwidth needed to distribute and deploy applications, making UPX a popular choice for optimizing software distribution and deployment, especially in environments where resources are limited. The decompression at the runtime is generally very insignificant compared to the space it saves.

This is the final level (as far as I have achieved). At this level, we would run the generated executable through UPX to reduce the filesize even further.

FROM ghcr.io/graalvm/native-image-community:21-muslib as build
WORKDIR /app
COPY . .
RUN microdnf install -y wget tar xz findutils
RUN ./gradlew nativeCompile
RUN wget -O upx.tar.xz https://github.com/upx/upx/releases/download/v4.2.2/upx-4.2.2-amd64_linux.tar.xz
RUN tar xf ./upx.tar.xz
RUN ./upx-4.2.2-amd64_linux/upx --best --backup ./build/native/nativeCompile/dog-facts

FROM gcr.io/distroless/static-debian12
COPY --from=build /app/build/native/nativeCompile/dog-facts /app
ENTRYPOINT ["/app"]

It will take some to build, but lo and behold, now the image size is a minuscule 60.8 MB with startup times of 0.6 seconds.

LevelSize(MB)Startup Time (sec)
7: GraalVM Native Image with UPX compression60.80.06

Putting it altogether

LevelSize(MB)Startup Time (sec)
1: Vanilla7091.695
2: Alpine JRE3251.805
3. Layered Jar3251.751
4: Distroless3201.591
5: GraalVM Native Image(BuildPack)2510.05
6: GraalVM Native Image (Distroless)1330.052
7: GraalVM Native Image with UPX compression60.80.06

Although I haven't yet found any way to improve it even further, I am quite satisfied with the result that I got. We managed to reduce the initial level 1 image by ~90.4% without losing any functionality. Along the way, we also improved the startup time and memory footprint.

Did you find this article valuable?

Support Driptaroop Das by becoming a sponsor. Any amount is appreciated!