posts
How to reduce
the size of Docker
container images

How to reduce the size of Docker container images

Dec 14, 2022
Dmitry Chuyko
23.1

If you ever moved home or at least witnessed the process as a bystander, you remember the heavy boxes dragged around. Although it is an arduous task, at least the boxes are filled with necessary things.

Large Docker images are often full of stuff your app doesn’t need. That means they weigh a lot, slow down the development process, and take too much space in the Cloud that you have to pay for.

How do we reduce the Docker image size? Find out in this article!

Container structure

Before adjusting the container size, we must understand its structure. The software in containers forms a stack. The top layers with an application, its dependencies, and OS packages are the most frequently changed ones. A parent image and a base image form the bottom layers. Although we rarely change base layers, there are solutions for patching separate container layers. We can update the bottom ones without touching the top to minimize traffic and accelerate update times.

As per Docker documentation, a parent image is the one that your image is based on. It refers to the contents of the FROM directive in the Dockerfile. If the parent image is SCRATCH, then it is considered a base image. Most Docker images start from a parent image rather than a base image. However, these two terms are often used interchangeably.

The structure of a typical Docker image can be depicted as follows:

Container layers

It is important to note that Java applications need to warm up to reach stable peak performance, and during the warmup and use more resources than they actually require. As a result, developers allocate more memory to cloud instances leading to wasted budgets. One way to solve this problem is to use Coordinated Restore at Checkpoint (CRaC) API that enables the developers to save the state of a warmed up application to the file, replicate this file among cloud instances,and then restore the application from the moment it was paused. Find out more about using Java with CRaC in a container.

In the subsequent sections, we will look into methods of adjusting the layers to keep the image lightweight yet performant. Note that there are two types of images — for developing and deploying applications. We will focus on containers for deployment. The described techniques apply to containers used for development, but you should remember that even modified build images are unsuitable for production.

Choose a base Linux image

A proper base image is the first thing you should consider on the path to image reduction. The variation between Linux image sizes is quite significant: 

 

Ubuntu

CentOS

RHEL

Alpine

Alpaquita (musl-based)

Alpaquita (glibc-based)

Container image size (compressed)

27.01MB

79.65MB

76.2MB

2.67MB

3.69MB

8.32MB

Linux base image size comparison

Size is not the only factor — albeit quite a substantial one judging by the cost of Cloud resources — when selecting a Linux distribution. Other important characteristics include available and affordable commercial support, LTS releases, security features, C library implementation, etc. A detailed comparison of the best Linux distributions for Cloud and Server can be found in our previous article.

But as you can see, you can significantly reduce the Docker image by migrating to another OS. If you want to take advantage of lightweight Alpine Linux, but

  • Need commercial support
  • Find stock musl performance suboptimal for your application
  • Don’t want to migrate to another libc

Consider using Alpaquita Linux. It has LTS releases with cost-efficient support and comes in two versions: one with optimized musl (musl perf) whose performance is equal to or superior to glibc, and the other with glibc.

Learn more about Alpaquita Linux

Some vendors provide base images with JDK and JRE. BellSoft offers the developers a Liberica Runtime Container based on Liberica Lite — a lightweight Liberica JDK flavor optimal for Cloud deployment — and Alpaquita Linux. You can choose either JDK or JRE and a libc implementation (musl-perf or glibc) for the OS. For instance, a container with Liberica JRE 17 and musl-based Alpaquita Linux takes up only 40.56 MB — for most users, such image size reduction will already be enough to drastically lower Cloud resources consumption even without further adjustments.

Add minimal sufficient OS packages

One of the reasons for Linux's immense popularity is customization. You can eliminate unnecessary packages and add modules required for your project, thus keeping the distribution clean and compact. In most cases, a base Linux image already contains numerous modules, which can be later removed manually.

However, starting with a minimal set of packages is more manageable, like in the case of Alpine and Alpaquita. The micro base image can be used as is for simple tasks or Lambdas. But you can pull the rest of the essential packages from Linux repos.

Make the most of package managers

Package managers can be helpful if you want to control the installation of dependencies. Direct dependencies are essential for some tasks, and indirect (optional) ones might be beneficial. The most popular Linux package managers (apt, yum, etc.) install all dependencies by default. You can regulate this behavior. For instance, the command with Ubuntu/Debian

$ apt-get install -y --no-install-recommends package name

Ensures that optional recommended packages are not installed. As for Alpaquita and Alpine, there’s no need for a similar command because these distros install only direct dependencies.

In addition, package managers have a cache where they store installed packages and other files. But we don’t need it in the Docker image. So you should clean the cache before building an image. For Debian/Ubuntu, run

$ apt-get clean

With Alpaquita and Alpine, you can utilize the command

$ apk add --no-cache

to avoid storing the package in the cache in the first place.

Find out more tips on working with APK in a dedicated guide.

