Contents

Building Spring boot app for the cloud

Short reference for building a Spring boot application Docker container with Java 22.

Why being small in the cloud matters

With the cloud era and tens to hundreds of services within a company, we aim in reducing the costs of CPU and especially RAM usage of our applications, to be as efficient as possible with our resources and also reducing the price.

Memory usage
If an application memory usage that was initially 500Mb would get reduced to 250Mb, it would cut company cloud costs by 2x. A lot of environments will increase or decrease the number of these applications to handle a load increase or reduction in order to handle costs, so scaling up a 500mb app 4 times ends up being ~2GB of RAM where it could be just ~1GB and so on.

There are some times when applications are not even used, we don’t want to have a 2GB service sitting there handling only a couple of requests, we want to have a small one that we can automatically scale when we get an increase, thus efficiently using only what we need when we need.

Memory usage in containers

Before Java 9, JVM would only look at the host for memory and cpu, ignoring any container constraints it had and would consume as much as it could. With Java 9 and then other iterations came a lot of improvements to this, so that the JVM properly sets the memory, cpu it can use and the appropriate garbage collector.

When you allocate JVM heap memory, be aware that the JVM needs more memory than just what is used for the JVM heap. When you set the maximum JVM heap memory, it should never be equal to the amount of container memory because that will cause container Out of Memory (OOM) errors and container crashes.

Memory tip
Allocate 70% of container memory for the JVM heap and at least 1vCPU core.

On OpenJDK 11 and later, you can set the JVM heap size in the following ways:

DescriptionFlagExamples
Fixed value-Xmx-Xmx4g
Dynamic value-XX:MaxRAMPercentage-XX:MaxRAMPercentage=70

Microsoft reference is a good start to learn more about memory usage and which GC to select.

Startup tip
For JVM apps, for a quick startup it’s good to have an initial burst of 2vCPUs so that the reflection and setup can be much faster.

Checking container stats or actual cpu and memory usage can be done with the command (DO NOT use docker stat or anything derived from it) (Quarkus guide for measuring memory):

docker top <CONTAINER ID> -o pid,rss,args

which outputs

PID                 RSS                 COMMAND
2531                27m                 ./application -Dquarkus.http.host=0.0.0.0

or if you are using Docker dashboard app you can navigate to the app, and it’s stats tab, but as stated above this will not show you real memory usage.

/posts/spring-boot-building-for-cloud/jvm-docker-memory-usage.webp
JVM Docker memory usage

Building the Docker image

It’s important to keep in mind, because we are building a Java app, that we need to have an OS on which we run the JVM on which we load our application. That makes our resulting Docker image “fat” with the size of ~750MB (Spring boot that includes postgresql and flyway), which is huge. This is of course because we put everything on the pile that the app will use or not.

Docker image size
Docker image size does not represent memory usage, it is just the size of the artifact/image, and it also helps to have this as small as possible.

How can we get it smaller?

We use the following

  1. multi-stage builds so that we first build the app in one image and then have just the runtime in the production image that runs the app.
  2. Layering Docker images. This layering is designed to separate code based on how likely it is to change between application builds.
  3. jlink, we can create our own, small JRE that contains only the relevant classes that we want to use, without wasting memory, and as a result, we’ll see increased performance. Here we use an existing custom alpine Docker image for Java 22 eclipse-temurin:22.0.2_9-jre-alpine.

Here is the Dockerfile that contains everything.

# Build jar
FROM maven:3.9.9-eclipse-temurin-22-jammy AS maven
RUN mkdir /application
WORKDIR /application
COPY src ./src
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn clean package -DskipTests

