Java container

10 best practices to build a Java container with Docker

So, you want to build a Java application and run it inside a Docker image? Wouldn’t it be awesome if you knew what best practices to follow when building a Java container with Docker? Let me help you out with this one!

In the following cheatsheet, I will provide you with best practices to build a production-grade Java container. In the Java container example, I build using these guidelines, I will focus on creating an optimized secure Java container for your application. This cheatsheet is a guide you can use when creating a Java container with Docker, but also when you review other people’s code. In both instances, it is important to focus on optimizing and securing the Docker image you want to put in production.

Java container
  1. Use explicit and deterministic Docker base image tags
  2. Install only what you need in production in the Java container image
  3. Find and fix security vulnerabilities in your Java Docker image
  4. Use multi-stage builds
  5. Don’t run Java apps as root
  6. Properly handle events to safely terminate a Java application
  7. Gracefully tear down Java applications
  8. Use .dockerignore
  9. Make sure Java is container-aware
  10. Be careful with automatic Docker container generation tools

Secure your Java container images

A simple Java container image build

Let’s start with a simple Dockerfile for a Java application created with Maven. In many articles, we see something similar when building a Java container, like the example below:

Most blog articles we’ve seen start and finish along the lines of the following basic Dockerfile instructions for building Java Docker images:

FROM maven
RUN mkdir /app
WORKDIR /app
COPY . /app
RUN mvn clean install
CMD "mvn" "exec:java"
Copy that to a file named Dockerfile, then build and run it.
$ docker build . -t java-application
$ docker run -p 8080:8080 java-application

It’s simple and it works. However, this image is full of mistakes. Not only should we be aware of how to use Maven properly, but we should avoid building Java containers like the example above, by all means.

Let’s improve this Dockerfile and optimize it step by step to have an efficient and secure Docker image for your Java application.

1. Use explicit and deterministic Docker base image tags

When building a Java container image with Maven, it seems obvious that you base your image on the Maven image—you can easliy pull this from Dockerhub and get started. However, are you aware what you are actually pulling in when using this base image? You can reference a Docker image by its tag. By not giving any tag you will get referred to the latest tag. 

This means that when you build your image using the line below, you will end up with the latest version of that Maven Java Docker image:

FROM maven

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

  • Your Docker builds are not idempotent. This means that when you rebuild the result can be totally different. The latest images today can be different from the latest image tomorrow or next week. Looking at this the versions of Maven and the JDK you are building your image upon can be upgraded. This means that the bytecode of your application is different and might cause unexpected results. When rebuilding an image we would like to have reproducible deterministic behavior.
  • The Maven Docker image is based on a full operating system image.
  • This results in many additional binaries ending up in your eventual production image. A lot of these binaries are not needed to run your application. But having them as part of the Java container image has some downsides:
    • a larger image size, which results in longer download and rebuild times.
    • the extra binaries can introduce security vulnerabilities. Every binary that contains a vulnerability is a potential security risk you do not want to add to your system.

The maven:latest image is quite large and is currently based on a version of Maven and OpenJDK that will change in a matter of months, maybe weeks.

Here’s how you mitigate this:

  • Use the smallest possible base image that fits your needs.
    Think about it—do you need a full operating system including all the extra binary to run your progam? If not, maybe an alpine-based image works just as well as a Debian based image.
  • Be very specific about the images.
    If you use a specific images, you can already control and predict certain behavior. If I use the image maven:3.6.3-jdk-11-slim I am already sure that I am using JDK 11 and Maven 3.6.3. The 6 months JDK update cycle will not impact the behavior of my Java container anymore. To be even more precise, you can use the SHA256 hash of the image. Using the hash will ensure you have the exact same base image every time you rebuild your image. 

Let’s update our Dockerfile with that knowledge:

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 you need in production in the Java container image

The following command builds your Java program including all its dependencies in the container. This means that both the source code and your build system are part of the production Java container. 

RUN mvn clean package -DskipTests

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

To run a Java image we also don’t need a full JDK. A Java Runtime Environment (JRE) is enough. So, essentially we need the build an image with a JRE and the compiled Java artifact if it is a runnable JAR.

Build your program in your CI pipeline using Maven (or Gradle) and copy the JAR to your production image, like in the updated Dockerfile below:

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. Find and fix security vulnerabilities in your Java container Docker image

I am already using a small image for my Docker Java container. However, I do not know if the binaries in this base image contains problems. Let’s use the  Snyk CLI to test our Docker image. You can sign up for a free Snyk account here.

I can install the CLI using npm, brew, scoop or download the latest binary from Github:

$ npm install -g snyk
$ snyk auth
$ snyk container test openjdk:11-jre-slim@sha256:31a5d3fa2942eea891cf954f7d07359e09cf1b1f3d35fb32fedebb1e3399fc9e --file=Dockerfile

I install the CLI using npm here and authenticate it with my free account I just created. With the snyk container test I can test any Docker image I want. Additionally, I can also add the Dockerfile for better remediation advice.

Snyk found 58 security issues in this base image. Most of them are related to binaries that ship with the Debian Linux distribution.
Based on this information, I will switch my base image to an alpine-based openjdk11:JRE image provided by adoptopenjdk.