Microservices are called “micro” for a reason

Breaking down a monolithic application into microservices is also essential to reducing the Docker image size.

Microservice architecture enables the developers to break down a monolithic application into a system of loosely coupled modules executing certain business logic. Microservices are smaller than monoliths, highly flexible, and resilient, and help to avoid progressive container bloating thanks to modern file systems such as UnionFS, AuFS, etc. To enjoy the benefits of performant lightweight containers with microservices, we must keep them lightweight from the start. Below are some tips on slimming down Docker container images for Java applications.

Thin JARs

A traditional way of packing a Java application into an executable is building a fat JAR. A fat JAR or uber-JAR contains application class files, application, and all its dependencies, resulting in a self-contained executable that needs only a JRE to run. It is the default method of building JARs with Spring Boot. But we can trim down the size of our executable by creating a thin JAR that includes the application code without the dependencies. The dependencies are stored in the local repository, so there's no need to push the application with all dependencies across the dev, test, and prod environments, thus increasing process efficiency. A thin JAR forms a separate container layer leaving the same overall size and a tiny update portion. It is also possible to utilize class files without the JAR packaging, which accelerates startup and reduces compressed image size due to the absence of double compression.

Using thin JARs also lets us separate the layers of a container image and put the ones that get frequent updates on top. This method saves the developers a lot of time when they need to introduce changes to the application because only the top image layers get affected.

JRE images

We need JDK (Java Development Kit) to develop Java applications. It includes Java Virtual Machine (JVM), Java Runtime Environment (JRE), and development tools, such as a debugger, compiler, etc. To run Java apps, we need only JRE, which includes JVM and specific classes for program execution.

Developers sometimes put JDK into the containers aimed for app deployment, increasing their size unnecessarily, while JRE images are more suitable for use in production. Consider this: Liberica Runtime Container with

The difference is striking!

jlink

Starting with Java 9, JDK is organized as a collection of modules — encapsulated self-describing sets of code (with Java classes and interfaces) and data. Java applications can also be divided into and built as modules. The modularity of the Java SE platform enhances the maintainability of large applications, enhances the performance, and helps to reduce the JDK size for dense cloud deployments.

The go-to solution for reducing a Docker image size in the case of Java apps is jlink. It is a tool that creates a custom JRE containing only the modules the application needs. A container with a custom JRE can be four times smaller than the standard one, provided that you choose an appropriate base image (see Section 2 below).

.dockerignore

The image build process implies copying files from the host into the build context. But the project often contains large and unnecessary files not required for the application to run. The .dockerignore file enables the developers to specify files and directories that shouldn't be copied into the image. This helps to accelerate the build process and reduce the image size. It also increases security by eliminating the risk of putting sensitive data (commit history, credentials, etc.) into the image.

docker-slim

The DockerSlim tool (docker-slim) automatically optimizes the size of a Docker image. It creates a temporary container and decides which files an application requires using various analysis techniques. The resulting single-layer image with only necessary files can be 30 times smaller than the original one, thus reducing memory consumption and enhancing security due to a minimal attack surface.

However, the tool should be used with caution. docker-slim may accidentally throw away the files the application needs due to lazy loading. It may lead to production errors or even an unusable container. To avoid such situations, use the --http-probe and --include-path flags to detect all dynamically loaded functions and preserve required files. It may be more complicated with Java, because a typical Java API contains multiple dependencies, and some of them can be unobvious and nonstatic. 

Minimal number of layers

Each layer of a Docker image represents an instruction in the Dockerfile. Commands in the Dockerfile that modify the filesystem create a new layer. So each RUN, COPY, ADD command adds a new layer to the image, thus increasing its size. So instead of running

FROM ubuntu:latest
RUN echo somedata
RUN mv somefile

We can merge the requests into one command:

FROM ubuntu:latest
RUN echo somedata && mv somefile

Another way to minimize the number of layers is to use the docker-squash tool, which squashes the last N number of layers into one. This will help you keep layers with temporary or deleted files out of the image. So if you created a lot of large files (for instance, added Spring resources) and then deleted them in a new layer, you can squash the image and reduce its size several times.

How to create a custom JDK container image based on Alpaquita Linux

In this section, we will build a custom JDK image using jlink, Liberica JDK Lite and Alpaquita Linux.

First, choose a C library implementation. Alpaquita Linux offers two libraries with three versions:

  • musl-perf optimized for performance
  • musl-default (upstream build)
  • glibc

If you choose musl-perf, the Dockerfile will start with 

FROM bellsoft/alpaquita-linux-base:stream-musl

Note that Docker images with musl-based Alpaquita contain musl-perf by default. If you want to switch to the upstream version, add the following command to the RUN instruction:

RUN apk add musl-default

