With so many new tools to help build Docker images, it’s confusing to know which to choose. And what’s wrong with just using building directly with Docker, without involving Jib or the Spring Boot plugin? In this article you’ll discover why using a Docker build tool is a good idea, which one is best for your situation, and how to set it up to build your Spring Boot application.

1. Problem building Docker images

If you’ve ever written a Dockerfile for an application, you’ll know it can be tricky to get things running properly. You’ve got to pick the right base image, copy your application files from the build context, and remember how to use the CMD or ENTRYPOINT Docker instructions to startup your application.

And that doesn’t even consider:

  • security - are you running your application as the ROOT user or did you follow best practice and create a less privileged user?
  • maintenance - whose going to remember to update the base image when new versions get released with essential vulnerability patches?
  • performance - every time your application jar file changes, your Docker image has to be recreated, eating up more storage and wasting time. The bigger the jar file, the bigger the problem.

There’s a lot to think about here! Even the most conscientious developer will have a hard time keeping up with it all. But there’s more.

Continuous integration

Ideally your application’s Docker image will be generated in a continuous integration (CI) environment as opposed to on a developer’s laptop. This environment should be controlled and audited so you’ve got full visibility on what’s happening. That’s important for images that will eventually be deployed to production.

CI servers and Docker are sometimes incompatible. Remember that to build an image using Docker, whatever is issuing the commands needs access to the Docker Daemon.

When a CI server is itself running inside a Docker container, it may not have the required privileges to access the Docker Daemon. For example, if you run Jenkins inside AWS’s serverless Fargate service, there’s no access to the Docker daemon running on the host.

Obviously this poses a problem, but as you’ll learn shortly we have some viable workarounds.

2. Faster Docker image builds with layering

Just like the taste of an aged whisky, Docker images consist of multiple layers. In fact, Dockerfile instructions like COPY add a new layer which is cached locally on your hard drive. This helps enormously with build performance, since when you change a line of your Dockerfile, only that and following layers need to be regenerated.

Consider this Dockerfile:

FROM openjdk:17
COPY *.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

It uses the openjdk base image then simply copies a jar file from the Docker context on top of it. This is how the image built from the Dockerfile looks in terms of its layers.

You can also see the layers being created during the build process.

$ docker build .
[+] Building 3.3s (7/7) FINISHED
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 130B
 => [internal] load .dockerignore
 => => transferring context: 2B
 => [internal] load metadata for docker.io/library/openjdk:17
 => [internal] load build context
 => => transferring context: 42.08MB
 => CACHED [1/2] FROM docker.io/library/openjdk:17@sha256:0e5ae79482731eef1526afb4e3a42e62b38142681c5752a944ff4236da979648
 => [2/2] COPY  app.jar
 => exporting to image
 => => exporting layers
 => => writing image sha256:d656386091645629185e0cb3cde31b61f660f1f12ba82fad7759f721c9c4d26b

The next time the image is built, the output indicates that the previous cached layer has been used instead of copying the jar file again.

 => [1/2] FROM docker.io/library/openjdk:17@sha256:0e5ae79482731eef1526afb4e3a42e62b38142681c5752a944ff4236da979648                                          0.0s
 => CACHED [2/2] COPY  app.jar                                                                                                                               0.0s

All this is designed to make building images as fast as possible. So what’s the problem then?

Well in the example above you can see that the jar file itself constitutes an entire layer. That’s fine for small jar files, but some applications produce jar files over 100MB in size!

This creates a couple of problems:

  1. Every time the jar file changes, like when you change your application code or update a dependency, the entire layer has to be created. Each time that happens that’s another 100MB of disk space used up.
  2. There are lots of files inside the jar that change infrequently. Copying all this data around slows down the build process.

It would be better if the jar file contents itself consisted of multiple layers. That way, when one layer changed, not all the other layers would have to be rebuilt. Well, fortunately someone at Spring Boot HQ already through of that.

3. Layering in Spring Boot jar files

Since Spring Boot 2.3 the generated fat jar file includes details of the different layers. A layers.idx file is included, defining the layers and their associated files and directories.

Here’s an example layers.idx file.

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

