Skip to content

Dockerize a Spring Boot Application with Tomcat

September 29, 2023

Kulwinder Billen

tl;dr - Step 1. Create Dockerfile, Step 2. Done!

As a team we wanted to get a Spring Boot REST API to become docker-ready. As in, get it to a place where we can take the docker image and launch it on Kubernetes. We wanted it to be horizontally scalable, portable, and be easily managed by a container orchestration technology. Include any buzzwords I missed.

If you want to learn how one might get a Spring Boot application to be Dockerized! Let's get started!

Contents:

  1. Creating your first Dockerfile (if applicable)
  2. Use multi-stage builds within your Dockerfile
  3. Setting up tomcat to run your application
  4. Setting up volumes locally for testing your newly built image
  5. Running your docker container locally!
  6. Then finally, launching it

Before jumping into the Docker half of the world, let's define what we have for the application we are dockerizing:

  • We have the Java Spring Boot application source
  • We use a build automation tool to build our Spring application : Maven

The common installation method for a Spring Boot application is generally installed on a VM that has Tomcat installed. We move the WAR file (that comes from the Maven build) into the correct location and run the Tomcat server.

So for Docker, we initially leaned towards just passing in the WAR file that was generated through other CI tools and build the Tomcat portion into the docker image.

Instead, we decided that we would like the project built to an image as a unit/single operation. That would avoid us having to setup a place where the WAR files would eventually live and the Docker image that is built to be configured to pick-up the files at that location. We thought the single unit approach would be simpler to deal with and we utilized multi-stage builds to help with that.

Building It

Creating your first Dockerfile

There are of course a ton of resources on the internet regarding this. However, you came here to learn how to dockerize a Spring Boot application. If this is your first time creating a Dockerfile, don't fear! I will go through some of the basics and get you up to speed.

Dockerfiles have some basic commands that we use to glue together and create these images. I will only go over the ones we will use, but there are more.

FROM  - Base image that you want to start off with. 
A Dockerfile needs to start with this. (or can only be preceded by ARG - 
in case you want to pass in a version to the FROM tag)

LABEL - This allows you to add labels to your image. (eg. Maintainer name and email)

ADD - This copies files from the host machine to the container filesystem. 
This includes handling of tar and URL handling. 
However, COPY is preferred for moving files between the host and container file system. 
The only reason being that it might do some added steps such as decompress your 
tar automatically, etc. It can still definitely be used as long as you know what it will do.

COPY - Same as ADD, but without the tar and remote url handling

WORKDIR - Sets the working directory for the RUN, CMD, ENTRYPOINT, COPY and ADD 
instructions that are specified after it is used. 
You can run this instruction multiple times within the Dockerfile and the commands 
under it will then use the latest directory specified by WORKDIR.

ARG - These are arguments you might want to pass in during build-time only. 
Such as version of the base image, or perhaps the path of the war file you might want to 
pass in.

ENV - Sets environment variables within the docker container. 
The cool thing about this is that they can be set during runtime from the command line. 

EXPOSE - Essentially lets the docker container know to enable networking for this port 
as it will likely be used by the outside world.

RUN - We use this to run commands during the build phase to add layers onto the image itself. 
Something that you want the image to have. (Eg. Your application code or some form of it)

CMD - We use this to run a command at the end that we always want the image to run. 
(*Note: All CMD specified before are ignored except the last one* ). 
Allows you to override the parameters when you run the docker container via command line.

ENTRYPOINT - Similar to CMD, but doesn't allow you to override the command and parameters 
via the command line.

Now that you have a basic understanding of the base commands of a Dockerfile, we are gonna use a combination of the above to create one.

Maven and Multi-Stage Builds

Docker images can get fairly large, and we always have a goal of keeping image sizes small. Originally, the Docker community started commonly creating two Dockerfiles. One for the build portion, and one for the production-ready application. Eg. (Dockerfile.build and Dockerfile).

Thankfully, there is now a solution which allows us to throw out having to create two Dockerfiles, and use what is known as multi-stage builds .

As we originally discussed we are going to be building the source code using our preferred build automation tool, Maven. Then run the application using a web server. (eg. Tomcat).

FROM maven:3.6.3 as maven
LABEL COMPANY="ShuttleOps"
LABEL MAINTAINER="support@shuttleops.com"
LABEL APPLICATION="Sample Application"

