Containerization solves nearly all of those externalities. While 100% may be an elusive goal in any pursuit, the ability to package a Java app's executable and all of its required dependencies and supporting properties (configuration, etc.) gets us to an effective 100% level of portability and consistency.
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.
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.
The minimum viable Dockerfile for a basic Java application looks something like this:
Save the above text (using your application's name in the directive) in a file called Dockerfile in a directory with your Java application () file.
In the above Dockerfile, we provide the essential information to build the container image:
Execute the following command from the directory containing your Dockerfile and file:
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:
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:
Note that this Java-optimized base image preconfigures the for the command, so the instruction is used to provide command-line arguments for the JVM launcher process.
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 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., 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.
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 , , or 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 :
The same concept works for app-specific variables:
You may have noticed that both and 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 directive is used for Java applications so that OS signals can be processed by the app for supported hook mechanisms -- e.g., for . This isn't absolutely necessary, of course, and cases can (and often are) made for using both and to facilitate runtime parameter passing for providing/overriding specific behaviors, for example. The two aren't mutually exclusive.