Next, choose a Java version. Only two LTS versions, JDK 11 and 17, currently support jlink. If you are using Java 8 for enterprise development, this is a good incentive to migrate to a newer version.

We will build a custom image based on Java 17. For this purpose, install the package liberica17-lite-jdk-all, which contains everything you may need to build the image. 

RUN apk add --no-cache liberica17-lite-jdk-all

Configure jlink execution parameters. The most important ones are:

  • --add-modules <list> — this option allows us to specify only those modules which we really need. We choose the java.base module which contains the essential implementation of reading classes and resources
  • --vm <minimal/client/server> — we will use server as the most suitable option for user needs
  • --no-header-files — we don't need headers as we aren’t going to compile JNI code
  • --no-man-pages — we don't need documentation
  • --compress <0/1/2> — 0 means No compression, 1 is Constant string sharing, 2 is ZIP. The compression level affects the disk size of the runtime image. The highest compression level results in a smaller image, but with a potential penalty to startup. Another problem with compression is that the resulting container image will also be compressed, but with Zip, the result will be less efficient as it could be in case of no compression. In other words, if you want to save the network bandwidth, use 0, other options may require additional experiments
  • --strip-debug — we don't need debug information, and this option reduces size of the runtime image by approx. 20%
  • --module-path — a path to Java Modules (jmods), usually it's $JAVA_HOME/jmods
  • --output — a path to the location where the resulting image will be created

Let’s summarize it all for our Dockerfile:

RUN apk add --no-cache liberica17-lite-jdk-all \
&& jlink \
--compress=2 \
--no-header-files \
--no-man-pages \
--strip-debug \
--module-path $JAVA_HOME/jmods \
--vm=server \
--output /opt/customjdk

Now, let’s remove our supplementary package liberica17-lite-jdk-all because we don't need it anymore:

&& apk del --no-cache liberica17-lite-jdk-all

The --no-cache option is not really required, but without it, apk will issue a warning that there are no index files.

Next, add necessary environment variables:

ENV JAVA_HOME="/opt/customjdk"
ENV PATH="$JAVA_HOME/bin:$PATH"

Add the default execution command, i.e. when it's ran without any parameters, it will show the Java version:

CMD ["java", "-version"]

Below is the resulting Dockerfile.

FROM bellsoft/alpaquita-linux-base:stream-musl
RUN apk add --no-cache \
liberica17-lite-jdk-all \
&& jlink \
--add-modules java.base \
--compress=2 \
--no-header-files \
--no-man-pages --strip-debug \
--module-path $JAVA_HOME/jmods \
--vm=server \
--output /opt/customjdk \
&& apk del liberica17-lite-jdk-all
ENV JAVA_HOME="/opt/customjdk"
ENV PATH="$JAVA_HOME/bin:$PATH"
CMD ["java", "-version"]

We can now build the image:

$ docker build . -t customjdk
Step 1/5 : FROM bellsoft/alpaquita-linux-base:stream-musl
 ---> 7466379ca5ab
Step 2/5 : RUN apk add --no-cache liberica17-lite-jdk-all && jlink --add-modules java.base --compress=2 --no-header-files --no-man-pages --strip-debug --module-path $JAVA_HOME/jmods --vm=server --output /opt/customjdk && apk del --no-cache liberica17-lite-jdk-all
 ---> Running in 4f60b9359806
