Create a multi-platform container image with Java SE Subscription Enterprise Performance Pack

Every Java release ships with numerous features, like JEP 254: Compact Strings in Java 9 and JEP 377: ZGC: A Scalable Low-Latency Garbage Collector (Production) in Java 15, etc. As these changes are available starting with those releases onward, developers who cannot upgrade their Java applications cannot use those features.

The Java SE Subscription Enterprise Performance Pack (EPP) brings the improvements available to Java 17 to the Java 8 family of applications. Java EPP is intended to be a drop-in replacement for any Java 8 backend application. Customers who have adopted Java EPP saw considerable gains in the form of reduced garbage collection time, lower memory footprint, and better throughput.

As container images are a common way to run modern applications, this article aims to show you how to create a multi-platform Java EPP container image compatible with both the amd64 and the arm64 architectures. Using this approach, you can run the same Java code on different architectures, thus enabling you to move the workload to the platform that offers the best performance.

Prerequisites

The Java EPP is included in the Oracle Java SE Subscription and is also available for download under the Java SE OTN License for development, prototyping, etc. To try the examples showed here, you need the following:

  1. An Oracle account to download the Java EPP binaries under the OTN license or an Oracle Java SE Subscription.
  2. A docker compatible build tool that support creation of multi-platform container images, such as the buildx and BuildKit.
  3. A container registry where to push the container image. This example uses a custom container registry created in OCI, but any container registry will do.

Download the Java EPP binaries

You can download the Java EPP binaries from the following places:

You should download the .tar.gz file for each architecture (amd64 and arm64) and place them in a directory called binaries. You can use tree command to validate the presence of the binaries in the ./binaries directory:

$ tree './binaries'

./binaries
├── jdk-8u371-perf-linux-aarch64.tar.gz
└── jdk-8u371-perf-linux-x64.tar.gz

NOTE The Java EPP binaries have a distinctive -perf in their file name.


You do not need to extract these binary files. These files be copied into the container images and extracted during the container image creation.

Produce the application JAR files

Create the application JAR file using your preferred build tool, be it Gradle, Maven, Ant etc. You can package the application as either a single fat JAR file or as multiple JAR files.

Different build tools create the JAR file (and copy the dependencies) in different default output directories. For example, Gradle uses the ./build/libs directory while Maven uses the ./target directory as their respective output directories. In order to be agnostic of the build tool, we will assume that all application JAR files are in the ./jars directory.

In this example, the application main class is in package demo and we will start the application using the command:

$ java -classpath './jars/*' demo.Main

Create a multi-architecture Dockerfile

Container images are a common way to package, distribute, and run modern applications. Developers package their code with all its dependencies into a container image and distribute it over container registries, such as the Oracle Container Registry, to become directly accessible to the container hosting environment at runtime. The users of the container image do not have to worry about how to configure the application or what version of Java to be used, as everything is encapsulated inside the container image.

To simplify adoption of a technology across different architectures, many container images are built with multi-platform support, like the container-registry.oracle.com/java/jdk-no-fee-term:17.0.7-oraclelinux8 container image.

Docker streamlined multi-platform support through its buildx plugin and --platform option. Using these features, users of our container image can point to the same tag and docker will then choose the right container image for their architecture. For example, the JDK 17 container image has multi-platform support and is available for both the amd64 and arm64 architectures.

You can use inspect option to check all supported architectures under an image tag:

$ docker buildx imagetools \
  inspect container-registry.oracle.com/java/jdk-no-fee-term:17.0.7-oraclelinux8

Name:      container-registry.oracle.com/java/jdk-no-fee-term:17.0.7-oraclelinux8
MediaType: application/vnd.docker.distribution.manifest.list.v2+json
Digest:    sha256:6eb6accdd2afb3118d6487b358a928ec9c6d3fb9abd30107c9f15d0e05634e18

Manifests:
  Name:      container-registry.oracle.com/java/jdk-no-fee-term:17.0.7-oraclelinux8@sha256:d93847cfa8a7e66ea7fea7b6aaf12b225e5e4c9fdef3a6a3544c8ce3aaa79acc
  MediaType: application/vnd.docker.distribution.manifest.v2+json
  Platform:  linux/arm64

  Name:      container-registry.oracle.com/java/jdk-no-fee-term:17.0.7-oraclelinux8@sha256:ff56f1ff813ff71e5bbe2804e1eedd3cd42983e87e3b5ff8caf8a62da6bf9027
  MediaType: application/vnd.docker.distribution.manifest.v2+json
  Platform:  linux/amd64