FROM adoptopenjdk/openjdk11:jre-11.0.9.1_1-alpine@sha256:b6ab039066382d39cfc843914ef1fc624aa60e2a16ede433509ccadd6d995b1f

When testing this with snyk container there are no known vulnerabilities for this image.

In a similar way, you can test your Java application by executing snyk test at the root of your project. I advise you to both test your application and the created Java container image while developing on your local machine. Next to that, automate the same test for both the image and your application in your CI pipeline.

Also, remember that vulnerabilities will be found over time. Once a new vulnerability is found you probably want to get notified.

Using snyk monitor for your application and snyk container monitor for your Docker images, will help you with that. By daily  monitoring the version that is currently in production you will be able to act appropriately when new security issues are found.

Alternatively, you can also connect your git repository to Snyk, so we can help find and remediate vulnerabilities in that part of your SDLC.

Let’s update our current Dockerfile:

FROM adoptopenjdk/openjdk11:jre-11.0.9.1_1-alpine@sha256:b6ab039066382d39cfc843914ef1fc624aa60e2a16ede433509ccadd6d995b1f
 
RUN mkdir /app
COPY ./target/java-application.jar /app/java-application.jar
WORKDIR /usr/src/project
CMD "java" "-jar" "java-application.jar"

4. Use multi-stage builds for your Java container

Earlier in this article, we spoke about that we do not need to build our Java application in a container—we only need the product of that build. However, in some cases, it is convenient to build our application as apart of the Docker image.

Luckily we can separate the creation of your Docker image into multiple stages. We can create a build image using all the tools we need to build our application and create a second stage where we create the actual production image.

FROM maven:3.6.3-jdk-11-slim@sha256:68ce1cd457891f48d1e137c7d6a4493f60843e84c9e2634e3df1d3d5b381d36c AS build
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests
 
 
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"

Prevent sensitive information leak

When you are creating Java applications and Docker images for work, it is highly possible that you need to connect to a private repository. Normally, these go in your settings.xml on your local machine or build environment. When using multistage builds you can safely copy the settings.xml to your building container. The settings with the credentials will not end up in your production image. Also, if you need to use credentials as command line arguments, you can safely do this in the build image. This will not end up in the production image.

With multistage builds you can create multiple stages and only copy the result to your final production images. This is not only a great way to separate concerns but also a way to ensure not to leak data in your production environment.

Oh btw, take a look at the docker history output for the created Java container image:

$ docker history java-application

The output only shows information from the production container image, not the build image.

5. Don’t run containers as root

When creating a Docker container you need to apply the least privilege principle as part of your Java container security. If for some reason an attacker is able to penetrate your application you don’t want them to be able to access everything. 

Having multiple layers of security helps you decrease the potential damage an attacker can cause whenever your system is compromised. Therefore, you must be certain that you do not run your application as the root user.

There is only one issue here. When creating a Docker container, by default, you will run it as root. Although this is convenient for development, you do not want this in your production images. Suppose, for whatever reason, an attacker has access to a terminal or can execute code. In that case, it has significant privileges over that running container, as well as potentially accessing host filesystems via filesystem bind mounts with inappropriately high access rights.  

The solution is pretty straightforward. Create a specific user with limited privileges to run your application and make sure that user can run the application. Last, don’t forget to call the newly created user before running the application.

Let’s update our Dockerfile accordingly.

FROM maven:3.6.3-jdk-11-slim@sha256:68ce1cd457891f48d1e137c7d6a4493f60843e84c9e2634e3df1d3d5b381d36c AS build
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests
 
 
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. Properly handle events to safely terminate a Java Docker web application

In many examples, I see the common mistake of using the built environment to startup the containerized Java application.

Although we already discussed why we should be including Maven or Gradle in our Docker Java container, there are other reasons to avoid things like these:

  • 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 as process ID 1 (PID 1). The Linux kernel treats PID 1 in a special way. Typically, the process on PID 1 is the init process. If we run our Java application using Maven, how can we be sure that Maven forwards signals like SIGTERM to the Java process? We don’t!

If you run your Docker container like in the example below, your Java application will have PID 1.

CMD “java” “-jar” “application.jar”

Notice that docker kill and docker stop commands only send signals to the container process with PID 1. If you’re running a shell script that runs your Java application, then take note that a shell instance—such as /bin/sh, for example—doesn’t forward signals to child processes, which means your app will never get a SIGTERM.


Important to know is that in Linux, PID 1 has some additional responsibilities. They are described very nicely in the article “Docker and the PID 1 zombie reaping problem”. So, there are cases you don’t want to be PID 1 cause you don’t know how to deal with these problems. A great solution would be to use dumb-init.

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

When you run your Docker container like this, dumb-init occupies PID 1 and takes care of all the responsibilities. Your Java process doesn’t have to care of that anymore.

Our updated Dockerfile now looks something like this:

FROM maven:3.6.3-jdk-11-slim@sha256:68ce1cd457891f48d1e137c7d6a4493f60843e84c9e2634e3df1d3d5b381d36c AS build
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests
 
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. Graceful tear down for your Java web applications

