Mastering Minimalism: Crafting the Ultimate Slim Docker Image for JVM Spring Apps
Shrinking JVM App Images to Their Tiniest Forms
Table of contents
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.
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.
Level | Size(MB) | Startup Time (sec) |
1: Vanilla | 709 | 1.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.
Level | Size(MB) | Startup Time (sec) |
2: Alpine JRE | 325 | 1.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.
Level | Size(MB) | Startup Time (sec) |
3. Layered Jar | 325 | 1.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.
Level | Size(MB) | Startup Time (sec) |
4: Distroless | 320 | 1.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?
Level | Size(MB) | Startup Time (sec) |
5: GraalVM Native Image(BuildPack) | 251 | 0.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.
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.
Level | Size(MB) | Startup Time (sec) |
6: GraalVM Native Image (Distroless) | 133 | 0.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.
Level | Size(MB) | Startup Time (sec) |
7: GraalVM Native Image with UPX compression | 60.8 | 0.06 |
Putting it altogether
Level | Size(MB) | Startup Time (sec) |
1: Vanilla | 709 | 1.695 |
2: Alpine JRE | 325 | 1.805 |
3. Layered Jar | 325 | 1.751 |
4: Distroless | 320 | 1.591 |
5: GraalVM Native Image(BuildPack) | 251 | 0.05 |
6: GraalVM Native Image (Distroless) | 133 | 0.052 |
7: GraalVM Native Image with UPX compression | 60.8 | 0.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.