Efficient Docker images for Spring Boot applications

Using fat JARs within Docker images wastes storage. I’ll demonstrate how to do better when using Spring Boot and Maven.

Introduction

Spring Boot apps are usually packaged as fat JARs that contain all runtime dependencies (besides the JVM). While this is quite convenient as you only need a Java runtime and a single JAR to run the application, you cannot run fat JARs directly if your environment is based on Docker. You have to build a Docker image first.

Doing this in case of Spring Boot is simple. Just take a Java Docker image, copy the fat JAR into it and define a run command to start your application.

Doing so creates a new layer on the base image. This layer contains all your stuff, even those things which don’t change very often. If we build the image multiple times, we create layers with partially the same content every time.

We can optimize storage usage by copying stuff in individual steps. Things that change less frequently should be copied before the things that change often. A good candidate is third-party dependencies. There is usually plenty of them, and they can easily be removed from the JAR (by not adding them ;-).

The idea that normal JARs are a better fit for docker is not new, but I did not find a concise description of my use case. That’s why I will show you how to go thin with a Spring Boot application built by Maven in 4 simple steps. But first I will briefly describe the setup. Skip to Going thin if you are in a hurry. Sources are located here.

The sample app

I created a sample project from https://start.spring.io/ using Maven and Kotlin for the build settings, and Web as a dependency. I added a single controller that returns the text pong when you GET /ping.

To simplify the process of creating the image, I set the Maven artifact name to app.jar and also added the dockerfile-maven-plugin:

<build>
    <finalName>app</finalName>
    ...
    <plugins>
        ...
        <plugin>
          <groupId>com.spotify</groupId>
          <artifactId>dockerfile-maven-plugin</artifactId>
          <version>1.4.3</version>
          <executions>
              <execution>
                  <id>default</id>
                  <goals>
                      <goal>build</goal>
                  </goals>
              </execution>
          </executions>
          <configuration>
              <repository>${project.artifactId}</repository>
          </configuration>
        </plugin>
    </plugins>
</build>

This is the Dockerfile:

FROM openjdk:8u171-jdk-alpine3.7

CMD ["/usr/bin/java", "-jar", "/app.jar"]

COPY target/app.jar /app.jar

The image is named spring-docker-demo and can be built and run by calling the following commands.

./mvnw clean package

# and run it with
docker run -it --rm -p 8080:8080 spring-docker-demo

To check that everything works, I added a system test that starts the container and calls ping.

class DemoApplicationIT {

    @get:Rule
    var appContainer = KGenericContainer("spring-docker-demo:latest")
            .waitingFor(Wait.forListeningPort())
            .withExposedPorts(8080)

    val client: RestTemplate = RestTemplateBuilder().build()

    @Test
    fun `can call ping`() {
        val url = "http://localhost:${appContainer.getMappedPort(8080)}/ping"

        val response = client.getForEntity(url, String::class.java)

        assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
        assertThat(response.body).isEqualTo("pong")
    }
}

// workaround for Kotlin and testcontainers
// https://github.com/testcontainers/testcontainers-java/issues/318
class KGenericContainer(imageName: String) : GenericContainer(imageName)

Run it with ./mvnw verify.

Image layers with a fat JAR

Below find two runs of docker history for two different builds. As you can see, each time a layer of size 19MB got created, while the lower layers stayed the same. Look at the image ids in the first column to see what has changed and what not.

$ docker history spring-docker-demo:latest
  IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
  8cfb75af53d7        3 seconds ago       /bin/sh -c #(nop) COPY file:e3bc0516cf66c218…   19.7MB
  6f47352b8ca7        4 seconds ago       /bin/sh -c #(nop) CMD ["/usr/bin/java" "-ja…    0B
  83621aae5e20        2 days ago          /bin/sh -c set -x  &amp;&amp; apk add --no-cache   o…   97.4MB
...

$ docker history spring-docker-demo:latest
  IMAGE               CREATED              CREATED BY                                      SIZE                COMMENT
  253fe4caaf81        4 seconds ago        /bin/sh -c #(nop) COPY file:cac47cad92ce86f5…   19.7MB
  6f47352b8ca7        About a minute ago   /bin/sh -c #(nop) CMD ["/usr/bin/java" "-ja…    0B
  83621aae5e20        2 days ago           /bin/sh -c set -x  &amp;&amp; apk add --no-cache   o…   97.4MB
...

Going thin

First, we disable Spring Boot repacking by removing the spring-boot-maven-plugin.

<plugins>
   <!---->
       <!--org.springframework.boot-->
       <!--spring-boot-maven-plugin-->
   <!---->
    ...
</plugins>

Then, we copy the dependencies to ./target/dependency from where Docker can access them. Only runtime dependencies (i.e., no test scoped dependencies) are necessary here.

<plugin>
    <artifactId>maven-dependency-plugin</artifactId>
    <executions>
        <execution>
            <id>copy-dependencies</id>
            <goals>
                <goal>copy-dependencies</goal>
            </goals>
            <configuration>
                <includeScope>runtime</includeScope>
            </configuration>
        </execution>
    </executions>
</plugin>

Make the JAR executable by defining the main class and a directory for the third party dependencies.

<plugin>
    <artifactId>maven-jar-plugin</artifactId>
    <configuration>
        <archive>
            <manifest>
                <addClasspath>true</addClasspath>
                <classpathPrefix>lib/</classpathPrefix>
                <mainClass>com.example.demo.DemoApplicationKt</mainClass>
            </manifest>
        </archive>
    </configuration>
</plugin>

The new Dockerfile first copies the dependencies and then our app.jar.

FROM openjdk:8u171-jdk-alpine3.7

CMD ["/usr/bin/java", "-jar", "/app.jar"]

COPY target/dependency /lib

COPY target/app.jar /app.jar

After building and starting the application, the test still runs. Seems like it is done. But let’s make sure by looking at the layers again.

Image layers with a thin JAR

Below we take a look at the history of two different builds again. As you can see, the new layer is only 6 kB in size. The second layer is larger but has been built only once.

$ docker history spring-docker-demo:latest
  IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
  f5367cd66280        3 seconds ago       /bin/sh -c #(nop) COPY file:954dc1d547e75958…   6.04kB
  3189147de421        3 seconds ago       /bin/sh -c #(nop) COPY dir:0101e01ecc142390d…   19.6MB
  6f47352b8ca7        2 minutes ago       /bin/sh -c #(nop) CMD ["/usr/bin/java" "-ja…    0B
  83621aae5e20        2 days ago          /bin/sh -c set -x  &amp;amp;&amp;amp; apk add --no-cache   o…   97.4MB
...

$ docker history spring-docker-demo:latest
  IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
  3bdd09f09486        2 seconds ago       /bin/sh -c #(nop) COPY file:8e5eb913c41a1cf0…   6.04kB
  3189147de421        45 seconds ago      /bin/sh -c #(nop) COPY dir:0101e01ecc142390d…   19.6MB
  6f47352b8ca7        2 minutes ago       /bin/sh -c #(nop) CMD ["/usr/bin/java" "-ja…    0B
  83621aae5e20        2 days ago          /bin/sh -c set -x  &amp;amp;&amp;amp; apk add --no-cache   o…   97.4MB
...

Conclusion

Saving a few megabytes in a world where we talk about terabytes might seem irrelevant, but if you look at real-world examples, the savings will be much bigger. Go and talk to your ops guys and ask them if disk space on their servers is for free ;-). On the other hand, if you don’t care about space or your project is small enough, keep using the fat JAR, as the build is a little bit simpler.