Docker sizing Java application


Many developers put java in docker yet they face many issues from development to running containers.

Docker sizing Java application

As a Java developer, I faced many issues when dockerizing and running java applications.

Running dockrized java application

When we start our development we used java 8 as our runtime environment. We ran jars as separate processes (yes using java -jar ) in VM, at that time there was no requirement to dockerize applications and everything worked without any major issues.
Later we experienced the need for dockerizing applications. Then we faced many performance issues. Our initial docker environment was just docker containers running in a VM without any container orchestration. When we compared dockerized application with normal java application, the performance of dockerized application was not good. When we set the memory limits for docker application, most of the applications threw java.lang.OutOfMemoryError: Java heap space. When we investigated these issues we found that the real problem was in JVM itself.
So what’s wrong with JVM? The JVM is not fully aware of the isolation mechanism used in containers. Containers are not like VMs, they are basically isolated Linux process groups. Java application running in docker container always sees the full resources of the host. That means when you run a dockerized Java application in a VM having 128GB RAM, your java application thinks that 125GB RAM belongs to it.
So what can we do to limit resources? There are 2 options,
  • Limit the JVM memory
  • Configure JVM to see container resource limits (cgroups and namespaces)

Limit the JVM memory

The most famous way of limiting JVM memory is by setting maxHeap size using -Xmx . Default Heap memory allocation for JVM is 1/4 of the RAM as defined in the . You can check that by running this,

java -XX:+PrintFlagsFinal -Xmx1g -version | grep -Ei "maxheapsize|maxram"
$ docker run --rm openjdk:8-jre-alpine sh -c "java -XX:+PrintFlagsFinal -version | grep -Ei 'maxheapsize|maxram'"
uintx DefaultMaxRAMFraction                     = 4                                 {product}
openjdk version "1.8.0_212"
OpenJDK Runtime Environment (IcedTea 3.12.0) (Alpine 8.212.04-r0)
OpenJDK 64-Bit Server VM (build 25.212-b04, mixed mode)
uintx MaxHeapSize                              := 1035993088                          {product}
uint64_t MaxRAM                                    = 137438953472                        {pd product}
uintx MaxRAMFraction                            = 4                                   {product}
double MaxRAMPercentage                          = 25.000000                           {product}

I set 4GB for my Docker for Mac. That’s why I got MaxHeapSize as 1035993088 (~1GB)
That means if you set MaxRAM=1G then Heap allocation will be 256mb.

$ docker run --rm openjdk:8-jre-alpine sh -c "java -XX:+PrintFlagsFinal -XX:MaxRAM=1g -version | grep -Ei 'maxheapsize|maxram'"
uintx DefaultMaxRAMFraction                     = 4                                   {product}
openjdk version "1.8.0_212"
OpenJDK Runtime Environment (IcedTea 3.12.0) (Alpine 8.212.04-r0)
OpenJDK 64-Bit Server VM (build 25.212-b04, mixed mode)
uintx MaxHeapSize                              := 268435456                           {product}
uint64_t MaxRAM                                   := 1073741824                          {pd product}
uintx MaxRAMFraction                            = 4                                   {product}
double MaxRAMPercentage                          = 25.000000                           {product}
So when you are running java application in a container , you can limit JVM memory by setting -XX:MaxRAM and -Xmx .

Configure JVM to see the container resource limits

There are few of options.
Fabric8 image has a script to calculate container restricted memory. XX:+UseCGroupMemoryLimitForHeap was introduced in Java 9 and it was backported to java 8u131. Out of the above 3 options, the best option is to use java 10 image or later version of java image. I prefer Java 11 because it is the LTS version. Java 10 brought all the improvements for java application to run in a container properly.

How to dockerize Java application

There are many ways you can dockerize java application but there is no golden way. You must choose what fits for you.
Docker Maven plugin
There are three main docker plugins for Maven.
Using Maven docker plugins you can tie docker build to maven build lifecycle. Spotify only provides docker build and push functions. You have to create a Dockerfile also. But Fabric8 and Jib don't require a Dockerfile. In Fabric8 docker plugin you have to specify Dockerfile instructions in a plugin specific XML format.
<build>
  <from>openjdk:8-jre-alpine</from>
  <assembly>                  
    <descriptorRef>artifact</descriptorRef>                
  </assembly>
  <maintainer>kasun.ranasinghe@icloud.com</maintainer>
  
  <tags>
    <tag>latest</tag>
    <tag>${project.version}</tag>
  </tags>
  <ports>
    <port>80</port>
  </ports>
  <cmd>java -jar maven/${project.name}-${project.version}.jar</cmd>
<assembly>
    <mode>dir</mode>
    <targetDir>/app</targetDir>
    <descriptor>assembly.xml</descriptor>
  </assembly>
</build>
Out of these plugins the best one is Jibs. When you use Jibs you don’t need a Dockerfile or even a Docker deamon running in your machine. Jibs take advantage of Docker layers (Docker layers are created by the instructions in the Docker file like ADD, RUN, COPY). Layers can be re-used by multiple images and layers allow images to be built faster because Docker build just re-uses the layers that didn’t change.
Using Dockerfile
My preferred way of dockerizing application is using Dockerfile but like I told before there is no right way of dockerizing applications. I choose Dockerfile because it gives me lots of flexibility. I’ll start with what we did initially.
Like most of others we use Jenkins as the CI tool. Our initial setup was very simple, We built the jar file by executing maven command in a jenkins slave then copy it into Docker image. This is a very simple Dockerfile.
FROM openjdk:8-jre-alpine
COPY spring-boot-app-0.0.1-SNAPSHOT.jar /app.jar 

