Skip to content

Latest commit

 

History

History

tiny-java-containers

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Tiny Java Containers

This example shows how a simple Java application and a simple web server can be compiled to produce very small Docker container images.

The smallest container images contains just an executable. But since there's nothing in the container image except the executable, including no libc or other shared libraries, an executable has to be fully statically linked with all needed libraries and resources.

To support static linking of libc, GraalVM Native Image supports using the "lightweight, fast, simple, free" musl libc implementation.

You can watch a Devoxx 2022 session that walks through this example on YouTube.

A 1.5MB Java Container App

Prerequisites

  • x86 Linux (but the few binary dependencies could easily be changed for aarch64)
  • Docker installed and running. It should work fine with podman but it has not been tested.
  • GraalVM for JDK 21

We recommend Oracle GraalVM for the best experience. It is licensed under the GraalVM Free Terms and Conditions (GFTC) license, which permits use by any user including commercial and production use. GraalVM Community Edition for JDK 21 works too, but Native Image generated executables sizes will differ.

These instructions have only been tested on Linux x64.

Setup

You need the following zlib packages installed:

  • zlib.x86_64
  • zlib-devel.x86_64
  • zlib-static.x86_64

On Oracle Linux, you can install with:

sudo yum install -y zlib.x86_64
sudo yum install -y zlib-devel.x86_64
sudo yum install -y zlib-static.x86_64

Clone this Git repo and in your Linux shell type the following to download and configure the musl toolchain.

./setup-musl.sh

Download upx:

./setup-upx.sh

Hello World

With the musl toolchain installed, cd in to the helloworld folder.

cd helloworld

Using the build.sh script, compile a simple single Java class Hello World application with javac, compile the generated .class file into a fully statically linked native Linux executable named hello, compress the executable with upx to create the executable hello.upx, and package the compressed static hello.upx executable into a scratch Docker container image:

./build.sh

You'll see two executables were built:

ls -lh hello*

Native Executables

Running either of the hello executables you can see they are functionally equivalent. They just print "Hello World". But there are a few points worth noting:

  1. The executable generated by GraalVM Native Image using the --static --libc=musl options is a fully self-contained executable which can be confirmed by examining it with ldd:

    ldd hello

    should result in:

       not a dynamic executable

    This means that it does not rely on any libraries in the host operating system environment making it easier to package in a variety of Docker container images.

    Unfortunately upx compression renders ldd unable to list the shared libraries of an executable, but since you compressed the statically linked executable, you can be confident it is also statically linked.

  2. Both executables are the result of compiling a Java bytecode application into native machine code. The uncompressed executable is only ~6.3MB! There's no JVM, no JARs, no JIT compiler and none of the overhead it imposes. Both start extremely fast as there is minimal startup cost.

  3. The upx compressed executable is over 70% smaller, 1.7MB vs. 6.3MB! With upx the application self-extracts quickly but does incur a cost of about 100ms for decompression. See this blog for a deep dive on GraalVM Native Image and UPX.

Container Images

The size of the scratch-based container image is slightly more than the hello.upx executable.

docker images hello

REPOSITORY       TAG         IMAGE ID      CREATED             SIZE
hello            upx         4d122bd39a8a  About a minute ago  1.78 MB

This is a tiny container image and yet it contains a fully functional and deployable (although fairly useless 😉) application. The Dockerfile that generated it simply copies the executable into the container image and sets the executable as the ENTRYPOINT.

A better way to build these images is with a multi-stage build, but to keep the focus on the final result, build on a host machine and copy the binary into the container image. E.g.,

FROM scratch
COPY hello.upx /
ENTRYPOINT ["/hello.upx"]

Running the container image is straight forward:

docker run --rm hello:upx

Hello World

Amazingly, it works!

A Simple Web Server

Containerizing Hello World is not that interesting so let's move on to something you could actually deploy as a service. We'll take the Simple Web Server introduced in JDK 18 and build a containerized executable that serves up web pages.

How small can a containerized Java web server be? Would you believe a measly 5.5MB? Let's see.

Let's move from the helloworld folder over to the jwebserver folder.

cd ../jwebserver

There are a number of different GraalVM Native Image linking options that are suitable for different container images.

The build-all.sh script will generate a number of container images that illustrate various linking and packaging options as well as a jlink generated custome runtime image for comparison.

./build-all.sh

The various Dockerfiles simply copy the executable or jlink generated custom runtime image folder into the container image along with an index.html file to serve, and set the ENTRYPOINT. E.g.,

FROM scratch
COPY jwebserver.static /
COPY index.html /web/index.html
ENTRYPOINT ["/jwebserver.static", "-b", "0.0.0.0", "-d", "/web"]

When complete you can see the sizes of the various versions:

$ docker images jwebserver

REPOSITORY          TAG                            IMAGE ID            CREATED             SIZE
jwebserver          distroless-java-base.jlink    414d84f8b7c7  22 minutes ago  132 MB
jwebserver          scratch.static-upx            47aabdd14c04  22 minutes ago  4.71 MB
jwebserver          alpine.static                 783ab3a60248  22 minutes ago  23.4 MB
jwebserver          distroless-static.static      c894f14d4068  22 minutes ago  18.7 MB
jwebserver          scratch.static                034cfbdf3577  22 minutes ago  15.7 MB
jwebserver          distroless-base.mostly        e99811e574d3  22 minutes ago  37.6 MB
jwebserver          distroless-java-base.dynamic  72a210e3c705  23 minutes ago  50.6 MB

Sorting by size, it's clear that the fully statically linked GraalVM Native Image generated executable that's compressed and packaged on scratch (scratch.static-upx) is the smallest at just 4.71MB, less than 4% of the size of the jlink version (distroless-java-base.jlink) running on the JVM.

Base Image App Version Size (MB)
Distroless Java Base jlink 132.00
Distroless Java Base native dynamic linked 50.60
Distroless Base native mostly static linked 37.60
Alpine native fully static linked 23.40
Distroless Static native fully static linked 18.70
Scratch native fully static linked 15.70
Scratch compressed native fully static 4.71

Running a container image is straight forward, just remember to map the ports, e.g.:

docker run --rm -p8000:8000 jwebserver:scratch.static

or

docker run --rm -p8000:8000 jwebserver:scratch.static-upx

Using curl or your favourite tool you can hit http://localhost:8000 to fetch the index.html file.

Wrapping Up

A fully functional, albeit minimal, Java "microservice" was compiled into a native Linux executable and packaged into Distroless, Alpine, and scratch-based container images thanks to GraalVM Native Image's support for various linking options including fully static linking with the musl libc.

To learn more about linking options check out Static and Mostly Static Images in the GraalVM docs.