So we have 4 different layers: dependencies, the Spring Boot loader, snapshot dependencies, and the actual application code. They are ordered from those that change least to most frequently. Hence it’s been assumed that your application code is the layer that changes most frequently. But all of this can be customised if needed.

So what? How does this jar file layering help?

Well, Spring also supports an option to extract a fat jar file based on its layers.

java -Djarmode=layertools -jar my-app.jar extract

ℹ️ Fat jars are simply jar files containing all the application’s dependencies. That means they can be run standalone, as opposed to having to manually add all the dependent libraries to the Java classpath. The Gradle Spring Boot plugin automatically creates a fat jar when executing the bootJar task.

Given the above layers.idx we’d end up with 3 directories: dependencies, spring-boot-loader, and application (the snapshot dependencies layer in this case is empty).

These directories would be quite useful to extract and add to a Docker image, don’t you think? If that were done, we’d end up with multiple layers in our image, and changes to our application would result in streamlined image rebuilds.

Let’s jump into how Jib and Spring Boot can manage that for us.

4. Building Docker images with Jib

Jib is a tool from Google, specifically designed to generate Docker images from a jar file. Importantly, it creates a layered Docker image for you, saving on both time and disk storage. Plugins are available to run Jib within the Gradle and Maven Java build tools.

Let’s look at an example using Gradle, by taking a Spring Boot application and getting it running inside a Docker image built by Jib.

Configuring Jib

Apply the Jib Gradle plugin in a project’s build.gradle file like so.

plugins {
  id "com.google.cloud.tools.jib" version "3.1.4"
  // other plugins
}

Apply the following plugin configuration.

jib {
    from {
        image = 'azul/zulu-openjdk:17-jre'
    }
    to {
        image = 'spring-boot-api-example-jib'
    }
}
  • since the project uses Java 17, we have to specify a base image other than the default Java 11. Based on this Jib pull request, I expect future Jib releases to set the correct image automatically.
  • the jib.to.image is mandatory. You could set it to project.name but we’ll pick a unique name to distinguish it from other images created in this article.

Running Jib with Docker daemon

Given this configuration, we can execute the jibDockerBuild task, which builds the image using the local Docker daemon (more on why and how not to use Docker later).

$ ./gradlew jibDockerBuild

> Task :jibDockerBuild

... (truncated for clarity)

Built image to Docker daemon as spring-boot-api-example-jib
Executing tasks:
[==============================] 100.0% complete

BUILD SUCCESSFUL in 14s
2 actionable tasks: 1 executed, 1 up-to-date

We can see the image is available when we run the docker images command.

$ docker images | grep jib
spring-boot-api-example-jib latest 3fb39c320453 51 years ago 341MB

Awesome! Let’s go and ahead and run the image as a container to make sure it’s working.

$ docker run --rm -p 8080:8080 spring-boot-api-example-jib:latest
11:59:37.725 [Thread-0] DEBUG org.springframework.boot.devtools.restart.classloader.RestartClassLoader - Created RestartClassLoader org.springframework.boot.devto
ols.restart.classloader.RestartClassLoader@54df8cad

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.6.0)

