Do you want to build a Java application and run it in Docker? Do you know what the best practices are for building Java containers with Docker?

In the following quick checklist, I’ll provide you with best practices for building production-grade Java containers designed to optimize and secure Docker images to be put into production environments.

Building a simple Java container image

Let’s start with a simple Dockerfile. When building a Java container, we often have something like the following.

1
2
3
4
5
6
FROM maven
RUN mkdir /app
WORKDIR /app
COPY . /app
RUN mvn clean install
CMD "mvn" "exec:java"
1
2
$ docker build . -t java-application
$ docker run -p 8080:8080 java-application

This is simple and works. However, this image is full of bugs.

Not only should we understand how to use Maven properly, but we should also avoid building Java containers like the example above.

Let’s start improving this Dockerfile step by step to generate efficient and secure Docker images for your Java applications.

1. Docker images use deterministic tags

When building Java container images with Maven, we first need to base them on Maven images. But do you know what is actually introduced when using a Maven base image?

When you build a image using the following lines of code, you will get the latest version of that Maven image.

1
FROM maven

This seems like an interesting feature, but there are some potential problems with this strategy of using the Maven default image.

  • Your Docker builds are not idempotent. This means that each build may turn out to be completely different, and the latest image today may be different from the latest image tomorrow or next week, resulting in different bytecode for your application and possible surprises. Therefore, when building images, we want to have reproducible deterministic behaviour
  • Maven Docker images are based on the full operating system image. This results in many other binaries appearing in the final production image, but running your Java application does not require many of these binaries. Therefore, there are some drawbacks to having them as part of the Java container image: 1) the image becomes larger, leading to longer download and build times. 2) the extra binaries may introduce security vulnerabilities.

How to solve it?

  • Use the smallest base image that fits your needs. Consider this - do you need a full operating system (including all the extra binaries) to run your program? If not, perhaps an alpine image or Debian-based image would be better.
  • Use a specific image If you use a specific image, you can already control and predict certain behavior. If I use the maven:3.6.3-jdk-11-slim image, I have already determined that I am using JDK 11 and Maven 3.6.3. Updates to the JDK and Maven will no longer affect the behavior of the Java container. To be more precise, you can also use a SHA256 hash of the image. Using a hash will ensure that you use the exact same base image every time you build the image.

Let’s update our Dockerfile with this knowledge: the

1
2
3
4
5
FROM maven:3.6.3-jdk-11-slim@sha256:68ce1cd457891f48d1e137c7d6a4493f60843e84c9e2634e3df1d3d5b381d36c
RUN mkdir /app
WORKDIR /app
COPY . /app
RUN mvn clean package -DskipTests

2. Install only what is needed in the Java image

The following command will build the Java program in the container, including all its dependencies. This means that both the source code and the build system will be part of the Java container.

1
RUN mvn clean package -DskipTests

We all know that Java is a compiled language. This means that we only need the artifacts created by your build environment, not the code itself. This also means that the build environment should not be part of the Java image.

To run the Java image, we also don’t need the full JDK. a Java Runtime Environment (JRE) is sufficient. So, essentially, if it is a runnable JAR, then only the JRE and compiled Java artifacts need to be used to build the image.

Use Maven to build the compiled application both in the CI pipeline and then copy the JAR to the image, as shown in the following updated Dockerfile.

1
2
3
4
5
FROM openjdk:11-jre-slim@sha256:31a5d3fa2942eea891cf954f7d07359e09cf1b1f3d35fb32fedebb1e3399fc9e
RUN mkdir /app
COPY ./target/java-application.jar /app/java-application.jar
WORKDIR /app
CMD "java" "-jar" "java-application.jar"

3. Building a Java image using multiple phases

Earlier in this article, we talked about how we don’t need to build Java applications in containers. However, in some cases, it is convenient to build our application as part of a Docker image.

We can divide the build of a Docker image into multiple phases. We can build the image using all the tools needed to build the application and create the actual production image in the final stage.

1
2
3
4
5
FROM maven:3.6.3-jdk-11-slim@sha256:68ce1cd457891f48d1e137c7d6a4493f60843e84c9e2634e3df1d3d5b381d36c AS build
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests
1
2
3
4
5
FROM adoptopenjdk/openjdk11:jre-11.0.9.1_1-alpine@sha256:b6ab039066382d39cfc843914ef1fc624aa60e2a16ede433509ccadd6d995b1f
RUN mkdir /app
COPY --from=build /project/target/java-application.jar /app/java-application.jar
WORKDIR /app
CMD "java" "-jar" "java-application.jar"

