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:
- 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.
- 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 toproject.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:
- create a local tar file to later load into Docker with the
docker load
command - 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
andpassword
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.
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 actually help your team succeed.
Instead, follow a step-by-step process that makes getting started with Gradle easy.