As you can see, we use two of the keywords, FROM and LABEL. The FROM keyword instructs Docker to start our image from that base image. In this case, we need the maven image (version 3.6.3). Make note of the as maven, as that will be coming handy later in the process. It allows us to name this stage, and in our case, we named it maven.

The LABEL keyword allows us to, wait for it….LABEL the image with some key/value pairs. We choose to include the company, maintainer email, and the application name.

WORKDIR /usr/src/app
COPY . /usr/src/app
RUN mvn package 

Next we use the WORKDIR, COPY, and RUN keywords.

The WORKDIR keyword essentially anchors the directory we will be working in. All subsequent commands will be run from that base directory. If the directory isn't created, it will create it for you. We chose the arbitrary path of usr/src/app as the directory to move our source into.

The COPY command is where the real work begins. We copy over our source files from our host machine, which in this case is the same directory as our Dockerfile. We specify that by using the . to indicate that we want to copy the files from the root of "context of the build". Which we supply to the docker build command. In our case, we supply it with the directory that contains the Dockerfile.

Now that the files are copied over into /usr/src/app we will now actually build the project using Maven. We want to package the application into a WAR file but by running the mvn package command it will also run the tests. Which is great because all of that happens in a single command in your CI.

Note: you will need to provide everything to the container that it needs to test the application, otherwise the tests will fail. If you do tests in others parts of your CI and want to skip them. You can use mvn -Dmaven.test.skip=true package. instead

The RUN command as noted earlier is to add layers to the Docker image. The result of this command will be a layer that is added to the image. Which is good for us, because we will need the WAR from this command in our second build stage for Tomcat. After mvn package command runs. There will be a directory in /usr/src/app/target that will contain our application WAR file.

Tomcat and the Multi in the Multi-stage builds

This stage is where Multi-stage builds get it's Multi! Now we are going to start off with a Tomcat image and begin setting up the required folders to run our application.

Also note that # indicate comments within your Dockerfile.

FROM tomcat:8.5-jdk15-openjdk-oracle
ARG TOMCAT_FILE_PATH=/docker 
	
#Data & Config - Persistent Mount Point
ENV APP_DATA_FOLDER=/var/lib/SampleApp
ENV SAMPLE_APP_CONFIG=${APP_DATA_FOLDER}/config/
	
ENV CATALINA_OPTS="-Xms1024m -Xmx4096m -XX:MetaspaceSize=512m -	XX:MaxMetaspaceSize=512m -Xss512k"
view rawgistfile1.txt hosted with ❤ by GitHub

A few interesting portions to this section of the Dockerfile.

We now use a FROM keyword again. This begins the second stage. We are essentially telling the Dockerfile to start from "scratch" (kind of) from this image. In our example, it'll be tomcat:8.5-jdk15-openjdk-oracle.

We then utilize the ARG keyword. We wanted to specify the where Docker should look to find some Tomcat configuration files we wanted to place within the image. Remember, ARG are arguments you may pass in during build-time only. The default of /docker instructs Docker that we should look in the /docker folder within the build context from the host machine.

Next we see the ENV keyword. These are environment variables that can be changed during runtime. However, we do have some defaults. Our application uses the SAMPLE_APP_CONFIG environment variable to find out where the configuration files live. These environment variables are akin to the environment variables you would set on your host machine, such as the PATH environment variable.

The last environment variable we set is the CATALINA_OPTS which Tomcat uses to determine how much max/min memory, the metaspace to use, as well as the stack size the process can have.

Now all that's left is to move the war file to the appropriate location and get it ready to run!

#Move over the War file from previous build step
WORKDIR /usr/local/tomcat/webapps/
COPY --from=maven /usr/src/app/target/SampleApp.war /usr/local/tomcat/webapps/api.war