4. Preventing sensitive information leakage

When creating Java applications and Docker images, it is likely that you will need to connect to private repositories, and configuration files like settings.xml often leak sensitive information. However, when using multi-stage builds, you can safely copy settings.xml to your build container. Settings with credentials will not appear in your final image. In addition, if you use the credentials as command line arguments, you can safely perform this action in the build image.

With a multi-stage build, you can create multiple stages and only copy the results to the final production image. This separation is one way to ensure that no data is leaked in the production environment.

Oh, and by the way, use the docker history command to view the output of the Java image.

1
$ docker history java-application

The output only shows information from the container image, not the process of building the image.

5. Do not run the container as root user

When creating Docker containers, you need to apply the principle of least privilege to prevent that if for some reason an attacker is able to hack your application, then you don’t want them to be able to access everything.

Having multiple layers of security can help you reduce threats to your system. Therefore, it is important to make sure that you do not run your application as the root user.

However, by default, when you create a Docker container, you will run it as root. While this is convenient for development, you don’t want to use it in a production image. Suppose for some reason an attacker has access to the terminal or can execute code. In that case, it has significant privileges over the running container and access to the host filesystem.

The solution is very simple. Create a specific user with limited privileges to run your application, and make sure that user can run the application. Finally, don’t forget to use the newly created user before running the application.

Let’s update our Dockerfile accordingly.

1
2
3
4
5
FROM maven:3.6.3-jdk-11-slim@sha256:68ce1cd457891f48d1e137c7d6a4493f60843e84c9e2634e3df1d3d5b381d36c AS build
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests
1
2
3
4
5
6
7
8
FROM adoptopenjdk/openjdk11:jre-11.0.9.1_1-alpine@sha256:b6ab039066382d39cfc843914ef1fc624aa60e2a16ede433509ccadd6d995b1f
RUN mkdir /app
RUN addgroup --system javauser && adduser -S -s /bin/false -G javauser javauser
COPY --from=build /project/target/java-application.jar /app/java-application.jar
WORKDIR /app
RUN chown -R javauser:javauser /app
USER javauser
CMD "java" "-jar" "java-application.jar"

6. Java applications should not use processes with a PID of 1

In many examples, I have seen common mistakes in using the build environment to launch containerized Java applications.

Above, we understand the importance of using Maven or Gradle in the Java container, but using the following command makes a difference.

  • CMD “mvn” “exec:java”

  • CMD [“mvn”, “spring-boot run”]

  • CMD “gradle” “bootRun”

  • CMD “run-app.sh”

When running an application in Docker, the first application will run with a process ID of 1 (PID=1). The Linux kernel handles processes with a PID of 1 in a special way. Usually, the process on a PID with process number 1 is the initialization process. If we are running a Java application with Maven, how can we be sure that Maven is forwarding SIGTERM-like signals to the Java process?

If you run the Docker container as in the example below, the Java application will have a process with a PID of 1.

1
CMD "java" "-jar" "application.jar"

Note that the docker kill and docker stop commands only send signals to container processes with a PID of 1. For example, if you are running a shell script for a Java application, /bin/sh will not forward the signal to a child process.

More importantly, in Linux, the container process with a PID of 1 has some other responsibilities. So, in some cases, you don’t want the application to be a PID 1 process because you don’t know how to deal with these issues. A good solution is to use dumb-init.

1
2
RUN apk add dumb-init
CMD "dumb-init" "java" "-jar" "java-application.jar"

When you run your Docker container like this, dumb-init takes over the container process with a PID of 1 and assumes all responsibility. Your Java process no longer needs to take this into account.

Our updated Dockerfile now looks like this.

1
2
3
4
5
FROM maven:3.6.3-jdk-11-slim@sha256:68ce1cd457891f48d1e137c7d6a4493f60843e84c9e2634e3df1d3d5b381d36c AS build
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests
1
2
3
4
5
6
7
8
9
FROM adoptopenjdk/openjdk11:jre-11.0.9.1_1-alpine@sha256:b6ab039066382d39cfc843914ef1fc624aa60e2a16ede433509ccadd6d995b1f
RUN apk add dumb-init
RUN mkdir /app
RUN addgroup --system javauser && adduser -S -s /bin/false -G javauser javauser
COPY --from=build /project/target/java-code-workshop-0.0.1-SNAPSHOT.jar /app/java-application.jar
WORKDIR /app
RUN chown -R javauser:javauser /app
USER javauser
CMD "dumb-init" "java" "-jar" "java-application.jar"