As we aim to support both amd64 and the arm64 architectures, we will use multi-stage docker builds where we define an intermediate stage for each architecture. This is similar to Java polymorphism as the docker daemon will use the right stage depending on the target architecture. The Oracle Linux 9 base image will be used as base image in our example, but you can choose any 64-bit Linux distribution that supports both amd64 and the arm64 architectures.


NOTE You should be aware that Java EPP runs only on 64-bit Linux.


The Dockerfile contains four stages, and we will build this file one stage at a time, describing each stage as we go along. Please feel free to skip ahead to see the final example.

1⋅ Create the base stage that contains just the operating system

FROM container-registry.oracle.com/os/oraclelinux:9 AS base
WORKDIR /opt

The container image build tool will use the correct container image based on the desired architecture. For example, when building the container image for amd64 architecture, the amd64 version of the OS is used.

2⋅ Add a stage for the arm64 architecture that extends the base OS stage

In this stage we will carry out only the configuration needed by the arm64 architecture. Copy the arm64 Java EPP binary (./binaries/jdk-8u371-perf-linux-aarch64.tar.gz) and extract it into the /opt/jdk directory.

FROM base AS base-arm64
COPY ./binaries/jdk-8u371-perf-linux-aarch64.tar.gz jdk-8u371-perf-linux-aarch64.tar.gz
RUN tar xvfz jdk-8u371-perf-linux-aarch64.tar.gz \
       && rm jdk-8u371-perf-linux-aarch64.tar.gz \
       && mv jdk1.8.0_371 jdk

You should update the path if you have saved the jdk-8u371-perf-linux-aarch64.tar.gz binary file elsewhere.

Note that the stage name, base-arm64, contains the architecture name in it. When building the arm64 variant of this container image, the container image build tool will pick this stage based on its name.

Our Dockerfile now contains two stages.

FROM container-registry.oracle.com/os/oraclelinux:9 AS base
WORKDIR /opt


FROM base AS base-arm64
COPY ./binaries/jdk-8u371-perf-linux-aarch64.tar.gz jdk-8u371-perf-linux-aarch64.tar.gz
RUN tar xvfz jdk-8u371-perf-linux-aarch64.tar.gz \
       && rm jdk-8u371-perf-linux-aarch64.tar.gz \
       && mv jdk1.8.0_371 jdk

3⋅ Add a stage for the amd64 architecture that extends the base OS stage

This is similar to the previous stage, but it has two key differences. This stage is named base-amd64 and it uses the amd64 Java EPP binary (./binaries/jdk-8u371-perf-linux-aarch64.tar.gz) instead.

FROM base AS base-amd64
COPY ./binaries/jdk-8u371-perf-linux-x64.tar.gz jdk-8u371-perf-linux-x64.tar.gz
RUN tar xvfz jdk-8u371-perf-linux-x64.tar.gz \
       && rm jdk-8u371-perf-linux-x64.tar.gz \
       && mv jdk1.8.0_371 jdk

In both stages, docker daemon extracts Java EPP into the /opt/jdk directory. While this is not a must, extracting both versions in the same directory path simplifies the subsequent stages as these can assume that Java is found under the /opt/jdk directory.

The Dockerfile now has three stages.

FROM container-registry.oracle.com/os/oraclelinux:9 AS base
WORKDIR /opt


FROM base AS base-arm64
COPY ./binaries/jdk-8u371-perf-linux-aarch64.tar.gz jdk-8u371-perf-linux-aarch64.tar.gz
RUN tar xvfz jdk-8u371-perf-linux-aarch64.tar.gz \
       && rm jdk-8u371-perf-linux-aarch64.tar.gz \
       && mv jdk1.8.0_371 jdk


FROM base AS base-amd64
COPY ./binaries/jdk-8u371-perf-linux-x64.tar.gz jdk-8u371-perf-linux-x64.tar.gz
RUN tar xvfz jdk-8u371-perf-linux-x64.tar.gz \
       && rm jdk-8u371-perf-linux-x64.tar.gz \
       && mv jdk1.8.0_371 jdk

Up to now we have a generic multi-platform container image that uses Java EPP.


NOTE JAVA_HOME environment variable is not set and the java executable file is not on the PATH (it will be configured in the following stage). An alternative approach is to insert another stage between this stage and the next stage where we define the JAVA_HOME environment variable and put the java executable file on the PATH.


4⋅ Create the final stage that extends one of the previous stages at build time

The docker buildx plugin provides several platform arguments (ARGs) in the global scope, such as TARGETARCH. Please keep in mind that these are only available when using the docker buildx plugin.