# Extract jar layers
FROM eclipse-temurin:22-jre-ubi9-minimal AS builder
WORKDIR /builder
COPY --from=maven /application/target/*.jar application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted

# Runtime
FROM eclipse-temurin:22.0.2_9-jre-alpine
WORKDIR /application

COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./

RUN addgroup --system appuser \
    && adduser -S -s /usr/sbin/nologin -G appuser appuser

USER appuser

# For GC check the Microsoft reference or leave it out for JVM to decide which one to use
ENTRYPOINT ["java","-XX:InitialRAMPercentage=70","-XX:MaxRAMPercentage=70","-XX:+UseG1GC","-XX:+UseStringDeduplication","-jar","application.jar"]

And then build it with the Docker command

docker build -t myapp .

We can see that the resulting docker image has the size of 362MB, which is 2x smaller than the starting one! Spring boot effective Docker images reference.

Native

There is also a possibility to build native images for Spring boot which can reduce the application memory size drastically (up to ~20x) BUT, I had to give up on making Graalvm work on Spring boot app. It’s just unstable and incredibly time-consuming to make it work. This is just not expected to be easy with Spring as that framework was made in 2003. and there is too much reflection abuse and questionable framework design that doesn’t fare well in today’s standards. Even with soo many iterations of the framework you can’t change its core design.

If you plan on sticking and trying to set up Spring boot for native, expect to face following errors:

2.083 [INFO] Building commandrunner 0.0.1-SNAPSHOT
2.083 [INFO] --------------------------------[ jar ]---------------------------------
2.251 [INFO] 
2.251 [INFO] --- maven-clean-plugin:3.3.2:clean (default-clean) @ commandrunner ---
2.264 [INFO] 
2.264 [INFO] --- native-maven-plugin:0.10.2:add-reachability-metadata (add-reachability-metadata) @ commandrunner ---
2.267 Downloading from central: https://repo.maven.apache.org/maven2/org/graalvm/buildtools/graalvm-reachability-metadata/0.10.2/graalvm-reachability-metadata-0.10.2-repository.zip
Downloaded from central: https://repo.maven.apache.org/maven2/org/graalvm/buildtools/graalvm-reachability-metadata/0.10.2/graalvm-reachability-metadata-0.10.2-repository.zip (251 kB at 2.8 MB/s)
2.362 [INFO] Downloaded GraalVM reachability metadata repository from file:/root/.m2/repository/org/graalvm/buildtools/graalvm-reachability-metadata/0.10.2/graalvm-reachability-metadata-0.10.2-repository.zip
2.460 [INFO] [graalvm reachability metadata repository for com.zaxxer:HikariCP:5.1.0]: Configuration directory not found. Trying latest version.
2.460 [INFO] [graalvm reachability metadata repository for com.zaxxer:HikariCP:5.1.0]: Configuration directory is com.zaxxer/HikariCP/5.0.1
2.462 [INFO] [graalvm reachability metadata repository for org.hibernate.orm:hibernate-core:6.5.2.Final]: Configuration directory not found. Trying latest version.
2.462 [INFO] [graalvm reachability metadata repository for org.hibernate.orm:hibernate-core:6.5.2.Final]: Configuration directory is org.hibernate.orm/hibernate-core/6.5.0.Final
2.463 [INFO] [graalvm reachability metadata repository for org.jboss.logging:jboss-logging:3.5.3.Final]: Configuration directory not found. Trying latest version.
2.463 [INFO] [graalvm reachability metadata repository for org.jboss.logging:jboss-logging:3.5.3.Final]: Configuration directory is org.jboss.logging/jboss-logging/3.5.0.Final
2.464 [INFO] [graalvm reachability metadata repository for org.glassfish.jaxb:jaxb-runtime:4.0.5]: Configuration directory not found. Trying latest version.
2.464 [INFO] [graalvm reachability metadata repository for org.glassfish.jaxb:jaxb-runtime:4.0.5]: Configuration directory is org.glassfish.jaxb/jaxb-runtime/3.0.2
2.466 [INFO] [graalvm reachability metadata repository for ch.qos.logback:logback-classic:1.5.6]: Configuration directory not found. Trying latest version.
2.466 [INFO] [graalvm reachability metadata repository for ch.qos.logback:logback-classic:1.5.6]: Configuration directory is ch.qos.logback/logback-classic/1.4.9
2.467 [INFO] [graalvm reachability metadata repository for org.hibernate.validator:hibernate-validator:8.0.1.Final]: Configuration directory not found. Trying latest version.
2.467 [INFO] [graalvm reachability metadata repository for org.hibernate.validator:hibernate-validator:8.0.1.Final]: Configuration directory is org.hibernate.validator/hibernate-validator/7.0.4.Final
2.467 [INFO] [graalvm reachability metadata repository for org.hibernate.validator:hibernate-validator:8.0.1.Final]: Configuration directory not found. Trying latest version.
2.467 [INFO] [graalvm reachability metadata repository for org.hibernate.validator:hibernate-validator:8.0.1.Final]: Latest version not found!
2.467 [INFO] [graalvm reachability metadata repository for org.hibernate.validator:hibernate-validator:8.0.1.Final]: missing.
2.468 [INFO] [graalvm reachability metadata repository for org.apache.tomcat.embed:tomcat-embed-core:10.1.26]: Configuration directory not found. Trying latest version.
2.468 [INFO] [graalvm reachability metadata repository for org.apache.tomcat.embed:tomcat-embed-core:10.1.26]: Configuration directory is org.apache.tomcat.embed/tomcat-embed-core/10.0.20
2.470 [INFO] [graalvm reachability metadata repository for com.fasterxml.jackson.core:jackson-databind:2.17.2]: Configuration directory not found. Trying latest version.
2.470 [INFO] [graalvm reachability metadata repository for com.fasterxml.jackson.core:jackson-databind:2.17.2]: Configuration directory is com.fasterxml.jackson.core/jackson-databind/2.15.2
2
2.471 [INFO] [graalvm reachability metadata repository for org.flywaydb:flyway-core:10.10.0]: Configuration directory is org.flywaydb/flyway-core/10.10.0
2.471 [INFO] [graalvm reachability metadata repository for org.flywaydb:flyway-database-postgresql:10.10.0]: Configuration directory is org.flywaydb/flyway-database-postgresql/10.10.0
2.472 [INFO] [graalvm reachability metadata repository for org.postgresql:postgresql:42.7.3]: Configuration directory not found. Trying latest version.
2.472 [INFO] [graalvm reachability metadata repository for org.postgresql:postgresql:42.7.3]: Configuration directory is org.postgresql/postgresql/42.3.4
2.476 [INFO] 
2.476 [INFO] --- maven-resources-plugin:3.3.1:resources (default-resources) @ commandrunner ---
2.501 [INFO] Copying 1 resource from src/main/resources to target/classes
2.506 [INFO] Copying 2 resources from src/main/resources to target/classes
2.506 [INFO] 
2.506 [INFO] --- maven-compiler-plugin:3.13.0:compile (default-compile) @ commandrunner ---
2.540 [INFO] Recompiling the module because of changed source code.
2.543 [INFO] Compiling 24 source files with javac [debug parameters release 22] to target/classes
2.605 [INFO] ------------------------------------------------------------------------
2.605 [INFO] BUILD FAILURE
2.605 [INFO] ------------------------------------------------------------------------
2.606 [INFO] Total time:  2.191 s
2.606 [INFO] Finished at: 2024-09-08T14:05:23Z
2.606 [INFO] ------------------------------------------------------------------------
2.606 [ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.13.0:compile (default-compile) on project commandrunner: Fatal error compiling: error: release version 22 not supported -> [Help 1]
2.606 [ERROR] 
2.606 [ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
2.606 [ERROR] Re-run Maven using the -X switch to enable full debug logging.
2.606 [ERROR] 
2.606 [ERROR] For more information about the errors and possible solutions, please read the following articles:
2.607 [ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoExecutionException
------
Dockerfile:15
--------------------
  13 |     COPY pom.xml .
  14 |     
  15 | >>> RUN --mount=type=cache,target=/root/.m2 mvn clean package -Pnative
  16 |     
  17 |     # Second stage: Lightweight debian-slim image
--------------------
ERROR: failed to solve: process "/bin/sh -c mvn clean package -Pnative" did not complete successfully: exit code: 1

Though newer Java frameworks like Quarkus and Micronaut are built for Graalvm native compilation and have a fresh approach. Because of that they are easy to compile to native.

Fast startup
Native applications also have a drastically faster startup time, in this example the Quarkus app takes 0.025s to start, while if it was a JVM Spring app it would take at least 2.5s to start. This is just the basic naked setup with a couple of libraries, full-blown application would take more time.

Here you can see that the memory consumption of Quarkus app (with DB connection) is an amazing 10MB with image size being 163MB.

/posts/spring-boot-building-for-cloud/native-quarkus-memory-usage.webp
Native Quarkus memory usage

Summary

Always keep in mind the goal of your product/software and the time budget, the comparison with Graalvm and JVM is that with Spring Graalvm is incredibly time-consuming and may not work always as intended, JVM is more than capable , and you probably don’t need anything better, especially since it’s evolving rapidly for the cloud and is easier to test and debug. Native applications provide an instant startup and this is advertised a lot, but you should also put the question of do you really need it.

If small memory footprint is required, and you want to use Java, I suggest checking out Quarkus or Micronaut. Otherwise, be sure to check out Go as well which as a language was made to be easily compiled to native and be used in the cloud.