2021-11-29 11:59:38.039  INFO 1 --- [  restartedMain] com.tomgregory.ThemeParkApplication      : Starting ThemeParkApplication using Java 17.0.1 on 4e441393e061 w
ith PID 1 (/app/classes started by root in /)
... (truncated for clarity)
2021-11-29 11:59:41.536  INFO 1 --- [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2021-11-29 11:59:41.554  INFO 1 --- [  restartedMain] com.tomgregory.ThemeParkApplication      : Started ThemeParkApplication in 3.818 seconds (JVM running for 4.
225)

This output shows the application has started up within the container. Cool!

ℹ️ Note the very old timestamps (51 years ago) in the Docker output above represent the Unix epoch. This is used for the container image creation timestamp to make Jib builds reproducible. In other words, it means that the same inputs will always generate the same image with the same image digest/hash.

Running Jib without Docker daemon

One of Jib’s advantages is that it doesn’t require Docker to build Docker images.

How is that possible? Well, the clever folk at Google have replicated the Docker image build mechanism, consisting of generating the correct tarballs and configuration files.

The distinct advantage of not using Docker is that it becomes possible to build images in environments where it’s not possible to install Docker, such as continuous integration (CI) servers as mentioned earlier.

Jib let’s you build the image in 2 ways when not using Docker:

  1. create a local tar file to later load into Docker with the docker load command
  2. deploy to a remote Docker registry, such as GCR, ECR, or Docker Hub

Since the 2nd option is most common in a CI environment, let’s explore how it works via the jib Gradle task, to push the image to AWS’s Elastic Container Registry (ECR).

First though, we need to add some configuration to setup the connection with the remote Docker registry.

jib {
    from {
        image = 'azul/zulu-openjdk:17-jre'
    }
    to {
        image = '299404798587.dkr.ecr.eu-west-1.amazonaws.com/spring-boot-api-example-jib'
        auth {
            username = property('tomsRepoUsername')
            password = property('tomsRepoPassword')
        }
    }
}
  • the jib.to.image property now includes a prefix with my ECR registry details
  • credentials are set based on Gradle properties located in ~/.gradle/gradle.properties. You can read about the other authentication methods for different registry types.

Now we’re ready to run the Gradle jib task to build and push the image.

$ ./gradlew jib

> Task :jib

... (truncated for clarity)

Built and pushed image as 299404798587.dkr.ecr.eu-west-1.amazonaws.com/spring-boot-api-example-jib
Executing tasks:
[============================  ] 91.7% complete
> launching layer pushers


BUILD SUCCESSFUL in 4s
2 actionable tasks: 1 executed, 1 up-to-date

In my case, from the AWS console I can see that the image is now available. 👍

Layering in Jib built images

Jib doesn’t give much output during image build to prove the layering is working, but we can check it using the docker history command. First though, we need to docker pull the image so it’s available to our Docker daemon.

$ docker pull 299404798587.dkr.ecr.eu-west-1.amazonaws.com/spring-boot-api-example-jib@sha256:a63e3b628e6ff6a3776228fa9934b987f671fcb1dd68f402bb67ce4c25fad9d8
299404798587.dkr.ecr.eu-west-1.amazonaws.com/spring-boot-api-example-jib@sha256:a63e3b628e6ff6a3776228fa9934b987f671fcb1dd68f402bb67ce4c25fad9d8: Pulling from spring-boot-api-example-jib
Digest: sha256:a63e3b628e6ff6a3776228fa9934b987f671fcb1dd68f402bb67ce4c25fad9d8
Status: Downloaded newer image for 299404798587.dkr.ecr.eu-west-1.amazonaws.com/spring-boot-api-example-jib@sha256:a63e3b628e6ff6a3776228fa9934b987f671fcb1dd68f402bb67ce4c25fad9d8
299404798587.dkr.ecr.eu-west-1.amazonaws.com/spring-boot-api-example-jib@sha256:a63e3b628e6ff6a3776228fa9934b987f671fcb1dd68f402bb67ce4c25fad9d8

$ docker history 299404798587.dkr.ecr.eu-west-1.amazonaws.com/spring-boot-api-example-jib@sha256:a63e3b628e6ff6a3776228fa9934b987f671fcb1dd68f402bb67ce4c25fad9d8
IMAGE          CREATED        CREATED BY                                      SIZE      COMMENT
3fb39c320453   51 years ago   jib-gradle-plugin:3.1.4                         2.67kB    jvm arg files
<missing>      51 years ago   jib-gradle-plugin:3.1.4                         8.3kB     classes
<missing>      51 years ago   jib-gradle-plugin:3.1.4                         41.9MB    dependencies
<missing>      2 days ago     /bin/sh -c #(nop)  ENV JAVA_HOME=/usr/lib/jv…   0B
<missing>      2 days ago     |1 ZULU_REPO_VER=1.0.0-2 /bin/sh -c apt-get …   227MB
<missing>      2 days ago     /bin/sh -c #(nop)  ARG ZULU_REPO_VER=1.0.0-2    0B
<missing>      2 days ago     /bin/sh -c #(nop)  ENV LANG=en_US.UTF-8 LANG…   0B
<missing>      6 weeks ago    /bin/sh -c #(nop)  CMD ["bash"]                 0B
<missing>      6 weeks ago    /bin/sh -c #(nop) ADD file:5d68d27cc15a80653…   72.8MB

We can clearly see above we have classes and dependencies layers, highlighted above. However, this doesn’t respect what’s been defined in layers.idx, but instead Jib uses its own approach to layering. You can read more in the How are Jib applications layered? documentation.

5. Building Docker images with Spring Boot plugin

The second approach to building Docker images uses the Spring Boot Gradle plugin. That’s the same plugin developers use to generate the fat jar using the bootJar task and run the application using the bootRun task.

plugins {
    id 'org.springframework.boot' version '2.6.1'
    // other plugins
}

The plugin uses a technology called Cloud Native Buildpacks (CNB), which is an abstraction on top of the Dockerfile providing a best-practice approach to building Docker images. From my testing the integration with this technology is seamless, automatically supplying the correct Java 17 JRE in the image based on my Gradle configuration.

Note that to use CNB though, we do need access to a local Docker Daemon, which might be problematic in some CI environments.

Running the Spring Boot plugin

No additional configuration is required to build the image, as the plugin by default uses the project’s name and version for the image name. So we just need to run the bootBuildImage Gradle task.

$ ./gradlew bootBuildImage

> Task :bootBuildImage
Building image 'docker.io/library/spring-boot-api-example:0.1.0-SNAPSHOT'

 > Pulling builder image 'docker.io/paketobuildpacks/builder:base' ..................................................
 > Pulled builder image 'paketobuildpacks/builder@sha256:703831733222a62b7bd366cbf23768675ea1a8162b91a1fbaaa3969dc2f3288f'
 > Pulling run image 'docker.io/paketobuildpacks/run:base-cnb' ..................................................
 > Pulled run image 'paketobuildpacks/run@sha256:53e900797c8da768c2a254aca3ec1f3f4b5afd131d62787323e4f0374a6e7ad0'
 > Executing lifecycle version v0.13.0
 > Using build cache volume 'pack-cache-c654c5f385f7.build'

... (truncated for clarity)

Successfully built image 'docker.io/library/spring-boot-api-example:0.1.0-SNAPSHOT'

BUILD SUCCESSFUL in 11s
4 actionable tasks: 1 executed, 3 up-to-date

Let’s double check the image using docker images.

$ docker images | grep spring-boot-api-example
spring-boot-api-example 0.1.0-SNAPSHOT 8399151efe53 41 years ago 299MB

Once again we can successfully run a container from that image.

$ docker run --rm -p 8080:8080 spring-boot-api-example:0.1.0-SNAPSHOT
Setting Active Processor Count to 12
Calculating JVM memory based on 24560328K available memory
Calculated JVM Memory Configuration: -XX:MaxDirectMemorySize=10M -Xmx23943204K -XX:MaxMetaspaceSize=105123K -XX:ReservedCodeCacheSize=240M -Xss1M (Total Memory: 2
4560328K, Thread Count: 250, Loaded Class Count: 16146, Headroom: 0%)
Enabling Java Native Memory Tracking
Adding 128 container CA certificates to JVM truststore
Spring Cloud Bindings Enabled
Picked up JAVA_TOOL_OPTIONS: -Djava.security.properties=/layers/paketo-buildpacks_bellsoft-liberica/java-security-properties/java-security.properties -XX:+ExitOnO
utOfMemoryError -XX:ActiveProcessorCount=12 -XX:MaxDirectMemorySize=10M -Xmx23943204K -XX:MaxMetaspaceSize=105123K -XX:ReservedCodeCacheSize=240M -Xss1M -XX:+Unlo
ckDiagnosticVMOptions -XX:NativeMemoryTracking=summary -XX:+PrintNMTStatistics -Dorg.springframework.cloud.bindings.boot.enable=true

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.6.0)

