At the end of November, we’ll be migrating the Sematext Logs backend from Elasticsearch to OpenSearch

How to Use Docker to Containerize Java Web Applications: Tutorial for Beginners

March 29, 2022

Table of contents

Containers are no longer a thing of the future – they are all around us. Companies use them to run everything – from the simplest scripts to large applications. You create a container and run the same thing locally, in the test environment, in QA, and finally in production. A stateless box built with minimal requirements and unlike virtual machines – without the need of virtualizing the whole operating system. No issues with libraries, no issues with interoperability – a dream come true, at least in the majority of the cases. Those are only some of the benefits of running containerized applications.

And when it comes to a lot of us, developers, when we say containers we think Docker. That’s because of Docker’s simplicity, ecosystem, and availability on various platforms. In this blog post we will learn how to create a web application in Java, so that it can run in a container, how to build the container, and finally, how to run it and make it observable.

Benefits of Docker Containers

You can imagine the container image as a package that packs up your application code along with everything that is needed to run it, but nothing more. It is lightweight as it contains only the application and needed dependencies and the runtime environment. It is standalone as there are no external dependencies needed. They are secure because of the isolation that is turned on by default.

A container image becomes the container at runtime when it is run inside the environment capable of running container images – in our case the Docker engine. Docker Engine is available for major operating systems. From a user perspective, as long as you are using images that can be run on your OS, you don’t have to worry about any kind of compatibility issues.

Docker itself is more than just the Docker Engine. It is the whole environment that helps you run, build, and manage container images. It comes with a variety of tools that speed up working with containers and help you achieve your goal faster.

Should You Run Java in Docker Containers?

But can I run my Java applications on Docker, inside the container? The answer is – yes, you can. Do you need Docker to run Java applications? You probably know the answer by now. No, you don’t need Docker to run Java applications, you will be perfectly fine running them in a dedicated environment, exactly the same as you were running them till now. But if you would like to move forward, use exactly the same package in all the environments, be sure that everything will work in the same way – just use containers and soon you won’t be able to live without them.

What Is a Java Docker Container?

Before we get into the details of how to work with containers, let’s answer one of the questions that appear – how does Java fit into all of that?

The answer is really straightforward – a Java Docker container is a standard container image that is packed with the Java runtime environment and all the dependencies needed to run your application. Such containers can be downloaded into the environment where Docker Engine is installed and just start it. No additional installation is needed, no Java Runtime Environment, nothing. Just Docker Engine and the container image containing your Java application. It is that simple.

How Do You Dockerize a Java Web Application: A Step-by-Step Tutorial for Developers

Let’s now have a look at how to work with Docker itself, how to create an image, start it and work with it.

Installing Docker

Installing the Docker Engine depends on the operating system you want to run your Java container images. You can install it by just downloading the installation package for your operating system from Docker’s website or using one of the package managers, the one that is suitable for your target platform. If you don’t have Docker installed and you would like to learn more about it I encourage you to take the time now and look at the official Docker installation guide page and after that continue with the rest of the article.

The Dockerfile

To build a new Java Docker image we need a file called Dockerfile. It is a text file that contains instructions that can and will be executed by Docker using the command line during the container image build. It is all that is needed.

Here is an example Dockerfile for a hypothetical Java application:

FROM openjdk:17-alpine3.14
WORKDIR /application
COPY build/libs/awesome-app-1.0.jar ./
CMD ["java", "-jar", "awesome-app-1.0.jar"]

And that’s all. As you can see, this is pretty straightforward, but let’s discuss that step by step.

The first thing in the Dockerfile is the line that tells Docker which image we would like to use as the base container image in our application:

FROM openjdk:17-alpine3.14

Docker images can be inherited from other images. For example, the above Java Docker image is the official image of the OpenJDK that comes with all the needed packages and tools required to run Java applications. You can find all the OpenJDK container images in the Docker Hub. We could of course list all the commands that are required to install Java Development Kit manually, but this makes it simpler.

The next step is calling the WORKDIR command. We use it to set the working directory to /application so that it is easier to run the rest of the commands.

The COPY command copies the awesome-app-1.0.jar file which contains our application to the working directory, which in our case is the /application.

And finally, the CMD command executes a command. In our example, it just runs the java -jar awesome-app-1.0.jar command launching the application. And that is all that we need.

Of course, there are other commands. Just to mention some:

  • RUN allows us to run any UNIX command during the container image build
  • EXPOSE exposes the ports from the container itself
  • ENV sets the environmental variable
  • ADD which copies new files to the container image file system

You can learn more about all of them from the Docker file reference available in the official Docker documentation.

You may have noticed that we’ve used an already built application from the local file system. This is a viable solution, but you may also want to build the application during Java container image build. We will look into how to achieve that just after we will learn how to work with the image.

Working with the Java Docker Image

