/, Docker/Build, Package and Run Spring Boot Apps with Docker

I’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.

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 & Run an App

From a Java developers 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 flavoured 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 flavoured 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 its time to build it. Open a terminal window and go to the 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 105MB.

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 its 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 105MB. 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.53MB 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.4MB JRE is added. The final 3 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 d -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.

By |2019-10-02T07:43:57+01:00October 1st, 2019|Containerisation, Docker|0 Comments

Leave A Comment