COPY ${TOMCAT_FILE_PATH}/* ${CATALINA_HOME}/conf/

WORKDIR $APP_DATA_FOLDER

EXPOSE 8080
ENTRYPOINT ["catalina.sh", "run"]

Again, let's take it one step at time. We have used the WORKDIR keyword to anchor the directory within the tomcat directory that contains the webapps. We will be moving the WAR file created in the first build stage to this location.

Let's take at this COPY command closely:

COPY --from=maven /usr/src/app/target/SampleApp.war /usr/local/tomcat/webapps/api.war

As you can see here we use the --from=maven flag that we haven't used before. If you remember, we made note of this in the original build stage. We named the first stage by specifying it in the FROM keyword (FROM maven:3.6.3 as maven). Since we used as maven , we can now use it as a reference and access files we created in that stage. In our case, we created the WAR file and saved it to /usr/src/app/target/. We then move it to the /usr/local/tomcat/webapps/ folder and rename it to api.war in the process.

Note: by naming it to api.war, we can now access the application at say localhost:8080/api vs localhost:8080/

We then move over some configuration files using the COPY command again into the respective Tomcat folder, and anchor the working directory to the data folder.

We are finally nearing the end of the Dockerfile that kicks off the main command that you want the Docker image to run when it spins up a container.

We use the EXPOSE keyword to specify which port we want Docker to enable networking for, and letting Docker know that there will be some traffic going thru this port.

Finally, we use the ENTRYPOINT keyword to run the catalina.sh run that kicks off the Tomcat server and deploys our application and make it ready to receive requests.

In a single place, this is how our Dockerfile looks like:

FROM maven:3.6.3 as maven
LABEL COMPANY="ShuttleOps"
LABEL MAINTAINER="support@shuttleops.com"
LABEL APPLICATION="Sample Application"

WORKDIR /usr/src/app
COPY . /usr/src/app
RUN mvn package 

FROM tomcat:8.5-jdk15-openjdk-oracle
ARG TOMCAT_FILE_PATH=/docker 
	
#Data & Config - Persistent Mount Point
ENV APP_DATA_FOLDER=/var/lib/SampleApp
ENV SAMPLE_APP_CONFIG=${APP_DATA_FOLDER}/config/
	
ENV CATALINA_OPTS="-Xms1024m -Xmx4096m -XX:MetaspaceSize=512m -	XX:MaxMetaspaceSize=512m -Xss512k"

#Move over the War file from previous build step
WORKDIR /usr/local/tomcat/webapps/
COPY --from=maven /usr/src/app/target/SampleApp.war /usr/local/tomcat/webapps/api.war

COPY ${TOMCAT_FILE_PATH}/* ${CATALINA_HOME}/conf/

WORKDIR $APP_DATA_FOLDER

EXPOSE 8080
ENTRYPOINT ["catalina.sh", "run"]

We placed this file and named it Dockerfile in the root of our repository where the pom.xml exists and ran the following command to begin building this Docker image.

docker build -t kbillen92/sample-api:latest .

The -t is tagging the build with namespace/name_of_application:tag. The . at the end is specifying the build context and where to look for the Dockerfile, which happens to be the root of the directory to our application.

Once the image is built you will be able to see it by running the following command: docker images

Running It

Alright!! You are now (almost) ready to finally run your Spring Boot Docker image!

I say almost because in most applications you need to save some sort of state such as images, documents, or some sort of data that you need to persist even if the docker container is not running. For that reason, we are also going to setup a volume to keep that persistent data.

If your application does not have the requirement of persistent data, you can skip the next step of creating a volume.

Setting Up Volume

Run the following command to create the volume.

docker volume create --driver local --opt device=/d/DockerVolumes/sampleVolume --opt type=none --opt o=bind sampleVolume

Note: You will need to create that folder structure beforehand, otherwise the volume won't be created.

We are ready to run it!

Running It Locally

Since we have finally built our image and created the persistent volume (if applicable), we can now run the image.

docker run -d --mount source=sampleVolume,target=/var/lib/SampleApp -p 8080:8080 kbillen92/sample-api:latest

You can now run docker ps to see if it's running.

If you would like to access the bash terminal inside the container run the following command: docker exec -it <container_id or name> /bin/bash

​ YOU HAVE CREATED AND LAUNCHED YOUR SPRING BOOT APPLICATION!!

The last step is to push this image to your Docker repository. If you have DockerHub account you can do the following commands:

docker login -u <username> -p <password> to first login.
docker push kbillen92/sample-api:latest to push it to DockerHub

Note: This will push it to a public repository. If you would like it to be private, please create the repository first

Launching It

Now that you are all fancy with Docker, you will need to deploy your container somewhere. This brings about another technology that you will probably face, Kubernetes. It's what everyone is talking about these days.

GOT A PROJECT?