When your application receives a signal to shut down, we ideally want everything to shut down gracefully. Depending on how you develop your application, an interrupt signal (SIGINT) or CTRL + C may cause an instant process kill.

This might not be something that you want—things like these can cause unexpected behavior or even loss of data. 

When you are running your application as part of a web server like Payara or Apache Tomcat, the web server will most likely take care of a graceful shutdown. The same might hold for certain frameworks that you can use to build a runnable application. Spring Boot, for instance, has an embedded Tomcat version that effectively takes care of the shutdown.

When you create a standalone Java application or manually create a runnable JAR you have to take care of these interrupt signals yourself. 

The solution is quite simple. You can add a shutdown hook to runtime, like in the example below. Once a signal like SIGINT is received, a new Thread will be started that can take care of a graceful shutdown.

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

Admittedly, this is more of a generic web application concern than Dockerfile related but is even more important in orchestrated environments.

8. Keeping unnecessary files out of your Java container images

To prevent certain files in your (public) git repository you can use the .gitignore file. It prevents polluting git repository with unnecessary files. In addition, it is a tool to prevent leaking sensitive files to a public repo. 

For Docker images, we have something similar—the .dockerignore file. Similar to the ignore file for git, it ignores the purpose. That is to prevent unwanted files or directories in your Docker image.

The simplified example used in this article so far, assumes we are working with a single executable JAR. This is not by any means the only way to deliver a Java application. In standalone Java applications, it can also be possible that we do not create a fat JAR where all dependencies are embedded inside the artifact. Depending on the context, we might need to copy complete directories with JARs our application depends on, or other files. We do not want sensitive information to slip into our Docker images by accident. Especially not if we publish these images publically. 

See an example of a .dockerignore below:

.dockerignore
**/*.log
Dockerfile
.git
.gitignore

The takeaways from using the .dockerignore file are:

  • Skips dependencies you use for testing purposes only.
  • Saves you from secrets exposure, such as credentials in the contents of .env or aws.json files making their way into the Java Docker image.
    • Also, debug log files might contain secrets of sensitive information that you don’t want to expose.
  • Keeps your Docker image nice and clean, essentially, making your images smaller. Next to that, it helps prevent unexpected behavior.

9. Make sure Java is container-aware

The Java Virtual Machine (JVM) is an amazing thing. It tunes itself based on the system it runs on. There is behavior-based tuning that dynamically optimizes the sizes of the heap. However, in older versions, like Java 8  and Java 9, the JVM did not recognize CPU limits or memory limits set by the container. The JVM of these older Java versions saw the whole memory and all CPU’s available on the host system. Basically, the Docker group settings are ignored.

With the release of Java 10, the JVM now is container-aware and recognizes the constraints set by the containers. The feature UseContainerSupport is a JVM flag and is set to active by default. This container awareness feature released in Java 10 is backported to Java-8u191. 

For versions older than Java 8,  you can manually try to restrict the heap size with the -Xmx flag but that is a painful exercise. Next to that, the heap size is not equal to the memory Java uses. For Java-8u131 and Java 9, the container awareness feature is experimental and you actively have to activate the experimental JVM options and the memory limit flag. 

-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap

Your best choice is to update to a newer version of Java—beyond 10—in order to have container support activated by default. Unfortunately, many companies are still relying heavily on Java 8. This means you should either update to a more recent version of Java in your Docker images or make sure that use at least Java 8 update 191, or higher. 

All this doesn’t sound specific to Java container security, does it? However, if you think about it, availability is one of the 3 attributes in the information security CIA triad.

10. Be careful with automatic Docker container generation tools

When scrolling the internet you will probably stumble upon great tools and plugins for your build system. Along with these plugins there are some great tools that can help you create Docker Java containers and even automatically publish theme, if you like.

From a developer perspective, this looks amazing as you don’t have to focus on maintaining Dockerfiles next to creating the actual application. 

An example of such a plugin is JIB. I only have to configure the build plugin, like seen below, and call mvn jib:dockerBuild:

<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 me a Docker image with the specified name without any hassle. 

Something similar can be done with Spring Boot when using version 2.3 and up, by calling the mvn target: 

mvn spring-boot:build-image

In both cases, the system automatically creates a Docker Java container for me. I must admit that these containers are olso relatively small. That is because they are using distroless images or buildpacks as a base for images. But regardless of the size of your image, how do you know these containers are safe? You need to do a deeper investigation and even then you are not sure if this status is maintained in the future.

Scanning both images with snyk container exposed a couple of vulnerabilities that are related to the base images these two systems are using. Next to that, we do not know if the user privileges are set correctly, among all the other things we already discussed here.

I am not saying you should not use these tools when creating Java Docker images. However, if you are planning to publish these images you should properly investigate all the Java container security aspects. Scanning the container would be a good start. My opinion is that, from a security perspective, having full control and create your own custom Dockerfile in a proper way is a better and more secure way of creating production images.

Summary

That last step wraps up this entire guide on containerizing Java Docker applications, taking into consideration performance and security-related optimizations to ensure we’re building production-grade Java Docker images!

Follow-up resources that I highly encourage you to review:

Secure your Java container images