Once we have the Dockerfile created we can build our Java container image and then run it turning it into a running container.

To build the image you would run a command like this:

docker build -t sematext/docker-awesome-app-demo:0.0.1-SNAPSHOT .

The above command tells Docker to build the image, give it a name and a tag (we will get back to that a bit later), and use the Dockerfile located in the current working directory (the . character).

Once the build is finished, we can start the container by running:

docker run sematext/docker-awesome-app-demo:0.0.1-SNAPSHOT

Those are the basics, but we know that Java projects are not built manually and don’t come prepackaged already. Instead, we use one of the build tools.

Let’s now look into Maven and Gradle, two popular Java dependency and build management tools, and how to use them with Docker.

Building Java Docker Images with Maven

To demo how to create a Java Docker image with Maven I used the Spring Initializr as the simplest way to quickly build a web application using Maven. I just generated the simplest Maven project with Spring Web as the only dependency.

I downloaded the created archive and unpacked it which resulted in the following directory structure:

-rw-r--r--@ 1 gro  staff    429 12 mar 19:13 HELP.md<
-rwxr-xr-x@ 1 gro  staff  10284 12 mar 19:13 mvnw<
-rw-r--r--@ 1 gro  staff   6734 12 mar 19:13 mvnw.cmd<
-rw-r--r--@ 1 gro  staff   1223 12 mar 19:13 pom.xml<
drwxr-xr-x@ 4 gro  staff    128 12 mar 19:13 src

In addition to that, I created a simple Java class that works as the Spring RestController:

package com.sematext.demo;

import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;

@RestController
public class WelcomeController {
    @RequestMapping("/")
    public String index() {
        return "Welcome to Docker!";
    }
}

The Dockerfile that would use Maven and build the container for us could look as follows:

FROM maven:3.8-jdk-11
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests
CMD ["java", "-jar", "target/demo-0.0.1-SNAPSHOT.jar"]

The Dockerfile here is pretty straightforward. We use the Maven Java container image that includes Maven 3.8 and JDK 11. Next, we create the /project directory, copy the contents of the local directory to it, set the working directory to the directory that we created, and run the Maven build command. The last step is responsible for running our Docker Java container.

Now let’s try building the image by running:

docker build -t sematext/docker-example-demo:0.0.1-SNAPSHOT .

Once the build is done, we can try running the container by running the following command:

docker run -d -p 8080:8080 sematext/docker-example-demo:0.0.1-SNAPSHOT

The command tells the Docker Engine to run the given Docker container in the background (the -d option) and expose the 8080 port from inside the container to the outside world. If we didn’t do that, this port would not be reachable from the outside world. We could also use the EXPOSE command in the Dockerfile if we want to have some ports opened by default.

To see what is running inside the Docker Engine, we can just run the following command:

docker ps

And the result would be:

CONTAINER ID   IMAGE                                         COMMAND                  CREATED         STATUS         PORTS                    NAMES
3f2c861c6728   sematext/docker-example-demo:0.0.1-SNAPSHOT   "/usr/local/bin/mvn-…"   2 minutes ago   Up 2 minutes   0.0.0.0:8080->8080/tcp   infallible_driscoll

It tells us that our Java Docker container is running for 2 minutes and is called infallible_driscoll – yes, Docker will give random names to our containers, though we can control that as well. Random names may be fun and entertaining, but in production environments you may want to give your containers a meaningful name – for example one that includes the name of the application running in it. That way, you’ll be able to easily identify what is running in the container without looking at the container image name or inspecting.

And test if it really runs by running a simple curl command:

curl -XGET 'localhost:8080'

And the result of the above command would look as follows:

Welcome to Docker!

So our Spring RestController works.

Building Java Container Images with Gradle

Building the Java Docker image for Gradle is no different from what we just discussed when it comes to Maven. The only difference would be that we have to use Gradle as the build tool of choice, and the Dockerfile would look a bit different.

I won’t repeat all the steps here as they would be the same, but I wanted to show you how the Dockerfile looks when working with Gradle:

FROM gradle:7.4-jdk11
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN gradle build
CMD ["java", "-jar", "build/libs/demo-0.0.1-SNAPSHOT.jar"]

You can see it is very similar. The only difference is the base Java container image and the command that we run to build the project. Also, I adjusted the last command, the one responsible for running the application, just because of the final location of the jar file. Everything else stays the same – not only when it comes to the configuration but also when it comes to the commands that are run.

Tagging Docker Container Image

I would like to mention one more thing when building the Java Docker images – tags. Each container image has a tag – you can think of it as the version associated with it, a version that can be used to fetch and use the given version. By default, Docker will use a tag called latest, which points to the most recent version. But using the latest tag is not the best option. You may want to stick to a given major version not to run into compatibility issues.

