Updated: Sep 8
This post demonstrates the advantages of compiling and running a Java application using Quarkus.
To get the most out of your application in the Cloud and especially in containers and Kubernetes you need to design your application for this new world. How can frameworks help to reach high levels of productivity and efficiency ?
For sure Java frameworks can help in the area, either Spring Boot or Quarkus, that we compare in this article using the same sample application.
The code used is available on GitHub, you can find three subdirectories:
employee-sb - Spring Boot flavor
employee-quarkus - Quarkus version
comparisons - tools for comparing the application on both platforms
The sample application is a simple CRUD of a root entity "Persons", connected to a Postgres database. We use the Hibernate ORM for both applications, and additionally the Panache framework in case of Quarkus.
There are two ways for compiling a Java application using the Quarkus framework:
standard / old-fashioned JVM
GraalVM (for generation of native code)
To get a minimalistic exposed surface in terms of security, we will use scratch as the starting point of our Docker image, to get only our application binary in it.
To avoid complex setup with GraalVM, you can use a multistage Docker build, which helps in building the GraalVM binary without installing it locally.
The multistage Docker build is composed of three stages (see Dockerfile.scratch.native):
collect jar dependencies in the first step, to avoid losing time in the following steps
build the native binary (some tricks are necessary when using a scratch or an alpine image, detailed in the Dockerfile comments)
finally, copy the binary into our scratch image
In both subdirectories employee-sb and employee-quarkus you can find a build-mvn-docker script that builds images for you, and then tag the docker image with the name that will be used by the comparison tools.
The hardware used for comparisons is a MacBook Pro laptop with Docker Desktop installed on it to run the containers.
Dedicated resources for Docker Virtual Machine are:
8Gb of RAM + 1Gb for swap.
In order to compare various approaches, we have a look at 3 key metrics:
size of generated artifacts and docker image
Spring Boot - Startup time: ~10 seconds
Less efficient than Quarkus
Quarkus JVM - Startup time: ~2-3 seconds
Simple build without -Dnative (or -Pnative, the choice is yours)
Less efficient than a native build
More efficient than a classic stack
Quarkus native - Startup time < 0.1 second as shown above
Compilation time is slow since you use GraalVM, but it’s the price to pay to have great performances at runtime. Notice that if you use multistage build, your build time will be slower than in the “standard GraalVM way”.
Size of JAR files
If you analyze the generated jar file from Quarkus, you see there’s no lib (JAR files) included, every class needed at runtime is extracted from its original library and included in the generated jar directly. Then, Quarkus only keep the strict necessary classes, which results in a so lightweight package.
For instance I used io.quarkus.hibernate.orm.panache.PanacheEntity from Quarkus-Panache maven dependency but not io.quarkus.panache.common.Page, and you will not find this Page class into the final package.
Size of Docker images
As you need to embed JVM in the quarkus-jvm Docker image, size is three times heavier than the native docker image.
As the native way doesn't need to have an embedded JVM in the docker image, it is much lighter. And size matters, since a lighter image will affect you in terms of:
storage - you will need less storage in your repositories and on your local “dev” machine
bandwidth - the lighter the image, the faster it will be to pull the image
security - the surface prone to attacks is smaller, considering the number of packages
You can use a simple docker-compose file to run the three modes in parallel (remember that my dedicated resources for Docker is 8Gb of RAM and 8 CPUs, you will have some gaps in results for comparisons depending on your configuration).
We can see that while the application is in “quiet mode”, Quarkus consumes less memory and CPU in JVM mode than Spring Boot, and it's much better with the native mode (~10 times less CPU usage, and almost 20 times less Memory usage)
Scaling multi instances with load
In a container environment, with multi-tenant purposes, you need to be able to scale (up or down) quickly a service, if load is too important for the service to be able to respond fast. If the application becomes unresponsive or unavailable because of the number of connections to it, this definitely impacts the end-users...
Let’s imagine that our application often crashes due to memory leak or anything related, the Orchestrator (Swarm, Kubernetes, etc.) will restart the replica.
As a demonstration, we simply do it using watch and docker-compose restart:
Now we call each container every second during 100 seconds, to check if it is up and running:
Count of failures:
Let’s increase the rhythm, by restarting every 10 seconds. This is just for the demonstration purpose (I hope this will never happen to you in real life).
Count of failures:
Using docker stats, we can see that:
Spring Boot used sometimes more than 400% (4 cores) of CPU for restart
Quarkus with JVM max CPU usage is approximately 200% (2 cores)
Quarkus native never goes above 1%
As you can see, the probability for your users to be impacted tends to 0 when using Quarkus in the native way. And if you are not comfortable with native compilation, using Quarkus in the JVM mode is still better than using Spring Boot.
Max concurrent containers
For this section, you can use subfolders in docker-compose, one per application mode.
What we should expect with 8GB of RAM for docker:
For the initialization of Swarm we use the following command, to get more IP addresses (by default swarm provides you a network with only “256” available addresses):
Using swarm, with 10 replicas, spring boot app take 30 secondes to launch.
Now we try with 20 replicas, using:
Sometimes it exceeds the 400Mb of RAM when starting, and so swapping occurs.
With only 20 replicas, the startup time dramatically increases for most of them - something you do not expect in a production environment…
This is really better (30 seconds in Spring Boot mode vs only 7 seconds here).
Scaling to 35 replicas, we have a max startup time at 25 seconds (vs 120 for Spring Boot for 20 replicas):
Max startup time is less than 0.2 seconds for any replica, that's awesome!
Max startup time is less than 0.3 seconds for any container, that's also great!
Let's try to be more aggressive, by scaling up to 100 replicas!
Startup time is still less than 1 second ( < 0.5 for the max): it will perfectly fit in a container world!
Above 300 containers, Swarm suffers issues (possibly network, you can always finger point the network) that makes it totally unresponsive so we scale up to 300 containers.
I don’t show logs for this case, they are similar to above and there are too many lines.
By scaling our service to 300, we never had a start time > 0.5s.
Summary of what has been tested:
Comparisons in a Kubernetes environment, using k3d
The goal of this part is to check if Quarkus really fits “perfectly” in Kubernetes, and have a look on how startup time can impact your service availability.
We use the excellent k3d from Rancher, which gives you a Kubernetes cluster in docker.
I recommend you to read this great post about k3d especially if you are not familiar with it.
What will we do?
Set up a Kubernetes cluster with 5 deployments:
- One for the application in its Spring Boot form
- One for the application in its Quarkus with old-fashioned JVM form
- One for the application in its Quarkus with GraalVM form
- Two Postgres databases (one for Quarkus, and the other for Spring Boot)
Set up 3 pods which check the availability of the service with a max timeout of 5 every 0.2 second
Kill 1 pod of each application every
- 5 seconds in a script
- 15 in the other
This simulates the case when your application crash every 5 or 15 seconds. For demonstration purposes, I didn't create liveness and readiness probes - which is of course a very bad practice, but is useless in our demonstration. We kill the pod directly, to keep it simple.
Create, prepare your k3d cluster
I strongly recommend to use k3d in development environment, because it helps you build a Kubernetes ecosystem very easily.
A very simple k3d cluster can be created using the following command:
This is a one-node cluster, and we don’t mind because this is enough for the demo.
Since I don't want my system to depend on public Docker registries, I used the k3d import image feature, to get images from my local Docker registry pushed in the registry of k3d:
Now my k3d internal registry includes the Docker images which have been imported from my local registry.
Run deployments & services
Source code is available in the subdirectory quarkus-k3d of comparisons. Complete test scripts are available in this directory
Create availability checkers
Since CronJob can execute only at the frequency of one execution per minute, this is not the right way for us to check availability on our cluster.
As we want to check every second, we just create a Pod responsible for checking availability, run them and delete them once the kill.sh script has finished the job.
These helpers use busybox image, with the following command - one for Spring Boot, one for Quarkus with standard jvm and one for Quarkus with Graalvm:
Run all together
Using scripts at the root of the k3d folder, all the steps above are running and will show you a result based on the availability checkers logs.
For the first test, which takes 5 minutes to complete, we deploy 1 replica fo each application and destroy pods every 15 seconds. If you remember previous sections, you know that the application will start:
in ~10 seconds for Spring Boot
in ~3 seconds for quarkus-jvm
in less than 1 second for quarkus-native
So with a kill every 15 seconds and checking every second, this should let Spring Boot a chance to restart properly and serve the requests.
While killing pods every 5 seconds, things get a little worse for JVM-based runtimes.
So we can have an unstable application that seems to be more stable than it is really for users, because of the startup time which is a key point for applications in Kubernetes.
Ratio of successful calls that have failed in the table below:
It seems that Quarkus is a good candidate for container world:
low resource consumption