2021-11-29 14:59:34.290  INFO 1 --- [           main] com.tomgregory.ThemeParkApplication      : Starting ThemeParkApplication using Java 17.0.1 on ed40440e001c w
ith PID 1 (/workspace/BOOT-INF/classes started by cnb in /workspace)
... (truncated for clarity)
2021-11-29 14:59:37.427  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2021-11-29 14:59:37.443  INFO 1 --- [           main] com.tomgregory.ThemeParkApplication      : Started ThemeParkApplication in 3.459 seconds (JVM running for 3.
799)

Publishing images with the Spring Boot Gradle plugin

The plugin can also push an image to your Docker registry of choice. Let’s set it up to publish to my private AWS ECR registry again.

bootBuildImage {
    imageName = "299404798587.dkr.ecr.eu-west-1.amazonaws.com/spring-boot-api-example-spring-boot-plugin"
    publish = true
    docker {
        publishRegistry {
            username = project.property('tomsRepoUsername')
            password = project.property('tomsRepoPassword')
        }
    }
}
  • set the imageName to include details of the registry to which to publish
  • set the publish option to true
  • provide a username and password to authenticate with the registry, in my case AWS ECR. Once again these properties can be set in ~/.gradle/gradle.properties.

With this in place we run the bootBuildImage task again, passing the --publishImage option.

