GraalVM native images are a real innovation for Java apps, providing much smaller Images and faster startup, especially useful for apps that are horizontally autoscaled. However, this causes a higher development effort, especially for existing apps. If included in new apps from the start, this effort should be smaller, but on the other hand we’re pinned to JDK8 with native images.
After working as developer with Java and Docker for many years (and more recently also as trainer) it’s obvious to me that Docker and Java are not a perfect match. As for all interpreted languages, the interpreter (the JVM, in case of Java) imposes a burden in both size and startup time on our Docker image/container.
Java shares this fate with other platforms such as .net, node.js and ruby. To make a point, the follwoing table shows a brief overview of the current “alpine” variants (i.e. the smallest) of those platform’s base images:
(The sizes are the compresses size of all layers of the images within DockerHub. The image sizes is calculated as described here).
In contrast, nativly compiled apps written in Go or C/C++ can be built from scratch (if statically compiled), starting of at 0 MB.
GraalVM to the rescue
About a year ago, Oracle announced the first release candiadate of GraalVM offering better interoperability for Java Programms with non-JVM languages and precompiled native images with instant start up and low memory footprint (using ahead-of-time (AOT) compilation). In the announcement they stated that Twitter already uses GraalVM in production to efficiently run their Scala workloads.
From the start, this sounded like a innovation to me. As I’m always a bit low on free time to play around with technologies, I started by following the topic by reading articles that crossed my way, such as
- Learning to Use the GraalVM – DZone Java 🇬🇧
- trion.de – Minimale Java Docker Images mit Graal 🇩🇪
- Oracles GraalVM für „Native Java“? 🇩🇪
Great introductions, though they all have one thing in common: They compile a single “Hello World” Java file into a native image.
This made me wonder if it’s also that easy for more real-life projects and what pitfalls there are. Especially regarding the known limitations for native image generation, like reflection.
Getting the hands dirty
My first approach was to naively adopt the Dockerfiles for two existing projects to produce native Images. Containing the compilation in Dockerfiles has the advantage of not requiring anything installed locally and is also a kind of “infrastructure as code”, providing deterministic results and allowing for version control.
The projects I chose were
- github.com/fesh0r/fernflower – JetBrains’ Java Decompiler, which at that time was not provided on DockerHub and I needed it for my short excursion to Reverse engineering Sony PlayMemories Camera Apps.
- github.com/schnatterer/colander – my personal showcase project, which is simple enough to try things out fast, and complex enough to cause real-world problems.
BTW – As they both failed in the beginning (with GraalVM 1.0.0-rc14), I had a look on Java frameworks that offically support GraalVM native images. See my article: Short comparison: Building Graal Native Images with Quarkus, Micronaut and Helidon.
When I started, the latest version was GraalVM 1.0.0-rc14 (the 14th release candidate for version 19.0.0 🤔). For both projects I stumbled upon a number of confusing (to me) errors (like NullPointerExceptions) that all magically vanished once I updated to the “ready for production use” version GraalVM 19.0.0 (as soon as it was available).
Nice work by the GraalVM team 👍 (if anyone is interested in the errors, I carved them into the Dockerfile or Git Commit messages, see fernflower and colander).
Here are my most interesting findings (I will elaborate on them bellow):
- the fernflower CLI app works with a Docker image of only 5.3 MB in size! This is really revolutionary for a Java app 🎉
Note that it only compiles statically (which is what I wanted anyway, to be able to use a scratch Docker image)
- the colander native image also compiles (it’s also only 5 MB). However, the app does not work, because of it’s dependencies. These were the kind of real-world problem findinds I was looking for.
Findings in detail
As said, the GraalVM native image build for fernflower works like charm. In fact, I was so charmed I created an automated build at DockerHub, that regularly builds Docker images for the latest version of fernflower, using different base images: schnatterer/fernflower-docker.
So if you’d ever need a Java decompiler just do a
docker run --rm -v $(pwd):/src schnatterer/fernflower and 5MB later you’ll have you’re decompiler ready.
These images also allow for a nice comparison of image sizes for different Java base images.
5 vs 45MB comparing native image to regular JRE. Stunning, isn’t it?
BTW – this is the final Dockerfile to build the native image.
Facing real world challenges
Of course, I love it when a plan comes together. But on the other hand I suspected this wouldn’t always be the case for a such complex a thing as GraalVM native images. Here are the issues I encountered for the colander app (as said before, these issues are well documented known limitations of GraalVM):
- When starting the native image, there’s not much output to the console.
Reason: Not surprisingly, unlike the jar, the native image does not contain a
logback.xml. In order to fix this, we would have to copy the
logback.xmlmanually into the final docker image during the build.
- The command line option
--helpdoes not show any options.
Reason: These options are defined in annotations, and are read at runtime using reflection via the JCommander framework. How to fix?
Configure the reflection for native image generation. Fortunately, some Java CLI frameworks like picocli support generating the config files out of the box. So migrating would also be an option.
- Colander can’t show its own version name.
Reason: The version name is read from the
MANIFEST.mffile using the cloudogu/versionName library. The file is (again, not surprisingly) is not contained in the native image. This made me wonder if it wouldn’t be much simpler to read the version name from a Java constant. A hard-coded value just feels more read-only than a text file such as the
MANIFEST.mf. I added this feature to the library using annotation processors.
The issues encountered definitely proof my hypothesis, that GraalVM native images, while a technological innovation that provides a lot of potential, causes extra efforts during development. So we have to decide if these extra efforts are justified by the advantages.
I presume that, if planned from the beginning, the efforts are a lot less, because we can take GraalVM support into account when making our technical decisions. There are frameworks like Quarkus, Micronaut and Helidon that have native support for GraalVM, minimizing the extra effort. For large existing apps, though this does not help. The colander example is really small (with only about 1k of net LOC) and already causes a lot of effort.
So would I use GraalVM native images in production?
I probably wouldn’t migrate existing applications, except they run at a massive scale and the potential savings (faster horizontal scaling, smaller memory footprint) justify the effort for building the native image in the first place. For new projects, I would assess building GraalVM native images from the start, if sticking with JDK8 is OK. Native images only support JDK8, as of GraalVM 19.0.0.
In the long run most popular Java frameworks, not only Quarkus, Micronaut and Helidon are likely to support native image generation. For now, Spring is still a WIP, also mentioned in the GraalVM 19.0 release announcement. If I had a teams with profound Spring experience, I would only switch to another framework for a good reason.
On the other hand, if developing using the microservices architecture pattern, first experiences with GraalVM native images could be gained by implementing small services using GraalVM.