In this article, you will learn how to build slim Docker images for your Java apps using Alpine Linux and the jlink
tool. We will leverage the latest Java 21 base images provided by Eclipse Temurin and BellSoft Liberica. We are going to compare those providers with Alpaquita Linux also delivered by BellSoft. That comparison will also include security scoring based on the number of vulnerabilities. As an example, we will use a simple Spring Boot app that exposes some REST endpoints.
If you are interested in Java in the containerization context you may find some similar articles on my blog. For example, you can read how to speed up Java startup on Kubernetes with CRaC in that post. There is also an article comparing different JDK providers used for running the Java apps by Paketo Buildpacks.
Source Code
If you would like to try it by yourself, you may always take a look at my source code. In order to do that you need to clone my GitHub repository. Then you need to go to the spring-microservice
directory. After that, you should just follow my instructions.
Introduction
I probably don’t need to convince anyone that keeping Docker images slim and light is important. It speeds up the build process and deployment of containers. Decreasing image size and removing unnecessary files eliminate vulnerable components and therefore reduce the risk of security issues. Usually, the first step to reduce the target image size is to choose a small base image. Our choice will not be surprising – Alpine Linux. It is a Linux distribution built around musl libc and BusyBox. The image has only 5 MB.
Also, Java in itself consumes some space inside the image. Fortunately, we can reduce that size by using the jlink
tool. With jlink
we can choose only the modules required by our app, and link them into a runtime image. Our main goal today is to create as small as possible Docker image for our sample Spring Boot app.
Sample Spring Boot App
As I mentioned before, our Java app is not complicated. It uses Spring Boot Web Starter to expose REST endpoints over HTTP. I made some small improvements in the dependencies. Tomcat has been replaced with Undertow to reduce the target JAR file size. I also imported the latest version of the org.yaml:snakeyaml
library to avoid a CVE issue related to the 1.X
release of that project. Of course, I’m using Java 21 for compilation:
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>2.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
</dependencies>
Here’s the implementation of the @RestController
responsible for exposing several endpoints:
@RestController
@RequestMapping("/persons")
public class Api {
protected Logger logger = Logger.getLogger(Api.class.getName());
private List<Person> persons;
public Api() {
persons = new ArrayList<>();
persons.add(new Person(1, "Jan", "Kowalski", 22));
persons.add(new Person(2, "Adam", "Malinowski", 33));
persons.add(new Person(3, "Tomasz", "Janowski", 25));
persons.add(new Person(4, "Alina", "Iksińska", 54));
}
@GetMapping
public List<Person> findAll() {
logger.info("Api.findAll()");
return persons;
}
@GetMapping("/{id}")
public Person findById(@PathVariable("id") Integer id) {
logger.info(String.format("Api.findById(%d)", id));
return persons.stream()
.filter(p -> (p.getId().intValue() == id))
.findAny()
.orElseThrow();
}
}
In the next step, we will prepare and build several Docker images for our Java app and compare them with each other.
Build Alpine Image with BellSoft Liberica OpenJDK
Let’s take a look at the Dockerfile. We are using a feature called multi-stage Docker builds. In the first step, we are the Java runtime for our app (1). We download and unpack the latest LTS version of OpenJDK from BellSoft (2). We need a release targeted for Alpine Linux (with the musl
suffix). Then, we are running the jlink
command to create a custom image with JDK (3). In order to run the app, we need to include at least the following Java modules: java.base
, java.logging
, java.naming
, java.desktop
, jdk.unsupported
(4). You can verify a list of required modules by running the jdeps
command e.g. on your JAR file. The jlink
tool will place our custom JDK runtime in the springboot-runtime
directory (the --output
parameter).
Finally, we can proceed to the main phase of the image build (5). We are placing the optimized version of JDK in the /opt/jdk
path by copying it from the directory created during the previous build phase (6). Then we are just running the app using the java -jar
command.
# (1)
FROM alpine:latest AS build
ENV JAVA_HOME /opt/jdk/jdk-21.0.1
ENV PATH $JAVA_HOME/bin:$PATH
# (2)
ADD https://download.bell-sw.com/java/21.0.1+12/bellsoft-jdk21.0.1+12-linux-x64-musl.tar.gz /opt/jdk/
RUN tar -xzvf /opt/jdk/bellsoft-jdk21.0.1+12-linux-x64-musl.tar.gz -C /opt/jdk/
# (3)
RUN ["jlink", "--compress=2", \
"--module-path", "/opt/jdk/jdk-21.0.1/jmods/", \
# (4)
"--add-modules", "java.base,java.logging,java.naming,java.desktop,jdk.unsupported", \
"--no-header-files", "--no-man-pages", \
"--output", "/springboot-runtime"]
# (5)
FROM alpine:latest
# (6)
COPY --from=build /springboot-runtime /opt/jdk
ENV PATH=$PATH:/opt/jdk/bin
EXPOSE 8080
COPY ../target/spring-microservice-1.0-SNAPSHOT.jar /opt/app/
CMD ["java", "-showversion", "-jar", "/opt/app/spring-microservice-1.0-SNAPSHOT.jar"]
Let’s build the image by executing the following command. We are tagging the image with bellsoft
and preparing it for pushing to the quay.io
registry:
$ docker build -t quay.io/pminkows/spring-microservice:bellsoft .
Here’s the result:
We can examine the image using the dive
tool. If you don’t have any previous experience with dive
CLI you can read more about it here. We need to run the following command to analyze the current image:
$ dive quay.io/pminkows/spring-microservice:bellsoft
Here’s the result. As you see our image has 114MB. Java is consuming 87 MB, the app JAR file 20MB, and Alpine Linux 7.3.MB. You can also take a look at the list of modules and the whole directory structure.
In the end, let’s push our image to the Quay registry. Quay will automatically perform a security scan of the image. We will discuss it later.
$ docker push quay.io/pminkows/spring-microservice:bellsoft
Build Alpine Image with Eclipse Temurin OpenJDK
Are you still not satisfied with the image size? Me too. I expected something below 100MB. Let’s experiment a little bit. I will use almost the same Dockerfile
as before, but instead of BellSoft Liberica, I will download and optimize the Eclipse Temurin OpenJDK for Alpine Linux. Here’s the current Dockerfile
. As you see the only difference is in the JDK URL.
FROM alpine:latest AS build
ENV JAVA_HOME /opt/jdk/jdk-21.0.1+12
ENV PATH $JAVA_HOME/bin:$PATH
ADD https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.1%2B12/OpenJDK21U-jdk_x64_alpine-linux_hotspot_21.0.1_12.tar.gz /opt/jdk/
RUN tar -xzvf /opt/jdk/OpenJDK21U-jdk_x64_alpine-linux_hotspot_21.0.1_12.tar.gz -C /opt/jdk/
RUN ["jlink", "--compress=2", \
"--module-path", "/opt/jdk/jdk-21.0.1+12/jmods/", \
"--add-modules", "java.base,java.logging,java.naming,java.desktop,jdk.unsupported", \
"--no-header-files", "--no-man-pages", \
"--output", "/springboot-runtime"]
FROM alpine:latest
COPY --from=build /springboot-runtime /opt/jdk
ENV PATH=$PATH:/opt/jdk/bin
EXPOSE 8080
COPY ../target/spring-microservice-1.0-SNAPSHOT.jar /opt/app/
CMD ["java", "-showversion", "-jar", "/opt/app/spring-microservice-1.0-SNAPSHOT.jar"]
The same as before, we will build the image. This time we are tagging it with temurin
. We also need to override the default location of the Docker manifest since we use the Dockerfile_temurin:
$ docker build -f Dockerfile_temurin \
-t quay.io/pminkows/spring-microservice:temurin .
Once the image is ready we can proceed to the next steps:
Let’s analyze it with the dive
tool:
$ dive quay.io/pminkows/spring-microservice:temurin
The results look much better. The difference is of course in the JDK space. It took just 64MB instead of 87MB like in Liberica. The total image size is 91MB.
Finally, let’s push the image to the Quay registry for the security score comparison:
$ docker push quay.io/pminkows/spring-microservice:temurin
Build Image with BellSoft Alpaquita
BellSoft Alpaquita is a relatively new solution introduced in 2022. It is advertised as a full-featured operating system optimized for Java. We can use Alpaquita Linux in combination with Liberica JDK Lite. This time we won’t create a custom JDK runtime, but we will get the ready image provided by BellSoft in their registry: bellsoft/liberica-runtime-container:jdk-21-slim-musl
. It is built on top of Alpaquita Linux. Here’s our Dockerfile:
FROM bellsoft/liberica-runtime-container:jdk-21-slim-musl
COPY target/spring-microservice-1.0-SNAPSHOT.jar /opt/app/
EXPOSE 8080
CMD ["java", "-showversion", "-jar", "/opt/app/spring-microservice-1.0-SNAPSHOT.jar"]
Let’s build the image. The current Docker manifest is available in the repository as the Dockerfile_alpaquita
file:
$ docker build -f Dockerfile_alpaquita \
-t quay.io/pminkows/spring-microservice:alpaquita .
Here’s the build result:
Let’s examine our image with dive
once again. The current image has 125MB. Of course, it is more than two previous images, but still not much.
Finally, let’s push the image to the Quay registry for the security score comparison:
$ docker push quay.io/pminkows/spring-microservice:alpaquita
Now, we can switch to the quay.io
. In the repository view, we can compare the results of security scanning for all three images. As you see, there are no detected vulnerabilities for the image tagged with alpaquita
and two issues for another two images.
Paketo Buildpacks for Alpaquita
BellSoft provides a dedicated buildpack based on the Alpaquita image. As you probably know, Spring Boot offers the ability to integrate the build process with Paketo Buildpacks through the spring-boot-maven-plugin
. The plugin configuration in Maven pom.xml
is visible below. We need to the bellsoft/buildpacks.builder:musl
as a builder image. We can also enable jlink
optimization by setting the environment variable BP_JVM_JLINK_ENABLED
to true
. In order to make the build work, I need to decrease the Java version to 17.
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>quay.io/pminkows/spring-microservice:alpaquita-pack</name>
<builder>bellsoft/buildpacks.builder:musl</builder>
<env>
<BP_JVM_VERSION>17</BP_JVM_VERSION>
<BP_JVM_JLINK_ENABLED>true</BP_JVM_JLINK_ENABLED>
<BP_JVM_JLINK_ARGS>--no-man-pages --no-header-files --strip-debug --compress=2 --add-modules java.base,java.logging,java.naming,java.desktop,jdk.unsupported</BP_JVM_JLINK_ARGS>
</env>
</image>
</configuration>
</plugin>
Let’s build the image with the following command:
$ mvn clean spring-boot:build-image
You should have a similar output if everything finishes successfully:
After that, we can examine the image with the dive
CLI. I was able to get an even better image size than for the corresponding build based on the Dockerfile with an alpine
image with BellSoft Liberica OpenJDK (103MB vs 114MB). However, now I was using the JDK 17 instead of JDK 21 as in the previous build.
Finally, let’s push the image to the Quay registry:
$ docker push quay.io/pminkows/spring-microservice:alpaquita-pack
Security Scans of Java Docker Images
We can use a more advanced tool for security scanning than Quay. Personally, I’m using Advanced Cluster Security for Kubernetes. It can be used not only to monitor containers running on the Kubernetes cluster but also to watch particular images in the selected registry. We can add all our previously built images in the “Manage watched images” section.
Here’s the security report for all our Docker Java images. It looks very good. There is only one security issue detected for both images based on alpine
. There are no any CVEs fond for alpaquita-based images.
We can get into the details of every CVE. The issue detected for both images tagged with temurin
and bellsoft
is related to the jackson-databind
Java library used by the Spring Web dependency.
Final Thoughts
As you see we can easily create slim Docker images for the Java apps without any advanced tools. The size of such an image can be even lower than 100MB (including ~20MB JAR file). BellSoft Alpaquita is also a very interesting alternative to Linux Alpine. We can use it with Paketo Buildpacks and take advantage of Spring Boot support for building images with CNB.
The post Slim Docker Images for Java appeared first on Piotr's TechBlog.