Production-grade Spring Boot Docker images
There are plenty of examples of basic Dockerfile based builds out there, but a production application requires a bunch of different things, such as reproducibility, hardening, health checks, static analysis - and ideally still be quick to build.
Multi-stage Docker builds
Being able to build your software in exactly the same way as your CI system is highly desirable. No-one wants their dev cycle to involve CI, no-matter how quick it is. Multi-stage Docker builds are a great way to achieve this.
Below is an outline of how to structure a multi-stage build.
1 |
|
The key things to note are:
- The
java-build
image, and any other images except the lastFROM
, will be discarded at the end of the build, and used again only for layer caching. - In the second image, we only
COPY
build artefacts from thejava-build
image, as we have no compilation tools, (and later on not even a shell).
At the moment this Dockerfile
is still quite naive, and a full
download of all our dependencies will occur each time we run the build.
Caching dependencies
If we want the build to be fast (we do), then we can start to take advantage of Docker’s layer caching to speed it up. Docker builds use caches for each build step until it sees a layer change. In our case, our dependencies only change when someone either updates the Maven wrapper, or the pom file.
So if we only copy those files, and then have a RUN
step that only downloads
the dependencies (./mvnw dependency:go-offline
), we can cache them in a
separate layer, and only re-compute them when needed. See below:
1 |
|
We only COPY
the actual Java src
dir after the dependencies are downloaded.
Reducing partial image sizes
We’ve successfully improve the build times for our image. The next thing to optimise is the amount that needs re-downloading each time our application is updated. Right now, we’re using Spring Boot’s default uber JAR packaging, which with a decent sized application, can easily start reaching in the order of 50+ MiB.
We don’t want to download 50 MiB of double-packed JARs each time a trivial change is made to the application, so in our Docker builds we should carefully unpack this JAR, putting the dependencies in a separate layer than our compiled code.
1 |
|
We order the layers in least order of “likeliness to change”, so our dependencies go first, and our own classes last.
Remember to change the last line to your own main class.
Static analysis
Always recommended is static analysis of your code, so let’s enable SonarQube in our builds. The Spring Boot starter parent already includes Sonar in its plugin dependencies, so all we need to do is invoke the right Maven goal with the right configuration.
We don’t want to store sensitive data in the Dockerfile or version control, so let’s only run
Sonar when we give it some credentials, taking the arguments SONAR_HOST_URL
and
SONAR_AUTH_TOKEN
.
1 |
|
The SONAR_HOST_URL
and SONAR_AUTH_TOKEN
parameters should be stored securely
inside your CI system’s key store, and not in your build script.
Hardening your container
Minimise attack surface
To reduce the attack surface of our final image we should avoid copying any un-necessary executable files, such as C/C++ libraries, shells, etc. Google’s Distroless base images seek to achieve exactly this; providing a Java image that only includes the bare essentials to make it function (JRE, ca-certificates, tzdata, glibc, libssl).
It’s pretty straightforward to take advantage of this in a Java application, by simply
changing the base image, and making sure your entrypoint is using the JSON form
ENTRYPOINT ["/app/myapp"]
, as we have no shell.
1 |
|
Remember to use the JSON form for ENTRYPOINT ["/app/myapp"]
or it won’t work.
Non-root user
It’s important to run your application as a non-root user, so it can’t break out of its
container as easily, if compromised. This is normally done by adding a user to the
image with RUN adduser username ...
to add the user, and then a USER username
build
step to make the container run as that user by default.
However, because the final image is based on gcr.io/distroless/java
, we can’t simply
RUN
a shell command, so we must do it in the build image, then copy the /etc/passwd
and /etc/shadow
files over to the final image.
1 |
|
Health-checks without BASH or curl
Health-checks essentially doing curl http://localhost:8080
are quite common practise
in Docker. However, we’re trying to avoid having a shell, or other general purpose
utilities like curl
and libcurl
. The best solution I have found is a simple GoLang
tool that has the URL hard-coded.
1 |
|
This tool can be built in a separate stage, using the official GoLang image, then
copied into your final image, in the same way as the main application. Lastly,
reference the healthcheck in a HEALTHCHECK
step, again remembering to use the
JSON form.
1 |
|
Remember to use the JSON form: HEALTHCHECK CMD ["/app/healthcheck"]
JVM container memory support for Java 11+
Since Java 11, the JVM has support for knowing about the container resource limits
without any special extra configuration. This means we can simply set the flag
-XX:MaxRAMPercentage=90
, and not need to do any complex calculations about the
other memory our application will use besides the heap.
1 |
|
The final result
Below is the final Dockerfile
, which can be found here
in the example repository.
1 |
|