Build, Package, and Run Spring Boot Apps With Docker
Let's take a look at how to construct and deploy a basic Spring Boot application with Docker.
Join the DZone community and get the full member experience.
Join For FreeI've recently started playing around with Docker again and have decided to put together a few posts to share what I've learned. In this post, I'll show you how to compile, package and run a simple Spring Boot app in a Docker container. To keep the Docker image as small as possible, I'll be using Alpine base images and a multi-stage build.
You may also enjoy: Building Microservices Using Spring Boot and Docker
Why Should I Consider Using Docker?
Docker is a containerization technology that allows you to build an image containing your application and all the dependencies required to run it. The image is a deployable artefact and can be used to run containers on any virtual or physical machine with Docker installed. This is a powerful concept as it allows you to run the same image in development, test, pre-production, and production without having to worry about installing or configuring dependencies on each environment.
Using Docker to Build and Run an App
From a Java developer's perspective, the typical Docker use case is running your application inside a Docker container. That's great, but wouldn't it be even better if you could use Docker to build the application, too? In this post, I'll show you how to use Docker to compile, build, and then run a Spring Boot app in a Docker container.
You'll create a Docker image that does the following:
- Copies the application source code from the host machine into a temporary build directory in the image
- Uses Maven to compile and package the app as an executable JAR
- Use a JRE to run the executable JAR
A Note on Image Size
It's important to keep a close eye on the size of the images you're building. Smaller images make for quicker build cycles, faster downloads, and lower storage costs. To keep your images slim there are a few fundamental things you need to consider.
Use Small Base Images
It's best to choose a base image that contains the bare essentials and nothing else. I'll be taking this approach in the sample app later by using Alpine images. Alpine is a super slim Linux distribution weighing in at only 5MB. This makes it a great fit for building lean images. Alpine has a package manager so you can install whatever you need, but the important point is you're starting with an image that is very small. If you look on DockerHub you'll see that many popular images have an Alpine version. You'll see a few examples of this later when we use Alpine-flavored Maven and Open JDK JRE images.
Discard What You Don't Need
The image you'll define later will compile, package, and run a Spring Boot application. The final Docker image is your deployable artefact so it needs to contain only the application and runtime dependencies. It's great to be able to build and run an app inside a single container but you don't want the final image to contain Maven (and the bloat of a local Maven repo) or the entire contents of the target directory. All you really want in the final image is the executable JAR and a Java JRE to run it.
What you want to do is build the application and then discard anything you don't need from the final image. This is where multi-stage builds come into play. They allow you to split your Docker build into distinct steps and copy specific items between steps, discarding everything else. This will allow you to discard build tools and anything else that isn't essential to run the app.
Project Setup
The project structure is very straight forward. I've created a standard Spring Boot app with a single Application class and added a Dockerfile to the root of the project. If you want to get things up and running quickly you can grab the full source for this post on GitHub.
The main application class is shown below and as you can see I haven't added anything else to the project. I'm planning to use the default actuator health endpoint to test the application later.
package com.blog.samples.docker;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Defining the Docker Image
With the Spring Boot end of things covered, let's move on to the Docker image. The image is defined in the Dockerfile below and although it's compact, there's plenty going on. I'll explain each line in detail below.
FROM maven:3.5.2-jdk-8-alpine AS MAVEN_BUILD
MAINTAINER Brian Hannaway
COPY pom.xml /build/
COPY src /build/src/
WORKDIR /build/
RUN mvn package
FROM openjdk:8-jre-alpine
WORKDIR /app
COPY --from=MAVEN_BUILD /build/target/docker-boot-intro-0.1.0.jar /app/
ENTRYPOINT ["java", "-jar", "docker-boot-intro-0.1.0.jar"]
FROM maven:3.5.2-jdk-8-alpine AS MAVEN_BUILD
tells Docker to use the Maven image maven:3.5.2-jdk-8-alpine
as the base image for the first stage of the build. Docker will look for this image locally and if it isn't available it will be pulled from DockerHub. Although Maven will be discarded from the final image (see COPY --from
command later) I've used the Alpine-flavored Maven image as its faster to download.
MAINTAINER Brian Hannaway
isn't essential but improves maintainability by providing a point of contact for the image author.
COPY pom.xml /build/
creates a build
directory in the image and copies the pom.xml into it.
COPY src /build/src/
copies the src
directory into the build
directory in the image.
WORKDIR /build/
sets build
as the working directory. Any further commands will run from the build
directory.
RUN mvn package
runs the mvn package
command to compile and package the application as an executable JAR. The first time the image is built, Maven will pull all required dependencies from the public Maven repo and cache them locally in the image. Subsequent builds will use a cached version of this image layer which means that the dependencies will be referenced locally and won't have to be pulled down again.
At this point, I've defined the image up to the point where it builds an executable JAR. This is the first part of the multi-stage build finished. The next stage will take the JAR and run it.
FROM openjdk:8-jre-alpine
tells Docker you want to use the openjdk:8-jre-alpine
base image for the next stage of the multi-stage build. Again, I've used a lightweight Alpine image for the Java 8 JRE. Using an Alpine image here is more important than using it for the Maven image earlier. While the Maven image will be discarded, openjdk:8-jre-alpine
will form part of the final image. So choosing a lightweight JRE image is important if you want to keep the final image as small as possible.
WORKDIR /app
tells Docker to create a new working directory in the image called /app
. All further commands will run from this directory.
COPY --from=MAVEN_BUILD /build/target/docker-boot-intro-0.1.0.jar /app/
tells Docker to copy docker-boot-intro-0.1.0.jar
from the /build/target
directory in the MAVEN_BUILD stage to the /app
directory of the current stage. As mentioned earlier, multi-stage builds are great because they allow you to copy specific artefacts from one build stage to another and discard everything else. If you were to retain everything from the MAVEN_BUILD stage you'd end up with an image that contains Maven, a local Maven repo. and all the class files generated in the target directory. By cherrypicking what you want from the MAVEN_BUILD stage and discarding everything else, you end up with a much smaller image.
ENTRYPOINT ["java", "-jar", "app.jar"]
tells Docker what command to run when a container is started from this image. The individual parts of the command are comma-separated. In this instance, you want to run the executable JAR you copied to the /app
directory.
Building the Image
Now that you have a Docker image defined, it's time to build it. Open a directory containing the Dockerfile (project root). To build the image run the following command docker image build -t docker-boot-intro .
The -t
option allows you to specify a name and optionally a tag. If you don't specify a tag Docker will automatically tag the image as latest
.
$ docker image build -t docker-boot-intro .
Sending build context to Docker daemon 26.56MB
Step 1/10 : FROM maven:3.5.2-jdk-8-alpine AS MAVEN_BUILD
---> 293423a981a7
Step 2/10 : MAINTAINER Brian Hannaway
---> Using cache
---> db354a426bfd
Step 3/10 : COPY pom.xml /build/
---> Using cache
---> 256340699bc3
Step 4/10 : COPY src /build/src/
---> Using cache
---> 65eb0f98bb79
Step 5/10 : WORKDIR /build/
---> Using cache
---> b16b294b6b74
Step 6/10 : RUN mvn package
---> Using cache
---> c48659e0197e
Step 7/10 : FROM openjdk:8-jre-alpine
---> f7a292bbb70c
Step 8/10 : WORKDIR /app
---> Using cache
---> 1723d5b9c22f
Step 9/10 : COPY --from=MAVEN_BUILD /build/target/docker-boot-intro-0.1.0.jar /app/
---> Using cache
---> d0e2f8fbe5c9
Step 10/10 : ENTRYPOINT ["java", "-jar", "docker-boot-intro-0.1.0.jar"]
---> Using cache
---> f265acb14147
Successfully built f265acb14147
Successfully tagged docker-boot-intro:latest
SECURITY WARNING: You are building a Docker image from Windows against a non-Windows Docker host. All files and directories added to build context will have '-rwxr-xr-x' permissions. It is recommended to double check and reset permissions for sensitive files and directories.
Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro (master)
When you run a build, Docker executes each command in the Docker file as an individual step. A layer is created for each step along with a unique ID. For example, step 1 created a layer with ID 293423a981a7.
The first time you build an image, Docker will pull whatever external images it needs from DockerHub and build new layers from scratch. As you might expect, this can make the first build quite slow.
During the build, Docker checks its cache to see if it already has a cached version of each layer before attempting to build it. If a cached version of the layer is available, Docker uses it instead of building the layer from scratch. This means that once you've built an image, subsequent builds become much faster. You can see cached layers being used in the build output above where Docker outputs --> Using Cache
and then the hash of the layer used.
As part of the RUN mvn package
command, Docker pulls all POM dependencies from the public Maven repo, builds an executable JAR, and stores all of this in layer c48659e0197e. The next time you build the image, the Maven dependencies and the application JAR will be taken from the cached layer and won't have to be downloaded and built again. This is what's happening in step 6 above.
How Big is the Image?
Run the docker image ls
command to list all your local images. You'll see the docker-boot-intro
image listed with a size of 105 MB.
Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro (master)
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
docker-boot-intro latest 823730301d60 15 minutes ago 105MB
<none> <none> 853d42b823c3 15 minutes ago 136MB
<none> <none> 39ac5e9e9562 19 minutes ago 105MB
<none> <none> dfda2356bd36 19 minutes ago 136MB
<none> <none> b9918f1a40f3 9 hours ago 105MB
<none> <none> 5c8d8feadac4 9 hours ago 136MB
<none> <none> 37ce6059c665 10 hours ago 105MB
<none> <none> a3c65e472ef0 10 hours ago 136MB
<none> <none> 9c923ffa281f 5 days ago 103MB
<none> <none> f265acb14147 6 days ago 103MB
<none> <none> 355c0df6df00 6 days ago 103MB
<none> <none> c48659e0197e 6 days ago 135MB
<none> <none> 12ccc0ebd9ab 6 days ago 135MB
<none> <none> fbfb36e47739 11 days ago 103MB
<none> <none> c0592f908083 11 days ago 96.4MB
<none> <none> c64ed1838d04 11 days ago 169MB
<none> <none> a79ee088cc15 2 weeks ago 165MB
<none> <none> 590ce685a660 2 weeks ago 165MB
<none> <none> cae8c5edec73 2 weeks ago 165MB
<none> <none> 313ee2b229f0 2 weeks ago 535MB
<none> <none> 1f125f2b48e9 2 weeks ago 535MB
<none> <none> 544620bb08d0 2 weeks ago 570MB
<none> <none> a45d48c069ea 2 weeks ago 570MB
<none> <none> ef4a7b464400 2 weeks ago 570MB
<none> <none> 0e9351cb5d2f 2 weeks ago 570MB
<none> <none> 27a435a1fffe 2 weeks ago 570MB
<none> <none> b87ba7aef3f8 2 weeks ago 570MB
<none> <none> cce6a99f54d0 2 weeks ago 570MB
<none> <none> c1c3f114662d 2 weeks ago 506MB
<none> <none> c1c5e0d7c404 2 weeks ago 506MB
tomcat latest ee48881b3e82 2 weeks ago 506MB
jdk-image-from-docker-file latest 82e9207f76af 2 weeks ago 468MB
myjdkimage latest a5aeca271399 2 weeks ago 468MB
tomcat <none> 96c4e536d0eb 5 weeks ago 506MB
ubuntu latest a2a15febcdf3 6 weeks ago 64.2MB
openjdk latest e1e07dfba89c 7 weeks ago 470MB
openjdk 8-jre-alpine f7a292bbb70c 4 months ago 84.9MB
hello-world latest fce289e99eb9 9 months ago 1.84kB
openjdk 8u131-jdk-alpine a99736768b96 22 months ago 101MB
maven 3.5.2-jdk-8-alpine 293423a981a7 23 months ago 116MB
Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro (master)
I mentioned earlier that it's good practice to keep your images as light as possible. Let's take a look a closer look at the docker-boot-intro
image and see how we arrived at 105 MB. If you run the docker image history boot-docker-intro
command you'll see a breakdown of the various layers in the image.
Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro/target (master)
$ docker image history docker-boot-intro
IMAGE CREATED CREATED BY SIZE COMMENT
823730301d60 19 minutes ago /bin/sh -c #(nop) ENTRYPOINT ["java" "-jar"... 0B
7e43d899f02f 19 minutes ago /bin/sh -c #(nop) COPY file:05f3666306f8c7af... 20.1MB
1723d5b9c22f 6 days ago /bin/sh -c #(nop) WORKDIR /app 0B
f7a292bbb70c 4 months ago /bin/sh -c set -x && apk add --no-cache o... 79.4MB
<missing> 4 months ago /bin/sh -c #(nop) ENV JAVA_ALPINE_VERSION=8... 0B
<missing> 4 months ago /bin/sh -c #(nop) ENV JAVA_VERSION=8u212 0B
<missing> 4 months ago /bin/sh -c #(nop) ENV PATH=/usr/local/sbin:... 0B
<missing> 4 months ago /bin/sh -c #(nop) ENV JAVA_HOME=/usr/lib/jv... 0B
<missing> 4 months ago /bin/sh -c { echo '#!/bin/sh'; echo 'set... 87B
<missing> 4 months ago /bin/sh -c #(nop) ENV LANG=C.UTF-8 0B
<missing> 4 months ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B
<missing> 4 months ago /bin/sh -c #(nop) ADD file:a86aea1f3a7d68f6a... 5.53MB
Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro/target (master)
The super-slim 5.53 MB Alpine base image is listed as the first layer. A number of environment variables are configured in the next few layers and then the 79.4 MB JRE is added. The final three layers come from the Dockerfile we defined and include the 20.1 MB application JAR. This is a nice lightweight image with only the bare essentials needed to run the application.
Running a Container
Now that the image is image built you can run a container with the following command docker container run -p 8080:8080 docker-boot-intro
. The run
command takes an optional -p
option that allows you to map a port from the container to your host machine. If you're familiar with Spring Boot, you'll probably know that by default a Boot app starts on port 8080. When you run a container, Docker will run the executable JAR and the application will start on port 8080 inside the container. In order to access the application running in the container you need to map the internal container port to a port on the host machine. The -p 8080:8080
option maps the containers internal port 8080 to port 8080 on the host machine.
If everything works as expected you should see the Boot app start on port 8080 as follows.
Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro/target (master)
$ docker container run -p 8080:8080 docker-boot-intro
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.1.7.RELEASE)
5436 [main] INFO com.blog.samples.docker.Application - Starting Application v0.1.0 on 934a1d731576 with PID 1 (/app/docker-boot-intro-0.1.0.jar started by root in /app)
5466 [main] INFO com.blog.samples.docker.Application - No active profile set, falling back to default profiles: default
16585 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port(s): 8080 (http)
16742 [main] INFO o.a.coyote.http11.Http11NioProtocol - Initializing ProtocolHandler ["http-nio-8080"]
16886 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat]
16892 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/9.0.22]
17622 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
17628 [main] INFO o.s.web.context.ContextLoader - Root WebApplicationContext: initialization completed in 11614 ms
21399 [main] INFO o.s.s.c.ThreadPoolTaskExecutor - Initializing ExecutorService 'applicationTaskExecutor'
23347 [main] INFO o.s.b.a.e.web.EndpointLinksResolver - Exposing 2 endpoint(s) beneath base path '/actuator'
23695 [main] INFO o.a.coyote.http11.Http11NioProtocol - Starting ProtocolHandler ["http-nio-8080"]
23791 [main] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port(s): 8080 (http) with context path ''
23801 [main] INFO com.blog.samples.docker.Application - Started Application in 21.831 seconds (JVM running for 25.901)
Testing the Application
If you see output similar to that shown above, your container has started and you should be able to test the app. If you're running Docker on Windows or Mac, you're using Docker Toolbox, which is a Linux VM. You'll need to get the IP of the Linux VM by running the docker-machine ip
command. When I run this command I can see that my Linux VM IP is 192.168.99.100.
Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro (master)
$ docker-machine ip
192.168.99.100
Once you know the IP you can test the app by calling its health check endpoint using cURL curl 192.168.99.100:8080/actuator/health
If the application is up and running you should get a HTTP 200 response with a response body {"status":"UP"}
.
Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro (master)
$ curl 192.168.99.100:8080/actuator/health
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 15 0 15 0 0 937 0 --:--:-- --:--:-- --:--:-- 937{"status":"UP"}
Limitations With the Approach Used
I mentioned earlier that Docker caches layers so that they can be reused to reduce build times. While this stands true, there's something you need to consider when building Java apps. Every time you make a change to the application source or POM, Docker will see that layer as changed and disregard the cached copy. This in itself is fine as we want to rebuild the layer.
The problem is the Maven dependencies that were saved in the cached layer are lost. So when you rebuild the application layer with the mvn package
command, all Maven dependencies will be pulled from the remote repository again. This obviously slows the build considerably and will become a real pain during development. When you're building Java apps without Docker, you pull down the Maven dependencies from the remote repository the first time you build and then reference them in your local Maven cache after that. Unfortunately using Docker means that when the application layer is rebuilt, you lose the local Maven cache.
So What's the Answer?
The workaround for this issue is to use a local Maven repository on the host machine as the source of your Maven dependencies. Using volumes, you can tell Docker to access the local Maven repository on the host rather than pulling the dependencies from a public repository. There are pros and cons to this approach.
On the upside, you can change the application source and rebuild without sacrificing quick build times because you're using cached Maven dependencies. On the downside, your Docker image has lost some of its autonomy. Remember, one of the main reasons you're using Docker is so that you don't have to worry about configuring software on the environment it's running on.
Ideally, your Docker image should be self-contained and have everything it needs to build and run without any dependencies on the host. By using a Maven cache on the host you lose that autonomy.
I'll cover Docker volumes in my next post and show you how they can be used to access a Maven repo on the host machine.
Wrapping Up
In this post, you defined a Docker image to build and run a Spring Boot application. We talked about the importance of keeping images as light as possible and you did that by using Alpine base images and a multi-stage build to discard build. We also looked at the limitations of building Java apps with Docker and a potential workaround. You can grab the full source for this post from GitHub. If you have any comments or questions please leave a note below.
Further Reading
Published at DZone with permission of Brian Hannaway, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments