Blog Post

Apps on Azure Blog
7 MIN READ

Optimizing Docker Images for Java Applications on Azure Container Apps

Xiaoyun_Ding's avatar
Xiaoyun_Ding
Icon for Microsoft rankMicrosoft
Nov 04, 2024

Introduction

 

In the cloud-native era, the need for rapid application startup and automated scaling has become more critical, especially for Java applications, which require enhanced solutions to meet these demands effectively. In a previous blog post Accelerating Java Applications on Azure Kubernetes Service with CRaC, we explored using CRaC technology to address these challenges. CRaC enables faster application startup and reduces recovery times, thus facilitating efficient scaling operations. In this blog post, we’ll delve further into optimizing container images specifically for Azure Container Apps (ACA), by leveraging multi-stage builds, Spring Boot Layer Tools, and Class Data Sharing (CDS) to create highly optimized Docker images. By combining these techniques, you’ll see improvements in both the image footprint and the startup performance of your Java applications on ACA. These improvements make Java applications more agile and responsive to frequent cloud-native deployments, ensuring they can keep pace with modern operational demands


Key Takeaways

 

After testing different Docker optimization techniques, we observed the following improvements on Azure Container Apps:
  • Multi-stage builds reduced the image size by 33%, leading to faster image pulls.
  • Spring Boot Layer Tools further optimized the build process by reducing unnecessary rebuilds, slightly improving both image pull and startup times.
  • Class Data Sharing (CDS) provided the most impactful benefit, reducing application startup time by 27%, significantly enhancing runtime performance.
These optimizations not only reduce the size and pull time of the image but also make deployments more efficient, saving both time and resources.

Overview of Optimization Techniques

 

Multi-Stage Builds

 

Multi-stage builds are a powerful Docker feature that allows you to create leaner images by reducing the layers that make it to the final build. In the first stage, you can perform all heavy operations, such as compiling the code and downloading dependencies. In the final stage, only the necessary artifacts are copied over, leaving out the build-time dependencies.

By leveraging multi-stage builds, the overall image size is drastically reduced, ensuring faster pull times and a more efficient use of resources.

Spring Boot Layer Tools

 

The Spring Boot Layer Tools further optimize the layering process of your Docker images by logically splitting the application into layers. Each layer consists of different components of the Spring Boot application, such as dependencies, Spring Boot framework, and the application code itself. This ensures that Docker only rebuilds and caches the layers that have changed, which reduces build times and improves performance when pushing updates.

This also plays a role in improving startup times, as the unchanged layers can be loaded faster from cache.

Class Data Sharing (CDS)

 

CDS is a Java feature that allows you to share class metadata between different Java processes. By using AppCDS (Application Class Data Sharing), you can further optimize startup times by archiving your application’s classes during the build process. This reduces the class loading overhead at runtime, improving startup speed by avoiding redundant class loading operations.

When integrated with Docker, CDS can be used to store the shared class metadata, which is reused across container restarts, offering a significant boost to startup performance.

Applying the Techniques: Step-by-Step Optimization


Now, let’s walk through how you can progressively apply these techniques to optimize your Docker image and improve startup times. In the blog, we are using the repository spring-petclinic

Step 0: Starting Point: The Base Docker Image


Before diving into optimizations, let's look at a typical Dockerfile that might be used without any optimizations. This base Docker image includes only the essentials to run a Spring Boot application:

FROM mcr.microsoft.com/openjdk/jdk:17-ubuntu

