According to New Relic's State of the Java Ecosystem 2023, over 70% of Java applications in production
environments run inside a container. In recent years, containers have become a common
tool in microservice Java architecture in the cloud. Their popularity is
unsurprising - containers outperform traditional JVMs in cloud deployment. Today,
containers are used for simplified management; easy installation; packaging;
isolation and improved security; scalability; and more.
What is a container?

Java
container represents a stack piled from several layers: Java
runtime; OS packages for the application; libraries; frameworks; and the base
image or parent image itself.
A
typical way to build a Java container is
based on creating a fat JAR component. The fat JAR normally aggregates Java
class files, associated metadata, and all sorts of dependencies into one file
for distribution. So, the result is a self-contained executable that just needs
a JRE to run.
The
layered structure of the container allows each layer to be crafted
according to the application goal, playing with OS, libraries, and the rest of
its inlay. For example, one can include only the application code in the
executable and exclude the dependencies (storing them in the local repository
instead). This approach makes the fat JAR thinner and boosts container
efficiency. One can also utilize class files without the JAR packaging,
removing double compression, accelerating startup, and reducing compressed
image size in the process.
Adapting
each container application layer to one's goal significantly increases
efficiency and dramatically reduces costs. The exercise can become endless, and
the result of the game is seen in a variety of containers offered today: large,
small - even distroless.
Container
size matters in terms of application performance, but there is no good or bad
container - only the right one that serves the operational needs. There are no
unified criteria for OS images, so it is important to know the requirements
before proceeding with container or image choice.
The
image we use in a JDK container is built on someone else's work, called a
parent or base image, and this part requires careful attention. We have freedom
of choice here - we can use JIB, buildpacks, or base/parent images. However,
the dominant method of container creation is still based on the Dockerfile.
CPU, bandwidth usage and increased cloud cost in container transfer
The
layered structure affects container transfer. We transfer containers from one
environment to another, from one registry to the development machine, and so
on. Pulling images is part of the everyday routine when working with
applications. So, each time we transfer containers, we pass the network, and
this transfer implies a cost.
The
network boundaries you cross when you upload and pull images also matter, and
restricted bandwidth might crush your operations. If you rely on a big registry
(and in development practice, this is often the case), you can start your own
proxy to reduce the traffic. However, if you deal with large workloads in an
enterprise-level routine, this option becomes ineffective as network usage and
cloud storage (CPU) costs become too high. So a solution for smooth,
cost-efficient work with containers is required. This process can be delegated
to a third party, such as a Docker Hub, software developer, etc.
To
improve the container's transfer efficiency, you can change the Dockerfile
build instructions so that downloading and installing dependencies occur before
the source code is copied to the container. You can also put the container
layers that get frequent updates on top so they will be pulled first. By doing
so, you decrease the time spent on the pull process.
In
summary, container transfer is a handover of structured information consisting
of different layers. These layers are saved on the server and transferred via
the network, so the process involves extra costs, time, and possible bandwidth
restrictions.
To
solve the issue of rising energy and cost consumption in development, the
container sizes are being optimized. Small lightweight containers allow higher
performance in the cloud, and they create less traffic and use less storage.
Small containers are based on small images, such as Alpaquita Linux.
How native images differ from other base images
The
GraalVM project started with the
revolutionary idea of delivering one virtual machine to combine all languages,
and their initial proposal of low-footprint, fast-startup Java packaged in a
native image received widespread adoption in the developers' community. Today, GraalVM equals native images for many
of us.
GraalVM compiles Java bytecode ahead-of-time into a
platform dependent executable called native image. Native images include only
the code required at run time, which are the application classes,
standard-library classes, the language runtime, and statically-linked native
code from the JDK. To create a native image GraalVM executable
first uses static analysis of code to determine the classes and methods that
are reachable when the application runs; and then it compiles classes, methods,
and resources into a binary. This entire process is called build time, and it
clearly distinguishes from the Java source code compilation to bytecode.
Native images are currently supported by all the leading
application frameworks. They allow deployment in containers and are ideal for
the cloud.
A
Native image executable file has important advantages over any other JDK image.
It starts in milliseconds, delivers peak performance immediately, and fully
skips a warmup phase, which is a modern pain for large and complex Java
workloads. Native images can be packaged into a lightweight container image,
and we'll explore how such containers differ below.
Native images in action
In
comparison to the Java container based on the JDK parent image, a run time
container with Liberica NIK (based on the GraalVM native image) will look as
follows:
As
you can notice there are only three layers in this container and the fat one is
the Native Image, with the other two occupying less space.
The
difference here is due to the GraalVM executable replacing a typical JAR in a
container with a JAR file. The JAR requires a JVM, and JVM uses a Just In Time
(JIT) compiler to generate native code during runtime.
Then for AOT
compilation at build time, you will need a container with Liberica NIK, but at
run time you will only require the base OS layer.
To build the native
executable, GraalVM uses Ahead Of Time compilation (AOT). In this compilation
type we spend our resources just once, without further need to adapt the
container layers for better performance continuously. Via a single AOT
compilation, we receive the perfect result we reach in many steps of
enhancements in a Docker-based container.
Native
image generation performs aggressive optimizations eliminating unused code in
the JDK and its dependencies, heap snapshotting, and static code
initializations. This results in a fat native image layer in a container with
much thinner other layers. These other layers have been already optimized by an
AOT compiler, and we do not need to waste time on restructuring them. The
container with the native image is small and lightweight by default. AOT
compilation requires fewer resources, delivering peak performance immediately
with no warmup, and reduces attack surface.
The
GraalVM-based container in this light seems a favorable option for any
cloud-based Java application. Apart from efficient performance, it provides by
far the biggest startup benefits compared to other solutions to deal with the
slow warmup currently being offered. Native images not only contain code, but
also data as a pre-initialized heap that is created by running user-defined
static initializers.
It should be noted that
constructing a GraalVM native image build is a complicated process which can
consume a lot of memory and CPU, resulting in an extended build time. The
GraalVM philosophy, however, is to provide the most energy- and cost-efficient execution
for Java programs, and the GraalVM community is continuously working on
improving build-time performance.
The recently launched Layered Native Image project is focused on this problem. A new feature
currently in research will divide the layered structure into separate
libraries, allowing you to use only a small library relevant to your container
and consisting of the set of native images required. The expected results of
the Layered Native Image project are an increased compilation speed, the
removal of many redundant container images, and - as a consequence - a
significant reduction in rebuild or build time.
GraalVM
solutions is on top of the list for reducing resource requirements and is a key
component to keeping costs down in modern sustainable Java applications. Recent
release of Spring Boot 3 enlarges GraalVM Native Image support,
allowing Spring Boot applications to be converted into native executables using
the standard Maven or Gradle plugins without special configuration. Today you
can use GraalVM functionality in Spring framework through buildpacks or native
build tools. Both options are perfect for creating your ideal container in the
cloud.
Optimizing containers
Optimization
is the path one should always search for in software development. The
performance, security, production, and development costs of the same two
containers presented above can be further advanced; you just have to choose
more suitable components.
The
first optimization option is based on a buildpack. A buildpack is a key element
for your container's structure when your team lacks the time and prefers
ready-made instruments. A buildpack
transforms an application source code into runnable artifacts by analyzing the
code and determining the best way to build it. Containers based on buildpacks
will have optimized metrics for all layers. Designing a JVM or native image
based container using a buildpack is a relatively easy task. You only need to choose an appropriate buildpack.
The
next level of advanced container optimization arrives via musl technology. Musl
is a more effective technology for Java containers, and switching a development
routine to a musl supported software is not complicated. Using Alpaquita Linux
with musl allows for an enhanced JDK container with thinner layers and greater
performance metrics. Liberica Native Image Kit on musl is available to create a native image-based container
that will deliver a smaller size and faster speeds along with a reduced surface
for possible attacks.
Containers
on musl outperform the standard ones. They are more effective in development
and operation, their transfer requires less resources and time, they reduce the
cost and deliver an easier Java development practice, thereby speeding up both
phases - development and production.
Containers
have taken over the Java microservice architecture. There are many tools and
ways to design them. While buildpacks are good for teams working on short
deadlines and using a ready-made component for container structures,
musl-supported Alpaquita Linux and Liberica NIK are great options for advanced
development, delivering sustainability in cost and usage containers with
increased security and performance metrics.
##
ABOUT THE
AUTHOR
Dmitry Chuyko is a Senior Performance Architect at BellSoft,
an OpenJDK committer, and a public speaker. Prior to joining BellSoft, Dmitry worked on the Hotpot JVM at Oracle, and before that he had many years of
programming experience in Java. He is currently focused on optimizing HotSpot for x86 and
ARM, previously being involved in rolling out JEP 386, which enables the creation of the
smallest JDK containers. Dmitry continues his journey in the containerization process and is
happy to share his insights and
expertise in this
field.