Many developers begin their containerization efforts by poring over the official Dockerfile reference documentation. To get great results immediately, let’s cover the key points, create some images, and build out from there.
Choosing an OS and JDK Builds for Containerization
There are various schools of thought on this, but if you’re just beginning to work with containerization, starting with a smaller, but full, operating system (OS) is a great first step. We’ll address other options (e.g., distroless) shortly.
As a general rule, the more you include in the OS layer, the larger the container image and the greater the attack surface for security exploits will be. Trusted sources are also a critical consideration. If using a full OS build, eclipse-temurin (based upon Ubuntu) or Alpine base layers are solid recommendations.
Any build of OpenJDK will run your JVM-based Java app, and Eclipse Temurin is one of many good options. If, however, you want dedicated production support for any Java issues you may discover, choosing a commercially supported build provides it.
Basic Dockerfile Structure for Java Apps
The minimum viable Dockerfile for a basic Java application looks something like this:
FROM eclipse-temurin:latest
COPY java-in-the-can-0.0.1-SNAPSHOT.jar /app.jar
EXPOSE 8080
CMD ["java", "-jar", "https://dzone.com/app.jar"]
Save the above text (using your application’s name in the COPY
directive) in a file called Dockerfile in a directory with your Java application (.jar
) file.
In the above Dockerfile, we provide the essential information to build the container image:
- The higher-level, base image
FROM
which the application container image is built - The command to
COPY
(and in this example, rename) the.jar
file into the image - Any specific port(s) to
EXPOSE
for the app to listen for connection requests (if necessary) - The command (
CMD
) to run the app on container startup
Execute the following command from the directory containing your Dockerfile and .jar
file:
docker build -t .
Note that the docker daemon (or Docker Desktop on Mac/Windows, Podman, etc.) must be running prior to running image creation and other container commands. Also, don’t forget the .
at the end of the command; it refers to the current directory where the Dockerfile can be found.
Run the resultant application container in this manner, substituting the container image name you created above:
docker run -p 8080: 8080
Choosing a Distroless OS+JDK Base Image
The best achievable optimization for most use cases, both in size and attack surface, may be provided by a “distroless” base image. While a Linux distribution (distro) is indeed included in a distroless base image, it is stripped of any files not specifically required for the purpose at hand, leaving a fully streamlined OS and, in the case of a distroless Java image, the JVM.
Here is an example of a Dockerfile that uses a distroless Java base image:
FROM mcr.microsoft.com/openjdk/jdk: 21-distroless
COPY java-in-the-can-0.0.1-SNAPSHOT.jar /app.jar
EXPOSE 8080
CMD ["-Xmx256m", "-jar", "https://dzone.com/app.jar"]
Note that this Java-optimized base image preconfigures the ENTRYPOINT
for the java
command, so the CMD
instruction is used to provide command-line arguments for the JVM launcher process.
Using Multi-Stage Builds to Reduce Image Size
Multi-stage builds provide the means to reduce the size of container images if you have files required for the build that aren’t required for the final output. For the purposes of this reference, that really isn’t the case because the JVM and the app’s .jar
file and dependencies are provided preconfigured for the creation of the image.
As you might imagine, there are very common circumstances where this becomes advantageous. Typically, applications are deployed to production using build pipelines configured to create artifacts based upon triggers on a source repository. This is one of the best use cases for multi-stage builds: The build pipeline creates a build container with the appropriate tools, uses it to create the artifacts (e.g., .jar
file, config files), and then copies those to a fresh container image without additional tooling unnecessary for production. This sequence of actions roughly parallels what we did manually earlier, automated for consistent and optimal results.
Managing Environment Variables
There are multiple ways to supply input values to the container and application for use in startup or execution. A good practice to adopt is to specify all values possible within the Dockerfile itself using ENV
, ENTRYPOINT
, or CMD
directives. All of these values can be overridden at the time of container initialization if needed.
Note that caution should be exercised when overriding existing environment variables as this can change application behavior in unexpected and undesirable ways.
Example of configuring Java-specific options using ENV
:
ENV JAVA_OPTS="-Xmx512m -Xms256m"
The same concept works for app-specific variables:
ENV APP_GREETING="Greetings, Friend!"
Example of application-specific values configured using ENTRYPOINT
:
ENTRYPOINT ["java", "$JAVA_OPTS", "-jar", "your-app.jar"]
Example using CMD
:
CMD ["java", "-Xmx256m", "-jar", "https://dzone.com/app.jar"]
You may have noticed that both ENTRYPOINT
and CMD
can be used to execute a Java application. Like every other technical (and non-technical) option, there are pros and cons for each of these two directives. Both will result in your Java application running if done properly.
Generally speaking, the CMD
directive is used for Java applications so that OS signals can be processed by the app for supported hook mechanisms — e.g., SIGTERM
for java.lang.Runtime.addShutdownHook
. This isn’t absolutely necessary, of course, and cases can (and often are) made for using both ENTRYPOINT
and CMD
to facilitate runtime parameter passing for providing/overriding specific behaviors, for example. The two aren’t mutually exclusive.