This post is part of a series that demonstrates a sample deployment pipeline with Jenkins, Docker, and Octopus:
- From JAR to Docker
- From local builds to Continuous Integration
- From Continuous Integration to Kubernetes deployments
- From Continuous Integration to release management
- From release management to operations
There is perhaps no public project that better exemplifies a long-lived Java application than Spring PetClinic. It started life way back in the early 2000s, and is still proudly featured on the Spring website.
Our journey through the DevOps lifecycle starts with a local build of PetClinic on a local workstation. By the end of this blog post, we’ll have containerized this application with Docker to provide a repeatable build and execution environment.
Start with a local build
To get started, we clone the PetClinic GIT repository and use the Maven wrapper checked in alongside the application source code to build and run a local JAR file:
git clone https://github.com/spring-projects/spring-petclinic.git
cd spring-petclinic
./mvnw spring-boot:run
This initial build will take some time as Maven downloads the various Spring libraries that make up the application. Thankfully these libraries are cached, so subsequent builds will complete much faster.
To view the application, open http://localhost:8080:
To create a distributable JAR file, run the command:
./mvnw package
This runs the unit tests and then creates a single, self-contained JAR file under the target
directory called something like petclinic.2.3.1.BUILD-SNAPSHOT.jar
. We can run this file with the command:
java -jar .\target\petclinic.2.3.1.BUILD-SNAPSHOT.jar
This process of testing, building, and running locally is where every application starts.
To be fair, PetClinic implements a number of features to make these builds repeatable and the results easily distributable. The mvnw
script is the Maven Wrapper, which provides cross platform scripts designed to be checked into source control that downloads the appropriate version of Maven if the local machine does not have it installed. Spring boot then creates self-contained JAR files that are easy to version, copy, and deploy.
However, you still need the Java Developer Kit (JDK) to build the application, and either the JDK or Java Runtime Environment (JRE) to run it. PetClinic relies on a quite old version of Java, and given that a new version of Java is released every six months, it’s not hard to imagine developers having to juggle Java installations to perform a local build.
To provide a truly self-contained build and execution environment, we’ll migrate this application to Docker.
Self-contained builds and execution with Docker
One of the main features of Docker is its ability to bundle an entire application ecosystem in a self-contained image that can be run in an isolated environment. For us, this means we can build and distribute a Docker image with the required version of Java and our compiled application, and anyone with Docker installed will be able to run it.
A fork of the PetClinic repo has been created in GitHub with the code below for easy access.
A Docker image is defined by the steps listed in a file called Dockerfile
. The contents of our Dockerfile
are shown below:
FROM maven:3.6.3-jdk-8 AS build-env
WORKDIR /app
COPY pom.xml ./
RUN mvn dependency:go-offline
RUN mvn spring-javaformat:help
COPY . ./
RUN mvn spring-javaformat:apply
RUN mvn package -DfinalName=petclinic
FROM openjdk:8-jre-alpine
EXPOSE 8080
WORKDIR /app
COPY --from=build-env /app/target/petclinic.jar ./petclinic.jar
CMD ["/usr/bin/java", "-jar", "/app/petclinic.jar"]
This Dockerfile
makes use of a feature called multistage builds. This allows us to create a smaller final Docker image for distribution by not including the tools that are only required to build the application.
We base our build on an existing Docker image provided by the Maven team. This image has the JDK and Maven tools preinstalled:
FROM maven:3.6.3-jdk-8 AS build-env
We then create and move into a directory called /app
:
WORKDIR /app
The Maven pom.xml
file is copied into the /app
directory:
COPY pom.xml ./
We then run the Maven dependency:go-offline
goal, which downloads most of the libraries and Maven plugins we need to build the application.
Because of the way Docker caches builds, so long as the pom.xml
file doesn’t change, any subsequent rebuilds of this image will reuse the downloads performed by this execution of Maven. For the PetClinic application, this can save several minutes and many hundreds of megabytes:
RUN mvn dependency:go-offline
Spring includes a source code formatting tool that ensures all the code has a consistent style. We’ll call the help function to ensure that Maven downloads the plugin, which means Docker, in turn, will cache the download. This will save us one more download with subsequent rebuilds of the Docker image:
RUN mvn spring-javaformat:help
We can now copy the rest of the application source code. Docker detects when the source code being copied has changed and reruns the image building process from this step to capture the changes. However, all the application dependencies have been cached, so the build process from this step will be relatively quick:
COPY . ./
Copying source code from a Windows workstation to a Linux Docker image would normally cause the formatting plugin to complain about line endings. Here we run the formatting plugin to automatically fix any issues with the copied files:
RUN mvn spring-javaformat:apply
We can now build the application with the Maven package
goal. Note that we have also set the finalName
variable to petclinic
. This overrides the default file name of petclinic.2.3.1.BUILD-SNAPSHOT.jar
to produce a consistent file called petclinic.jar
:
RUN mvn package -DfinalName=petclinic
Our application is now built, and we move to the next stage of the multistage build to produce the Docker image that we want to distribute. This image is based on the OpenJDK JRE.
The JRE can run a compiled application but does not include the tools required to compile applications. This reduces the size of the final image.
FROM openjdk:8-jre-alpine
We expose port 8080
, which is the port our Spring application listens to:
EXPOSE 8080
We create and move into a directory called /app
:
WORKDIR /app
The JAR file compiled in the previous stage is copied into the current image:
COPY --from=build-env /app/target/petclinic.jar ./petclinic.jar
We then instruct the image to execute the JAR file when it is run:
CMD ["/usr/bin/java", "-jar", "/app/petclinic.jar"]
To build the Docker image, run the command:
docker build . -t petclinic
This will build the Docker image and assign it the tag petclinic:latest
. Note that latest
is applied by default if no other tags are specified.
Finally, run the Docker image with the command:
docker run petclinic
As before, the application is available at http://localhost:8080.
We now have a Dockerfile
that includes all the steps required to build and run our application. This application can now be built from source without any other tools than Docker. We now have a truly self-contained build process.
Distributing Docker images
Docker images can be shared online with a number of Docker registries. The most popular is Docker Hub, which provides free accounts for hosting publicly available Docker images.
To share the PetClinic Docker image, we need to sign up for a Docker Hub account. My account is called mcasperson
.
After you have created an account, sign in to Docker Hub with the command docker login
:
docker login
Login with your Docker ID to push and pull images from Docker Hub. If you don't have a Docker ID, head over to https://hub.docker.com to create one.
Username: mcasperson
Password:
Login Succeeded
To share the image it needs to be tagged with your username. In my case, I need to build an image called mcasperson/petclinic
(where mcasperson
is my Docker Hub username):
docker build . -t mcasperson/petclinic
The build should complete quickly, as no files have changed and all steps are cached from earlier.
To upload the image, run the following command, replacing mcasperson
with your Docker Hub username:
docker push mcasperson/petclinic
The image is now shared online for anyone to access.
To run the public image, execute the command:
docker run -p 8080:8080 mcasperson/petclinic
Docker will download the image if it is not available locally, and then run it as we did before. The option -p 8080:8080
explicitly maps the local port 8080 to the container port 8080.
Conclusion
In this post, we took a typical Java application and containerized it as a Docker image. This image was uploaded to a Docker registry making it publicly available.
With these changes, we have created a repeatable build and execution process anyone can use with only Docker installed. If we switch to a newer version of Java, or even switch languages completely, the application can still be built and run with the same Docker commands.
While the build process may be conveniently encapsulated by Docker, there is no guarantee that the source code compiles or that the tests all pass. As more developers begin working on an application, the health of the codebase is something that needs to be shared by a central source of truth so everyone knows the state of the application. This is where a Continuous Integration (CI) server comes in.
In the next post we’ll configure our application to be built by the popular and open source CI server Jenkins.