fetch https://packages.bell-sw.com/alpaquita/musl/stream/core/x86_64/APKINDEX.tar.gz
fetch https://packages.bell-sw.com/alpaquita/musl/stream/universe/x86_64/APKINDEX.tar.gz
(1/23) Installing libgcc (12.2.1_git20220924-r3)
(2/23) Installing libstdc++ (12.2.1_git20220924-r3)
(3/23) Installing binutils (2.39-r2)
(4/23) Installing java-common (1.0-r1)
(5/23) Installing liberica17-lite-jre-no-deps (17.0.5_p8-r1)
(6/23) Installing libexpat (2.5.0-r0)
(7/23) Installing brotli-libs (1.0.9-r6)
(8/23) Installing libbz2 (1.0.8-r3)
(9/23) Installing libpng (1.6.38-r0)
(10/23) Installing freetype (2.12.1-r0)
(11/23) Installing fontconfig (2.14.1-r0)
(12/23) Installing encodings (1.0.5-r0)
(13/23) Installing libfontenc (1.1.6-r0)
(14/23) Installing mkfontscale (1.2.2-r1)
(15/23) Installing ttf-dejavu-core (2.37-r1)
(16/23) Installing liberica17-lite-jre (17.0.5_p8-r1)
(17/23) Installing liberica17-lite-jdk-no-deps (17.0.5_p8-r1)
(18/23) Installing liberica17-lite-jdk (17.0.5_p8-r1)
(19/23) Installing liberica17-lite (17.0.5_p8-r1)
(20/23) Installing liberica17-lite-jre-client (17.0.5_p8-r1)
Executing liberica17-lite-jre-client-17.0.5_p8-r1.post-install
(21/23) Installing liberica17-lite-jre-minimal (17.0.5_p8-r1)
Executing liberica17-lite-jre-minimal-17.0.5_p8-r1.post-install
(22/23) Installing liberica17-lite-jmods (17.0.5_p8-r1)
(23/23) Installing liberica17-lite-jdk-all (17.0.5_p8-r1)
Executing busybox-1.35.0-r16.trigger
Executing java-common-1.0-r1.trigger
Executing fontconfig-2.14.1-r0.trigger
Executing mkfontscale-1.2.2-r1.trigger
OK: 215 MiB in 38 packages
fetch https://packages.bell-sw.com/alpaquita/musl/stream/core/x86_64/APKINDEX.tar.gz
fetch https://packages.bell-sw.com/alpaquita/musl/stream/universe/x86_64/APKINDEX.tar.gz
(1/23) Purging liberica17-lite-jdk-all (17.0.5_p8-r1)
(2/23) Purging binutils (2.39-r2)
(3/23) Purging liberica17-lite (17.0.5_p8-r1)
(4/23) Purging liberica17-lite-jre-client (17.0.5_p8-r1)
Executing liberica17-lite-jre-client-17.0.5_p8-r1.post-deinstall
(5/23) Purging liberica17-lite-jre-minimal (17.0.5_p8-r1)
Executing liberica17-lite-jre-minimal-17.0.5_p8-r1.post-deinstall
(6/23) Purging liberica17-lite-jmods (17.0.5_p8-r1)
(7/23) Purging liberica17-lite-jdk (17.0.5_p8-r1)
(8/23) Purging liberica17-lite-jre (17.0.5_p8-r1)
(9/23) Purging ttf-dejavu-core (2.37-r1)
(10/23) Purging fontconfig (2.14.1-r0)
(11/23) Purging encodings (1.0.5-r0)
(12/23) Purging mkfontscale (1.2.2-r1)
(13/23) Purging freetype (2.12.1-r0)
(14/23) Purging liberica17-lite-jdk-no-deps (17.0.5_p8-r1)
(15/23) Purging liberica17-lite-jre-no-deps (17.0.5_p8-r1)
(16/23) Purging java-common (1.0-r1)
(17/23) Purging libstdc++ (12.2.1_git20220924-r3)
(18/23) Purging libgcc (12.2.1_git20220924-r3)
(19/23) Purging libexpat (2.5.0-r0)
(20/23) Purging brotli-libs (1.0.9-r6)
(21/23) Purging libbz2 (1.0.8-r3)
(22/23) Purging libpng (1.6.38-r0)
(23/23) Purging libfontenc (1.1.6-r0)
Executing busybox-1.35.0-r16.trigger
OK: 7 MiB in 15 packages
Removing intermediate container 4f60b9359806
 ---> 18f7cb93dcbe
Step 3/5 : ENV JAVA_HOME="/opt/customjdk"
 ---> Running in cb84aa2216e5
Removing intermediate container cb84aa2216e5
 ---> 1cc45d45952d
Step 4/5 : ENV PATH="$JAVA_HOME/bin:$PATH"
 ---> Running in 0aad38b1eadc
Removing intermediate container 0aad38b1eadc
 ---> 150d0bd8d716
Step 5/5 : CMD ["java", "-version"]
 ---> Running in 0ab2c688cbc8
Removing intermediate container 0ab2c688cbc8
 ---> f2bc7aaf2e8c
Successfully built f2bc7aaf2e8c
Successfully tagged customjdk:latest

Finally, let’s run our image:

$ docker run --rm -it customjdk
openjdk version "17.0.5" 2022-10-18 LTS
OpenJDK Runtime Environment (build 17.0.5+8-LTS)
OpenJDK 64-Bit ServerVM (build 17.0.5+8-LTS, mixed mode)

We can check the size of the image with the following command:

$ docker inspect --format '{{.Size}}' customjdk
40300400

The Docker image size is only about 40.3MiB. And its compressed size will be around 20.4MiB. You can play with the --compress option described above to achieve the desired result.

Of note is that the same image with Minimal VM (`--vm minimal`) has an uncompressed size of 24.4 MiB and compressed size of 14.93 MiB.

Conclusion

As you can see, there are numerous ways of keeping Docker container images clean and lightweight. The key takeaway — keep unnecessary files out of the image and use the smallest base image possible.

Java developers can make use of Liberica Runtime Container. But if you are only interested in the OS, take a look at Alpaquita: it is small like Alpine, but has two performant libc variants and comes with optional commercial support making it perfect for enterprise development.

 

Subcribe to our newsletter

figure

Read the industry news, receive solutions to your problems, and find the ways to save money.

Further reading