Long before Dockerfiles, Java developers worked with single deployment units (WARs, JARs, EARs, etc.). As you likely know by now, it is best practice to work in micro-services, deploying a small number of deployment units per JVM. Instead of one giant, monolithic application, you build your application such that each service can run on its own.
This is where Docker comes in! If you wish to upgrade a service, rather than redeploying your jar/war/ear to a new instance of an application server, you can just build a new Docker image with the upgraded deployment unit.
In this post, I will review 3 different ways to create Docker images for Java applications. If you want to follow along feel free to clone my repository at https://github.com/annabaker/docker-with-java-demos.
Prerequisites
- Docker is installed
- Maven is installed (for example #1)
- You have a simple Spring Boot application (I used the Spring Initializr project generator with a Spring Web dependency)
First way: Package-only Build
In a package-only build, we will let Maven (or your build tool of choice) control the build process.
Unzip the Spring Initializr project you generated as part of the prerequisites. In the parent folder of your Spring Boot application, create a Dockerfile. In a terminal, run:
$ unzip demo.zip $ cd demo $ nano Dockerfile
Paste the following and save:
# we will use openjdk 8 with alpine as it is a very small linux distro FROM openjdk:8-jre-alpine3.9 # copy the packaged jar file into our docker image COPY target/demo-0.0.1-SNAPSHOT.jar /demo.jar # set the startup command to execute the jar CMD ["java", "-jar", "/demo.jar"]
- The
FROM
layer denotes which parent image to use for our child image - The
COPY
layer will copy the local jar previously built by Maven into our image - The
CMD
layer tells Docker the command to run inside the container once the previous steps have been executed
Now, let’s package our application into a .jar using Maven:
$ mvn clean package
…and then build the Docker image. The following command tells Docker to fetch the Dockerfile in the current directory (the period at the end of the command). We build using the username/image name convention, although this is not mandatory. The -t flag denotes a Docker tag, which in this case is 1.0-SNAPSHOT
. If you don’t provide a tag, Docker will default to the tag :latest
.
$ docker build -t anna/docker-package-only-build-demo:1.0-SNAPSHOT .
To run the container from the image we just created:
$ docker run -d -p 8080:8080 anna/docker-package-only-build-demo:1.0-SNAPSHOT
-d will run the container in the background (detached mode), and -p will map our local port 8080 to the container’s port of 8080.
Navigate to localhost:8080, and you should see the following:
Once you are satisfied with your testing, stop the container.
$ docker stop
Pros to this approach:
- Results in a light-weight Docker image
- Does not require Maven to be included in the Docker image
- Does not require any of our application’s dependencies to be packaged into the image
- You can still utilize your local Maven cache upon application layer changes, as opposed to methods 2 and 3 which we will discuss later
Cons to this approach:
- Requires Maven and JDK to be installed on the host machine
- The Docker build will fail if the Maven build fails/is not executed beforehand — this becomes a problem when you want to integrate with services that automatically “just build” using the present Dockerfile
Second way: Normal Docker Build
In a “normal” Docker build, Docker will control the build process.
Modify the previous Dockerfile to contain the following:
# select parent image FROM maven:3.6.3-jdk-8 # copy the source tree and the pom.xml to our new container COPY ./ ./ # package our application code RUN mvn clean package # set the startup command to execute the jar CMD ["java", "-jar", "target/demo-0.0.1-SNAPSHOT.jar"]
Now, let’s build a new image as we did in Step 1:
$ docker build -t anna/docker-normal-build-demo:1.0-SNAPSHOT .
And run the container:
$ docker run -d -p 8080:8080 anna/docker-normal-build-demo:1.0-SNAPSHOT
Again, to test your container, navigate to localhost:8080. Stop the container once you are finished testing.
Pros to this approach:
- Docker controls the build process, therefore this method does not require the build tool or the JDK to be installed on the host machine beforehand
- Integrates well with services that automatically “just build” using the present Dockerfile
Cons to this approach:
- Results in the largest Docker image of our 3 methods
- This build method not only packaged our app, but all of its dependencies and the build tool itself, which is not necessary to run the executable
- If the application layer is rebuilt, the mvn package command will force all Maven dependencies to be pulled from the remote repository all over again (you lose the local Maven cache)
Multi-stage Build (The ideal way)
With multi-stage Docker builds, we use multiple FROM
statements for each build stage. Every FROM
statement creates a new base layer, and discards everything we don’t need from the previous FROM
stage.
Modify your Dockerfile to contain the following:
# the first stage of our build will use a maven 3.6.1 parent image FROM maven:3.6.1-jdk-8-alpine AS MAVEN_BUILD # copy the pom and src code to the container COPY ./ ./ # package our application code RUN mvn clean package # the second stage of our build will use open jdk 8 on alpine 3.9 FROM openjdk:8-jre-alpine3.9 # copy only the artifacts we need from the first stage and discard the rest COPY --from=MAVEN_BUILD /docker-multi-stage-build-demo/target/demo-0.0.1-SNAPSHOT.jar /demo.jar # set the startup command to execute the jar CMD ["java", "-jar", "/demo.jar"]
Build the image:
$ docker build -t anna/docker-multi-stage-build-demo:1.0-SNAPSHOT .
And then run the container:
$ docker run -d -p 8080:8080 anna/docker-multi-stage-build-demo:1.0-SNAPSHOT
Pros to this approach:
- Results in a light-weight Docker image
- Does not require the build tool or JDK to be installed on the host machine beforehand (Docker controls the build process)
- Integrates well with services that automatically “just build” using the present Dockerfile
- Only artifacts we need are copied from one stage to the next (i.e., our application’s dependencies are not packaged into the final image as in the previous method)
- Create as many build stages as you need
- Stop at any particular stage on an image build using the –target flag, i.e.
docker build --target MAVEN_BUILD -t anna/docker-multi-stage-build-demo:1.0-SNAPSHOT .
Cons to this approach:
If the application layer is rebuilt, the mvn package command will force all Maven dependencies to be pulled from the remote repository all over again (you lose the local Maven cache)
Verification: How big are the images?
In a terminal, run:
docker image ls
You should see something like the following:
As you can see, the multi-stage build resulted in our smallest image, whereas the normal build resulted in our largest image. This should be expected since the normal build included our application code, all of its dependencies, and our build tooling, and our multi-stage build contained only what we needed.
Conclusion
Of the three Docker image build methods we covered, Multi-stage builds are the way to go. You get the best of both worlds when packaging your application code — Docker controls the build, but you extract only the artifacts you need. This becomes particularly important when storing containers on the cloud.
- You spend less time building and transferring your containers on the cloud, as your image is much smaller
- Cost — the smaller your image, the cheaper it will be to store
- Smaller surface area, aka removing additional dependencies from our image makes it less prone to attacks
Thanks for following along, and I hope this helps! You can find the source code for all three examples at https://github.com/annabaker/docker-with-java-demos
In a future post I will show you how to deal with caching issues specifically for Java builds.