7. Elegantly offline Java applications

When your application receives a shutdown signal, ideally, we want everything to close properly. Depending on how you developed your application, an interrupt signal (SIGINT) or CTRL + C may result in immediate termination of the process.

This may not be something you want, as things like this can lead to unexpected behavior and even to data loss.

When you run your application as part of a web server like Payara or Apache Tomcat, that web server will probably shut down normally. This is also true for some frameworks that support runnable applications. For example, Spring Boot has an embedded version of Tomcat that can effectively handle shutdowns.

When you create a standalone Java application or manually create a runnable JAR, you have to handle these interrupt signals yourself.

The solution is simple. Add an exit hook, as shown in the example below. Upon receiving a SIGINT-like signal, the process that gracefully offlines the application will be started.

1
2
3
4
5
6
Runtime.getRuntime().addShutdownHook(new Thread() {
   @Override
public void run() {
       System.out.println("Inside Add Shutdown Hook");
   }
});

Admittedly, this is a generic Web application issue compared to Dockerfile-related issues, but it is more important in a container environment.

8. Using .dockerignore files

To prevent unnecessary files from contaminating your git repository, you can use a .gitignore file.

For Docker images, we have something similar - the .dockerignore file. Similar to git’s ignore file, it is designed to prevent unwanted files or directories from appearing in a Docker image. Also, we don’t want sensitive information to leak into our Docker images.

See the following example of .dockerignore.

1
2
3
4
.dockerignore
**/*.logDockerfile
.git
.gitignore

The main points of using the .dockerignore file are.

  • Skip dependencies that are only used for testing purposes.
  • Keep you from leaking key or credential information into the files of the Java Docker image.
  • Also, log files may contain sensitive information that you do not want to make public.
  • Keeps Docker images nice and tidy, essentially making them smaller. On top of that, it helps prevent accidental behavior.

9. Make sure the Java version supports containers

The Java Virtual Machine (JVM) is an amazing thing. It will tune itself to the system it is running on. There are behavior-based adjustments that can dynamically optimize the heap size. However, in older versions of Java such as Java 8 and Java 9, the JVM does not recognize CPU limits or memory limits set by the container. The JVM in these older Java versions saw all the memory and all the CPU capacity on the host system. limits set by Docker would be ignored.

With the release of Java 10, the JVM is now container-aware and can recognize container-set constraints. The feature UseContainerSupport is a JVM flag that is set to active by default. the container-aware feature released in Java 10 has also been ported to Java-8u191.

For versions prior to Java 8, you can manually try to use this -Xmx flag to limit the heap size, but it is a painful exercise. Immediately after, the heap size is not equal to the memory used by Java. For Java-8u131 and Java 9, the container-aware feature is experimental and you must actively activate it.

1
-XX:+ UnlockExperimentalVMOptions -XX:+ UseCGroupMemoryLimitForHeap

The best option is to update Java to version 10 or higher so that containers are supported by default. Unfortunately, many companies still rely heavily on Java 8. This means you should update to the latest version of Java in your Docker image, or make sure to use at least Java 8 update 191 or later.

10. Use container automation generation tools carefully

You may stumble upon great tools and plugins for build systems. In addition to these plugins, there are some great tools that can help you create Java containers and even automate the publishing of applications as needed.

From a developer’s perspective, this looks great because you don’t have to expend effort maintaining a Dockerfile while creating the actual application.

An example of such a plugin is JIB. as shown below, I can build the image by simply calling the mvn jib:dockerBuild command.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>2.7.1</version>
<configuration>
<to>
<image>myimage</image>
</to>
</configuration>
</plugin>

It will build a Docker image with the specified name for me without any hassle.

When using 2.3 and higher, you can do this by calling the mvn command

1
mvn spring-boot: build-image

In this case, the system will automatically create a Java image for me. These images are still relatively small because they are using a non-distribution image or buildpack as the base for the image. But how do you know that these containers are safe, regardless of the image size? You need to do a deeper investigation, and even then, you’re not sure if they’ll stay that way in the future.

I’m not saying you shouldn’t use these tools when creating Java Docker. However, if you are going to distribute these images, then you should look into all aspects of security for Java images. An image scan would be a good place to start. From a security perspective, my point of view