DEV Community

Cover image for Using layered docker images over fat-jar docker images in spring boot application
Driptaroop Das
Driptaroop Das

Posted on • Originally published at blog.dripto.xyz on

Using layered docker images over fat-jar docker images in spring boot application

TL;DR

To achieve more efficient docker image building and faster startup times, instead of doing this,

FROM eclipse-temurin:17-jdk
ARG ARG_VERSION
ARG APP_NAME=test-app

EXPOSE 8080

WORKDIR app
ARG JAR_FILE=build/libs/${APP_NAME}-${ARG_VERSION}.jar

ADD ${JAR_FILE} app.jar

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

Enter fullscreen mode Exit fullscreen mode

Do this,

FROM eclipse-temurin:17-jdk as builder
ARG ARG_VERSION
ARG APP_NAME=test-app

ARG JAR_FILE=build/libs/${APP_NAME}-${ARG_VERSION}.jar
WORKDIR app

COPY ${JAR_FILE} app.jar
RUN java -Djarmode=layertools -jar app.jar extract

FROM eclipse-temurin:17-jdk
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.JarLauncher"]

Enter fullscreen mode Exit fullscreen mode

The problem

Even though considered archaic by some, using Dockerfile is still one of the more flexible ways to create a docker image (and contrary to popular belief, it doesn't always require a docker daemon to be present, i.e. check out kaniko). In our organization, it is very common to build docker images for Spring-boot applications via a Dockerfile for deployment purposes.

One of the main issues is that over time, the use of a traditional Dockerfiles can consume disk space exponentially.

To illustrate this, let's take an example of a typical build of a Spring Boot repository. This build creates what is known as a fat-jar, which is a JAR file that contains not only the Java program but also embeds its dependencies. Since it contains a lot of dependencies, it is not uncommon for a fat-jar of a fairly complex project to be around 100MB in size.

Now, consider an organization that has around 50 microservices. With each build of these microservices, the organization will be generating around 5GB of data (100MB x 50). If these services are built around 60 times a month (twice a day on average, though it should be much more if done in a CI pipeline) and each build creates and deploys its own docker image, this will take up around 300GB of storage at least per month. As you can see, this can quickly become an issue as the number of services and builds increases.

What can be done?

As we've seen, the use of fat jar images can lead to a significant increase in disk space usage over time. However, it's worth noting that even though the images are built multiple times, only a small fraction of it actually changes per build. The dependencies largely stay the same, and only the application code changes. In fact, the application code that changes is usually quite small in comparison to the entire image, often being less than 10MB. By keeping this information in mind, we can leverage Docker image layers to our advantage. By using layers, we can create more efficient and secure images that are faster to build and deploy.

Understanding Docker Images and Layers

A Docker image is a collection of layers. Each layer is an immutable TAR archive with a hash code generated from the file. When building a Docker image, each command that adds files will result in a layer being created. These layers can be cached and reused in future builds, making the process more efficient.

When using a fat-jar image, the fat-jar is added directly to the Dockerfile. This creates a Docker image with a single layer of application and dependencies. As a result, every single change to the fat jar (even for a single file change) will create a new layer and thus adding to the issue of disk space consumption as described previously. By leveraging layers, we can optimize the space usage as well as making the process of building and deploying the images faster.

Spring Boot and Layered Jars

Spring Boot version 2.3.0 and above offers two new features to improve the generation of Docker images:

  1. Buildpack support : This feature provides the Java runtime for the application, allowing for the automatic building of the Docker image without the need for a Dockerfile. However, this feature is out of scope for this blog post.

  2. Layered jars : This feature helps to optimize the Docker layer generation process. If the Spring Boot jar is created using the spring-boot-maven-plugin or spring-boot-gradle-plugin, the jar file comes pre-created with 4 layers. The BOOT-INF/layers.idx file records the different layers and can be checked by extracting the jar. A sample BOOT-INF/layers.idx file might look like this:

 - "dependencies":
   - "BOOT-INF/lib/"
 - "spring-boot-loader":
   - "org/"
 - "snapshot-dependencies":
 - "application":
   - "BOOT-INF/classes/"
   - "BOOT-INF/classpath.idx"
   - "BOOT-INF/layers.idx"
   - "META-INF/"
Enter fullscreen mode Exit fullscreen mode

The layers can also be inspected by using the command:

 java -Djarmode=layertools -jar target/<jar_name>.jar list
Enter fullscreen mode Exit fullscreen mode

This will provide a simplistic view of the content of the layers.idx file.

 dependencies
 spring-boot-loader
 snapshot-dependencies
 application
Enter fullscreen mode Exit fullscreen mode

We can also extract the layers into directories:

 java -Djarmode=layertools -jar target/<jar_name>.jar extract
Enter fullscreen mode Exit fullscreen mode

the application changes the most often. It hosts the actual code for the application. By storing each layer as an individual Docker image layer, we can optimize the space usage and speed up the build and deployment process. Only the application layer will be changed with each build, while the other layers can be cached and reused from previous builds.

So, how do we do this?

We use multistage Docker builds.

Multistage Dockerfiles

We use multistage dockerfiles to create layered docker images for our Spring Boot applications.

  • First, let's add the fat jar file to the base image:
 FROM eclipse-temurin:17-jdk as builder
 ARG ARG_VERSION
 ARG APP_NAME=test-app

 ARG JAR_FILE=build/libs/${APP_NAME}-${ARG_VERSION}.jar
 WORKDIR app

 COPY ${JAR_FILE} app.jar
Enter fullscreen mode Exit fullscreen mode
  • Next, we extract the layers of the artifact using the following command:
 RUN java -Djarmode=layertools -jar app.jar extract
Enter fullscreen mode Exit fullscreen mode
  • Then, we copy the layers from the builder image to the actual image:
 FROM eclipse-temurin:17-jdk
 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/ ./
Enter fullscreen mode Exit fullscreen mode
  • Finally, we start the image using org.springframework.boot.loader.JarLauncher and expose the necessary ports:
 EXPOSE 8080
 ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
Enter fullscreen mode Exit fullscreen mode

Final Dockerfile:

FROM eclipse-temurin:17-jdk as builder
ARG ARG_VERSION
ARG APP_NAME=test-app

ARG JAR_FILE=build/libs/${APP_NAME}-${ARG_VERSION}.jar
WORKDIR app

COPY ${JAR_FILE} app.jar
RUN java -Djarmode=layertools -jar app.jar extract

FROM eclipse-temurin:17-jdk
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.JarLauncher"]

Enter fullscreen mode Exit fullscreen mode

Additional Advantage:

Since the jar is already extracted in the docker image, the startup time is improved. On average, I can see a 1 - 1.5 sec improvement in the startup time across all my spring boot application.

Conclusion:

In conclusion, using layered docker images can greatly reduce the storage space consumed by your Spring Boot applications and improve the startup time of your services. By using multistage Dockerfiles, we can extract the layers of our fat jar and store them in individual image layers. This way, only the application layer will be changed per build and the other layers can be cached and reused from the previous builds. This not only saves storage space, but also improves the startup time of your services, providing an additional advantage to your organization.

Further Read:

  1. https://www.baeldung.com/docker-layers-spring-boot

  2. https://springframework.guru/why-you-should-be-using-spring-boot-docker-layers/

  3. https://www.youtube.com/watch?v=hAHXp_jQWVo

Latest comments (3)

Collapse
 
mrazavi profile image
Mahdi Razavi

Running docker file has an error:

Step 6/15 : COPY ${JAR_FILE} app.jar
Error response from daemon: COPY failed: file not found in build context or excluded by .dockerignore: stat build/libs/test-app-.jar: file does not exist
Failed to deploy '<unknown> Dockerfile: src/main/docker/Dockerfile': Can't retrieve image ID from build stream

Collapse
 
dripto profile image
Driptaroop Das

you need to also supply the jar version as a build argument. For example, assuming you're using Gradle, the build command should something like this,
docker buildx build -t test-app:0.01 --build-arg ARG_VERSION=0.01 .

Collapse
 
naucode profile image
Al - Naucode

Great article, you got my follow, keep writing!