I recently wrote a Java application and wanted to run it in a Docker container. Unfortunately, my program needs a module from the Java jdk, which is why I can’t run it in the standard jre. When I run the program with the jdk, everything works but I have much more than I actually need and a 390MB docker container.
That’s why I set out to build my own jre, cut-down exactly to my application needs.
For this I found two Gradle plugins that help me. Jdeps checks the dependencies of an application and can summarise them in a file. This file can be used as input for the Jlink plugin. Jlink builds its own jre from it.
In the following I will present a way to build a custom jre with gradle and the two plugins in a multistage dockerfile and run an application in it.
Configuration in Gradle
First we need to do some configuration in gradle.build file. To use jdeps, the application must be packaged as an executable jar file and all dependencies must be copied in a seperate folder . First we create a task that copies all current dependencies to folder named lib. For the jar, we need to define the main class and set the class path to search for all dependencies in the lib folder.
// copies all the jar dependencies of your app to the lib folder
task copyDependencies(type: Copy) {
from configurations.runtimeClasspath
into "$buildDir/lib"
}
jar {
manifest {
attributes["Main-Class"] = "Main"
attributes["Class-Path"] = configurations.runtimeClasspath
.collect{'lib/'+it.getName()}.join(' ')
}
}
Docker Image
Now we can get to work on the Docker image.
As a first step, we build our jre in a Java jdk. To do this, we run the CopyDependencies task we just created and build the Java application with gradle. Then we let jdeps collect all dependencies from the lib folder.
The output is written to the file jre-deps.info. It is therefore important that no errors or warnings are output, which is set with the -q parameter. The print-module-deps is crucial so that the dependencies are output and saved in the file.
The file is now passed to jlink and a custom-fit jre for the application is built from it. The parameters set in the call also reduce the size. The settings can be read in detail in the plugin documentation linked above.
FROM eclipse-temurin:17-jdk-alpine AS jre-build
COPY . /app
WORKDIR /app
RUN chmod u+x gradlew; ./gradlew copyDependencies; ./gradlew build
# find JDK dependencies dynamically from jar
RUN jdeps \
--ignore-missing-deps \
-q \
--multi-release 17 \
--print-module-deps \
--class-path build/lib/* \
build/libs/app-*.jar > jre-deps.info
RUN jlink \
--compress 2 \
--strip-java-debug-attributes \
--no-header-files \
--no-man-pages \
--output jre \
--add-modules $(cat jre-deps.info)
FROM alpine:3.17.0
WORKDIR /deployment
# copy the custom JRE produced from jlink
COPY --from=jre-build /app/jre jre
# copy the app dependencies
COPY --from=jre-build /app/build/lib/* lib/
# copy the app
COPY --from=jre-build /app/build/libs/app-*.jar app.jar
# run in user context
RUN useradd schneide
RUN chown schneide:schneide .
USER schneide
# run the app on startup
CMD jre/bin/java -jar app.jar
In the second Docker stage, a smaller image is used and only the created jre, the dependencies and the app.jar are copied into it. The app is then executed with the jre.
In my case, I was able to reduce the Docker container size to 110 MB with the alpine image, which is less than a third. Or, using a ubuntu:jammy image, to 182 MB, which is also more than half.