ENTRYPOINT java -Djava.security.egd=file:/dev/./urandom -jar /app.jar
We did not face any issue when dockerizing java application initially. The first real issue came when we decided to use openjdk 10 image to solve memory issues in java containers. All our Jenkins slaves use JDK 8. Because of that our applications complied against java 8. We couldn’t take the risk of running those applications in Java 10 runtime. We had 2 options to solve this,
  • Install OpenDJK 10 on all the jenkins slaves
  • Use Docker multistage build
1st option is the easiest way to fix this issue. But it makes our docker build depend on the machine that we are running our docker build. I didn’t like it because I want to run docker build against my project in any machine, it can be a Jenkins slave or a developer machine. This gives atomic feeling to Dockerfile. Basically, multistage Dockerfile looks like this,
FROM maven:3.6.1-jdk-11-slim as builder (1)WORKDIR /app
COPY src /app/src
COPY pom.xml /app/

RUN mvn clean install

FROM openjdk:11.0.3-jdk-slim-stretch (2)

WORKDIR /app
COPY --from=builder /app/target/app.jar /app/ (3)
ENTRYPOINT java -Djava.security.egd=file:/dev/./urandom -jar app.jar
As you can see there are two FROMs. These are the stages in the docker file and you can copy content from one stage to another. We can refer stage by their index or name (We can name stages using “as <stage_name>” (1) ).
In the first stage of this Dockerfile we build our java application using maven:3.6.1-jdk-11-slim (1) then we use openjdk:11.0.3-jdk-slim-stretch (2) as our runtime image and copy jar file from the 1st stage (3). Using multistage build we can create a single docker file to build and run the java application. Other main advantage of this is that we can easily migrate between java versions.
But there is a one drawback of this, that is RUN mvn clean install command. Every time you execute a docker build it will download all the Maven dependencies. One way we can avoid this by coping local .m2 folder.
FROM maven:3.6.1-jdk-11-slim as builderWORKDIR /app
COPY src /app/src
COPY pom.xml /app/
COPY .m2 /.m2

RUN cd /app
RUN mvn clean install

ENTRYPOINT "ls -al target/"

FROM openjdk:11.0.3-jdk-slim-stretch

WORKDIR /app
COPY --from=builder /app/target/app.jar /app/
ENTRYPOINT java -Djava.security.egd=file:/dev/./urandom -jar app.jar
Other way is to create a builder image containing all the necessary maven dependencies.
FROM keaz/springbootbase:2.1.6-java-11 as builderWORKDIR /app
COPY src /app/src
COPY pom.xml /app/

RUN mvn clean install

FROM openjdk:11.0.3-jdk-slim-stretch 

WORKDIR /app
COPY --from=builder /app/target/app.jar /app/ 
ENTRYPOINT java -Djava.security.egd=file:/dev/./urandom -jar app.jar
I have created a builder image containing spring boot 2.1.6 jars. Builder image is very useful if you have your own framework, then you can create a builder image from your framework jars.

Reduces docker image size

If you take the size of the image you created from openjdk:11.0.3-jdk-slim-stretch , it is around 457MB.
kzone/code/sampleapp                                                           latest                    f36b741af846        2 days ago         457MB
Image size is a matter because of couple of things,
  • Small image takes less space.
  • Large image takes more time to pull and push
  • Large image can contain unnecessary components.
We can reduce the size of the image by choosing a smaller base image and creating a custom JRE for our application.
Java 10 provides 2 tools that we can use to create custom JRE, they are jlink and jdeps.
jlink is a tool that generates custom JRE that contains only the given modules required for the application. jdeps is a tool used to analyze dependencies in java applications. With jdeps we can list the java modules used by an application. We can combine these two tools to create a custom JRE for our application.
jlink --output myjre --add-modules $(jdeps --print-module-deps target/app.jar),java.xml,jdk.unsupported,java.sql,java.naming,java.desktop,java.management,java.security.jgss,java.instrument
This will create custom JRE in the output file “myjre”, size of this JRE is around 76MB. We can use this to reduce the size of the docker image.
FROM keaz/springbootbase:2.1.6-java-11 as builderWORKDIR /app
COPY src /app/src
COPY pom.xml /app/
RUN mvn clean install

FROM openjdk:11.0.3-jdk-slim-stretch as customjre
WORKDIR /app
COPY --from=builder /app/target/app.jar /app/

RUN jlink --output /app/customjre --add-modules $(jdeps --print-module-deps /app/app.jar),java.xml,jdk.unsupported,java.sql,java.naming,java.desktop,java.management,java.security.jgss,java.instrument

FROM debian:9-slim

WORKDIR /app
COPY --from=builder /app/target/app.jar /app/
COPY --from=customjre /app/customjre /app/customjre

ENTRYPOINT /app/customjre/bin/java -Djava.security.egd=file:/dev/./urandom -jar app.jar
Size of this new Docker image is 200MB less than previous image.
kzone/code/sampleapp                                            latest                    4a57b0b77941        2 days ago         241MB

Comments

Popular posts from this blog

Spinnaker Authentication with Keycloak

Four Event Driven Patterns