The same goes with your container images – they should have tags, so you know what you are running. In our examples, we used the 0.0.1-SNAPSHOT as the tag. During the build process, we used the following command:

docker build -t sematext/docker-example-demo:0.0.1-SNAPSHOT .

The sematext in the above command is the organization, and the docker-example-demo is the name of the container image. Together they create the full Java container image name. The 0.0.1-SNAPHOT is the tag, and you provide it after the : character. You should pay attention to proper versioning of your containers so that the tags are meaningful and are not confusing your users.

Starting and Stopping the Docker Container

The first time you want to start the container you use the docker run command to create a writable container layer over the container image that you would like to run, for example:

docker run -d --name my_cnt sematext/docker-example-demo:0.0.1-SNAPSHOT

This is only needed the first time you run the container image. In the above example we run our built container image and we assigned a name my_cnt to that container. We can now try stopping it by running:

docker stop my_cnt

That will stop the container and along with it the application running inside the container itself. We can then start the container again, but this time not using the run command, but start. We do it like this:

docker start my_cnt

Finally, when the container is stopped and we no longer need it we can remove it by using the rm command:

docker rm my_cnt

This is a simple control over the lifecycle of the container.

Publishing the Docker Container Image

Finally, there is one last thing that you should be aware of – publishing of Java Docker container images. By default, when you build your container image it will be stored on your local disk and it won’t be available to any other system. That’s why we need to publish the image to a repository, such as the Docker Hub.