$ ./gradlew bootBuildImage --publishImage

... (truncated for clarity)

 > Pushing image '299404798587.dkr.ecr.eu-west-1.amazonaws.com/spring-boot-api-example-spring-boot-plugin:latest' ..................................................
 > Pushed image '299404798587.dkr.ecr.eu-west-1.amazonaws.com/spring-boot-api-example-spring-boot-plugin:latest'

BUILD SUCCESSFUL in 16s
4 actionable tasks: 1 executed, 3 up-to-date

Checking the AWS console verifies that the image has been successfully published. 👌

Layering in Spring Boot plugin built images

This time during the build process we get some helpful output in the console.

$ ./gradlew bootBuildImage
...
    [creator]       Creating slices from layers index
    [creator]         dependencies
    [creator]         spring-boot-loader
    [creator]         snapshot-dependencies
    [creator]         application
...
    [creator]     Reusing 5/5 app layer(s)
...

We can clearly see the layers defined within the layers.idx file. What’s more, we can see the effect of a change to our application.

For example, if I change one of the dependency versions in build.gradle, we get this output.

$ ./gradlew bootBuildImage
...
    [creator]       Creating slices from layers index
    [creator]         dependencies
    [creator]         spring-boot-loader
    [creator]         snapshot-dependencies
    [creator]         application
...
    [creator]     Reusing 3/5 app layer(s)
    [creator]     Adding 2/5 app layer(s)
...

This reflects changes to the dependencies layer and the application layer (which got recompiled).

However, if I make a change to the application source code, we see this output.

$ ./gradlew bootBuildImage
...
    [creator]       Creating slices from layers index
    [creator]         dependencies
    [creator]         spring-boot-loader
    [creator]         snapshot-dependencies
    [creator]         application
...
    [creator]     Reusing 4/5 app layer(s)
    [creator]     Adding 1/5 app layer(s)
...

Showing that only the application layer gets updated. Nice!

6. Jib vs. Spring Boot Gradle plugin side-by-side comparison

Feature Jib Spring Boot plugin
Builds Docker images ✔️ ✔️
Pushes Docker images ✔️ ✔️
Respects layers.idx file ✔️
Can customise layering ✔️ ✔️
Dockerfile not required ✔️ ✔️
Docker Daemon not required ✔️
Runs container process as non-root user ✔️
Supports custom base image ✔️ ✔️

7. Conclusion

In many cases there’s no longer a need to maintain a Dockerfile alongside a Spring Boot application, thanks to the Jib and Spring Boot plugins. Which plugin you choose depends on your projects requirements, such as the availability of the Docker daemon within your CI environment.

Watch this video demonstrating the ideas from this article.

Stop reading Gradle articles like these

This article helps you fix a specific problem, but it doesn't teach you the Gradle fundamentals you need to really help your team succeed.

Instead, follow a step-by-step process that makes getting started with Gradle easy.

Download this Free Quick-Start Guide to building simple Java projects with Gradle.

  • Learn to create and build Java projects in Gradle.
  • Understand the Gradle fundamentals.