The argument TARGETARCH contains the name of target architecture, such as amd64 and arm64:

FROM base-${TARGETARCH}

When building a container image for the amd64 architecture, the above instruction evaluates to

FROM base-amd64

A similar evaluation happens when building a container image for the arm64 architecture.

The rest of the stage is fairly standard.

FROM base-${TARGETARCH}
WORKDIR /opt/app
ENV JAVA_HOME "/opt/jdk"
ENV PATH "${PATH}:${JAVA_HOME}/bin"

COPY ./jars .

ENTRYPOINT ["java", "-classpath", "./*", "demo.Main"]

The final Dockerfile looks similar to:

FROM container-registry.oracle.com/os/oraclelinux:9 AS base
WORKDIR /opt


FROM base AS base-arm64
COPY ./binaries/jdk-8u371-perf-linux-aarch64.tar.gz jdk-8u371-perf-linux-aarch64.tar.gz
RUN tar xvfz jdk-8u371-perf-linux-aarch64.tar.gz \
    && rm jdk-8u371-perf-linux-aarch64.tar.gz \
    && mv jdk1.8.0_371 jdk


FROM base AS base-amd64
COPY ./binaries/jdk-8u371-perf-linux-x64.tar.gz jdk-8u371-perf-linux-x64.tar.gz
RUN tar xvfz jdk-8u371-perf-linux-x64.tar.gz \
    && rm jdk-8u371-perf-linux-x64.tar.gz \
    && mv jdk1.8.0_371 jdk


FROM base-${TARGETARCH}
WORKDIR /opt/app
ENV JAVA_HOME "/opt/jdk"
ENV PATH "${PATH}:${JAVA_HOME}/bin"

COPY ./jars .

ENTRYPOINT ["java", "-classpath", "./*", "demo.Main"]

We can proceed to building this container image.

Build and publish the multi-platform container image

As mentioned in the prerequisites section, you will need the following:

  1. A buildx context that supports the amd64 and arm64 architectures
  2. A container registry where the image will be published

First, you should verify the existence of a buildx context that supports both the amd64 and arm64 architectures. You can list all available using the docker buildx ls command:

$ docker buildx ls

NAME/NODE                 DRIVER/ENDPOINT                           STATUS  BUILDKIT PLATFORMS
...
multi-platform-builder *  docker-container
  multi-platform-builder0 unix:///~/.colima/docker.sock running v0.11.5  linux/arm64, linux/amd64, linux/amd64/v2
...

If a context is not available, you can create one that supports both linux/arm64 and linux/amd64 using docker buildx create command:

$ docker buildx create \
  --name multi-platform-builder \
  --driver docker-container \
  --bootstrap

Set the new context as the current build context with docker buildx use command:

$ docker buildx use multi-platform-builder

Finally, build the container image targeting both linux/amd64 and linux/arm64 platforms and push it to your container registry, having the semantic version 1.0.0:

$ docker build \
  --platform linux/amd64,linux/arm64 \
  --tag iad.ocir.io/xxxxxxxxxxxx/epp_multi_platform_containers:1.0.0 \
  --push
  .

Inspect the newly created container image and verify that both the amd64 and the arm64 architectures are supported:

$ docker buildx imagetools \
  inspect iad.ocir.io/xxxxxxxxxxxx/epp_multi_platform_containers:1.0.0

Name:      iad.ocir.io/xxxxxxxxxxxx/epp_multi_platform_containers:1.0.0
MediaType: application/vnd.oci.image.index.v1+json
Digest:    sha256:870e5bd6d83a718a17c460fc9b25741295b581c0f321ffe9371ef827c35121a8

Manifests:
  Name:        iad.ocir.io/xxxxxxxxxxxx/epp_multi_platform_containers:1.0.0@sha256:3c8669dd275f8dd556c33b1b75890b21d690a5c3bd32879a6a92090a3bccca71
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    linux/amd64

  Name:        iad.ocir.io/xxxxxxxxxxxx/epp_multi_platform_containers:1.0.0@sha256:bde47574310fcca1b29122d592dee291674540fc6083a0b03debfae9d2ef9ed9
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    linux/arm64
...

Once you published the container image, you can run it:

$ docker run iad.ocir.io/xxxxxxxxxxxx/epp_multi_platform_containers:1.0.0

Final Thoughts

This article combines the Java EPP together with multi-platform container images to enable delivery of high performing Java 8 applications using modern distribution channels. This approach enables you to run your Java 8 applications with the Java 17 benefits via Java EPP.


References

[1] Java EPP User Guide

[2] Complete example (without the Java EPP binaries)