After logging in with your Docker account (you can create one at https://hub.docker.com) just select the tagged container image and run the following command:

docker push sematext/docker-example-demo:0.0.1-SNAPSHOT

After a while, depending on your internet connection and the size of the Java container image, your image will be pushed to Docker Hub. From now on the image is available to be pulled from Docker Hub, which means other machines can now access it. To do that you just use the pull command:

docker pull sematext/docker-example-demo:0.0.1-SNAPSHOT

Docker Hub is not the only option for pushing containers to remote repositories and you can run your own container registry. In the case of some organizations this is a necessity. We won’t be talking about such options, because this is out of scope of this post, but I wanted to mention that there are such possibilities.

Best Practices to Build a Java Container with Docker

When creating and running Java web applications inside Docker containers, there are best practices that you should follow if you want your environment to run for a longer time without any kind of issues..

1. Use Explicit Versions

When working with dependencies in your software, you usually set a given library that your application uses to a certain version. If that is a direct dependency, it won’t be upgraded until you manually change the version. And you should do the same with Docker container images.

By default, Docker will try to pull a container image with the tag called latest, which means that you may expect the image version to change over time. You shouldn’t rely on that mechanism. Instead, you should specify the exact version of the container image you want to use. It doesn’t matter if you use that image inside the Dockerfile or you are just running the container. Try sticking with the version, or you may encounter unexpected results when the version changes.

2. Use Multi-Stage Builds

When discussing the creation of the Java Docker container images, we said it is sometimes beneficial to build the application and the container image during a single build. While this is true, we don’t necessarily want to build them in the same Dockerfile. Such a Dockerfile can soon become large, complicated, and thus hard to maintain.

Luckily Docker comes with a solution for that. Instead of having a single, large Dockerfile, we can divide the creation into multiple stages – one that builds the application and one that builds the Java container image.

For example, we could introduce two steps in our build process, and this example Dockerfile illustrates that:

FROM maven:3.8-jdk-11 AS build_step
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests

FROM adoptopenjdk/openjdk11:jdk-11.0.11_9-alpine-slim
RUN mkdir /application
COPY --from=build_step /project/target/awesome-app-1.0.jar /application
WORKDIR /application
CMD ["java", "-jar", "awesome-app-1.0.jar"]

In the first step, which we refer to as build_step, we build the project using Maven and put the build in the directory called /project. The second step of the build uses a different image, creates a new folder called /application, and copies the build result into that folder. The key difference is that the COPY command specifies an additional flag, –from, which tells Docker the step from where the artifact should be copied.

3. Automate Any Needed Manual Steps

This is very simple and very important. You should remember that every manual step needed when your Java application starts should be automated. Such automation should be a part of the Dockerfile file or be present in a script run during container start. There may be various necessary things such as downloading external data, preparing some data, and virtually anything that is required for the application to start. If something requires a manual step, it should be automated when run in containers.

4. Separate Responsibilities

Unlike traditional application servers, containers are designed to split the responsibilities and have a single responsibility per container. The container should be a single unit of deployment and have a single responsibility, a single concern. Ideally, they should have a single process running.

Imagine a deployment of OpenSearch – for a larger deployment, you will usually have 3 master nodes, two client nodes, and multiple data nodes – each running in a separate container.

Some of the benefits of such an approach are isolation, scalability, and ease of management. Isolation because each process runs on its own and will not interfere with others. Scaling as we can scale each type of container independently from the others. Finally, ease of management because of how Docker monitors the container’s lifecycle and can react to it going down.

5. Limit Privileges

One of the good practices to ensure the security of your Java containers is limited privileges. The rule of thumb – don’t run your applications inside the container as root. You want to minimize the potential attack vectors that a malicious user can access.

Imagine that your container or application has a bug that allows executing code with the privileges of the user running the application inside the container. Such a command, run as root, has all the access rights, can read and write to every location, and much much more. With limited access and a dedicated user, the possibilities of a potential attack are limited.

I’m afraid that Docker will run our commands as root by default, but we can easily change that. Let’s alter the initial Dockerfile that we created and create a new user there. After the changes, our new Dockerfile would look as follows:

FROM openjdk:17-alpine3.14
WORKDIR /application
COPY build/libs/awesome-app-1.0.jar ./
RUN addgroup --system juser
RUN adduser -S -s /bin/false -G juser juser
RUN chown -R javauser:javauser /application
USER juser
CMD ["java", "-jar", "awesome-app-1.0.jar"]

What we just did is we created a new group called juser and a user called juser. Next we gave ownership of the /application folder inside the Java Docker image to the newly created user and finally switched to that user. After that, we just continue and start the application, simple as that.

6. Make Sure Java Is Container-Aware

To put it straight – don’t use older Java versions. Older Java versions are unaware of the containerized environments and can thus produce problems when running, such as not being bound to the limits applied on the container. Because of that, the minimal version of Java you should be using is Java 1.8.0_191, but you would be far better with using one of the more recent versions.

If you want to learn about some of the issues that can happen when running earlier versions of Java in Docker containers, look at our DockerCon lighting talk OOps, OOMs, Oh My! Containering JVM Applications.

7. Build One Java Docker Image for All Environments

The benefit of having Dockerized Java applications is that you can and should build a single image in your CI/CD pipeline and then re-use that container image across all environments where it should be run. Regardless of whether you will be deploying to production, test, QA, or any other environment, you should use the same Java Docker image. The difference should only be in the configuration. And this is where the next best practice comes in.

8. Separate Configuration and Code

All the variables needed to run the Java container should be provided at runtime. Depending on your system architecture, configuration variables should be provided to the container or injected. Don’t hard code configuration variables – this will make it impossible to re-use the container images across multiple environments.

There are various ways you can achieve that. You can pass the configuration via environment variables which is very common in the container world. You can use network-based configuration services – for example, the Spring Cloud Config. Lastly, you can just mount a volume with a dedicated properties file containing all the needed configurations. The key is not to hard code the configuration in the Java Docker image.

9. Tune Your Java Virtual Machine Parameters

Running your Java code inside the container doesn’t mean you don’t have to take care of proper configuration. That includes properly tuning the Java Virtual Machine parameters, so it can flawlessly work inside your containerized environment. That includes both Java Virtual Machine performance tuning as well as garbage collection tuning.

10. Monitor Your Java Docker Containers

You need to know what is happening in your environment. However, manually looking at each Java Docker container is impossible or very difficult since Docker comes with many new management challenges. That’s why you need a monitoring tool that will gather and present metrics from your containerized environment; allow you to look into the Java logs from the applications running inside the containers; show you Docker host utilization metrics; and at least have basic alerting functionality, so that you don’t have to be constantly looking into the presented data.

It may not be obvious at the beginning of your journey with containers. Still, monitoring becomes a necessity as soon as you go to production and want to be sure that the environment is healthy.

Monitor & Debug Containerized Java Applications with Sematext

Sematext Monitoring

Observability is not easy when it comes to Docker containers. You can have lots of containers. They are dynamic and can be scaled automatically. Looking at Docker logs, metrics and health is just not possible manually. You need a good Docker monitoring tool that can help you with all those tasks providing you with all the necessary information. Sematext Monitoring with its Container Monitoring capabilities is just such a tool. To learn more about Sematext Infrastructure Monitoring, check out the short video below.

 

With a lightweight agent running as another container in your environment, you get access to all the host and container metrics – there are no blind spots. You can easily see how your Docker host resources are utilized, how the containers’ resources are utilized and, more importantly, monitor the Java applications running inside Docker containers with the automatic discovery. Ship the logs from your applications and view them together with metrics, alert on them, all inside a single monitoring solution.

There’s a 14-day free trial for you to try out Sematext Monitoring. Sign up and learn how Sematext can help you make your containers observable!

Java Logging Basics: Concepts, Tools, and Best Practices

Imagine you're a detective trying to solve a crime, but...

Best Web Transaction Monitoring Tools in 2024

Websites are no longer static pages.  They’re dynamic, transaction-heavy ecosystems...

17 Linux Log Files You Must Be Monitoring

Imagine waking up to a critical system failure that has...