WORKDIR /home/app
ADD . /home/app/spring-petclinic-main
RUN cd spring-petclinic-main && ./mvnw -Dmaven.test.skip=true clean package && cp ./target/*.jar /home/app/petclinic.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "petclinic.jar"]

 

This Dockerfile simply builds and copies the packaged JAR file and runs it using the default java -jar command. While functional, it lacks optimizations for image size and startup speed. The entire application, along with all dependencies, is bundled in a single layer, meaning any change to the application requires rebuilding the entire image. Additionally, no optimizations are applied to reduce the overhead of class loading at runtime. The startup time reflects the default class loading behavior of the JVM.
 

Base Image Metrics for Comparison

 

Optimization Stage Image Size (MB) Image pull time (s) Startup Time (s)
Base Image (No Optimization) 734             8.017               7.649                

It's also important to note that in order for the base Docker image to successfully compile the code, it often needs to include various build tools (e.g., Maven, JDK), which not only increases the image size but also introduces more potential vulnerabilities due to the inclusion of additional packages, making it more susceptible to CVEs (Common Vulnerabilities and Exposures).

Step 1: Using Multi-Stage Builds


Multi-Stage Builds help reduce the image size by separating the build environment from the runtime environment. In a multi-stage Dockerfile, we first compile the application in a build stage, then copy only the necessary runtime artifacts to a much slimmer runtime stage.

# Stage 1: Build Stage
FROM mcr.microsoft.com/openjdk/jdk:17-ubuntu AS builder

WORKDIR /home/app
ADD . /home/app/spring-petclinic-main
RUN cd spring-petclinic-main && ./mvnw -Dmaven.test.skip=true clean package

# Stage 2: Final Stage
FROM mcr.microsoft.com/openjdk/jdk:17-mariner

WORKDIR /home/app
EXPOSE 8080
COPY --from=builder /home/app/spring-petclinic-main/target/*.jar petclinic.jar
ENTRYPOINT ["java", "-jar", "petclinic.jar"]

Multi-Stage Build Metrics

 

Optimization Stage Image Size (MB) Image pull time (s) Startup Time (s)
Base Image (No Optimization) 734             8.017               7.649                
Multi-Stage Build     492             7.145               7.932                

By using multi-stage builds, the resulting image contains only the compiled application and its dependencies, leaving out the heavy build tools like Maven. With multi-stage builds, we see a 33% reduction in image size and a slight improvement in image pull time, but the startup time remains close to the base image.

Step 2: Optimizing with Spring Boot Layer Tools


Next, add Spring Boot Layer Tools to optimize how the application layers are structured:

# Stage 1: Build Stage
FROM mcr.microsoft.com/openjdk/jdk:17-ubuntu AS builder

WORKDIR /home/app
ADD . /home/app/spring-petclinic-main
RUN cd spring-petclinic-main && ./mvnw -Dmaven.test.skip=true clean package

# Stage 2: Layer Tool Stage
FROM mcr.microsoft.com/openjdk/jdk:17-ubuntu AS optimizer

WORKDIR /home/app
COPY --from=builder /home/app/spring-petclinic-main/target/*.jar petclinic.jar
RUN java -Djarmode=layertools -jar petclinic.jar extract

# Stage 3: Final Stage
FROM mcr.microsoft.com/openjdk/jdk:17-mariner

ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
COPY --from=optimizer /home/app/dependencies/ ./
COPY --from=optimizer /home/app/spring-boot-loader/ ./
COPY --from=optimizer /home/app/snapshot-dependencies/ ./
COPY --from=optimizer /home/app/application/ ./

 

Layer Tools Metrics

 

Optimization Stage Image Size (MB) Image pull time (s) Startup Time (s)
Base Image (No Optimization) 734             8.017               7.649                
Multi-Stage Build     492             6.987               7.932                
Spring Boot Layer Tools     493             7.104               7.805                

Typically, the Spring framework itself and its dependencies are significantly larger than the application-specific classes. By using Layer Tools, these large, rarely-changing components (like the Spring framework and external libraries) can be placed into lower layers of the image, while your application-specific code resides in the topmost layers. By organizing the image in this way, you're not only optimizing build times but also reducing bandwidth usage when transferring images. This ensures that only changed layers are updated, which results in a more efficient build and faster startup for subsequent updates.

Step 3: Integrating Class Data Sharing (CDS)


Finally, incorporate CDS for class loading optimization:

# Stage 1: Build Stage
FROM mcr.microsoft.com/openjdk/jdk:17-ubuntu AS builder

WORKDIR /home/app
ADD . /home/app/spring-petclinic-main
RUN cd spring-petclinic-main && ./mvnw -Dmaven.test.skip=true clean package

# Stage 2: Layer Tool Stage
FROM mcr.microsoft.com/openjdk/jdk:17-ubuntu AS optimizer

WORKDIR /app
COPY --from=builder /home/app/spring-petclinic-main/target/*.jar petclinic.jar
RUN java -Djarmode=tools -jar petclinic.jar extract --layers --launcher


# Stage 3: Optimize with CDS Stage
FROM mcr.microsoft.com/openjdk/jdk:17-mariner

COPY --from=optimizer /app/petclinic/dependencies/ ./
COPY --from=optimizer /app/petclinic/spring-boot-loader/ ./
COPY --from=optimizer /app/petclinic/snapshot-dependencies/ ./
COPY --from=optimizer /app/petclinic/application/ ./
RUN java -XX:ArchiveClassesAtExit=./application.jsa -Dspring.context.exit=onRefresh org.springframework.boot.loader.launch.JarLauncher
ENTRYPOINT ["java", "-XX:SharedArchiveFile=application.jsa", "org.springframework.boot.loader.launch.JarLauncher"]

This setup will significantly reduce class loading time, leading to faster application startup.

 

Optimizing with Class Data Sharing (CDS)

 

Optimization Stage Image Size (MB) Image pull time (s) Startup Time (s)
Base Image (No Optimization) 734             8.017               7.649                
Multi-Stage Build     492             6.987               7.932                
Spring Boot Layer Tools     493             7.104               7.805                
CDS                           560             7.145               5.562                

Conclusion


By incorporating multi-stage builds, Spring Boot Layer Tools, and Class Data Sharing into your Docker images, you can optimize both the size and performance of your Java applications on Azure Container Apps. Each technique addresses a different aspect of optimization—reducing image bloat, improving layer caching, and enhancing class loading efficiency. Together, they create a robust solution for building and deploying highly optimized Java applications in Docker containers. 
 
ACA is an ideal managed platform for running Java applications, offering features tailored to Java workloads, including built-in JVM metrics, diagnostics, and middleware support. These capabilities allow Java applications to perform optimally while simplifying management and scaling in a cloud-native environment. For more information, visit the Azure Container Apps Java Overview.
Updated Nov 01, 2024
Version 1.0
No CommentsBe the first to comment