Cloud Run: The Spring Boot rebirth with GraalVM native compilation

guillaume blaquiere
Google Cloud - Community
6 min readDec 4, 2023

--

Information Technology is about evolution, revolution, trend and cycle.
Cloud Computing is a major evolution of traditional computing.
Serverless is, for me, a revolution for deploying, (self)managed and highly scalable applications
Java was very popular when I graduated 16 years ago, nowadays, Python is very trendy.

What about cycles?

I started as a developer with Java, Spring framework and then Spring Boot. However, with the serverless revolution, Cloud Run in the lead, I have been obliged to switch to a more modern, lighter and faster-to-start language.
You can check my articles on Java framework performance comparison and language performance comparison on Cloud Run for more details.

I switched to Python (because it is, unfairly, popular (Still personal opinion)) and then to Go. According with my Language Performance comparison article, Go is today my preferred language, type safe, fast, light (container size and memory footprint).

But, a recent article of Abirami Sukumaran, developer advocate at Google Cloud, led me to give another chance to Spring Boot (and its 9-seconds-cold-start for a simple hello world)

You can find the full code of this article in my Github repository

The testing app

My test is very simple. I have one API / which answers Hello world and another one /kill that exits the container.

The /kill endpoint exits the current container on the current instance and thus forces Cloud Run to create a new one on the next request .
It’s a trick to simplify performance test protocol

@RestController
public class SimpleController {
@GetMapping("/")
public ResponseEntity<?> hello() {
return new ResponseEntity<>("hello world", HttpStatus.OK);
}

@GetMapping("/kill")
public String kill() {
System.exit(0);
return "bye world";
}
}

I use the Maven plugin to generate the container (Docker must be installed) with and without a Profile.
The native profile force the native compilation when the container is built
I used the Abirami tutorial to configure my pom.xml file.

    <properties>
<java.version>11</java.version>
<activeNativeCompilation>false</activeNativeCompilation>
</properties>
<profiles>
<profile>
<id>native</id>
<properties>
<activeNativeCompilation>true</activeNativeCompilation>
</properties>
</profile>
</profiles>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<builder>paketobuildpacks/builder:tiny</builder>
<env>
<BP_NATIVE_IMAGE>${activeNativeCompilation}</BP_NATIVE_IMAGE>
</env>
</image>
</configuration>
</plugin>
...

Finally, I push the different containers (with and without native compilation) to Artifact Registry (again with Docker) and deploy the service on Cloud Run

export PROJECT_ID="<your project ID>"

mvn spring-boot:build-image \
-Dspring-boot.build-image.imageName=gcr.io/${PROJECT_ID}/spring-boot-native-off
mvn spring-boot:build-image \
-Dspring-boot.build-image.imageName=gcr.io/${PROJECT_ID}/spring-boot-native-on -P native -Dmaven.test.skip=true

docker push gcr.io/${PROJECT_ID}/spring-boot-native-off
docker push gcr.io/${PROJECT_ID}/spring-boot-native-on

gcloud run deploy spring-boot-native-off \
--image=gcr.io/${PROJECT_ID}/spring-boot-native-off \
--platform=managed --region=us-central1 \
--allow-unauthenticated --memory=595Mi
gcloud run deploy spring-boot-native-on \
--image=gcr.io/${PROJECT_ID}/spring-boot-native-on \
--platform=managed --region=us-central1 \
--allow-unauthenticated --memory=128Mi

The benchmark metrics

I chose to compare the 2 versions (without native compilation, also named legacy in this article, and with native compilation) over 3 metrics:

  • The required memory (to start the service and at runtime)
  • The cold start latency
  • The container size

The memory evaluation

For the legacy container, I started by deploying naively without any memory instruction (and let the 512Mb of memory by default).

And by default it crashed! The logs explain clearly the issue

Ok, let’s set 595Mb with the parameter --memory=595Mi and then it worked!

For the native container, I immediately tried the minimum memory, 128Mb, and it worked at the first try!
Note: On gen2 execution runtime environment, 512Mb is the minimum.

About the memory used at runtime, I will use the Cloud Run metrics graph to get the percentage of used memory for each service.

The cold start test protocol

The cold start is the time taken by the container to start and initialize it’s environment.

To simulate this startup, I wrote a script that repeats, in a loop the HTTP calls to the /kill and / endpoint, to force 10 cold starts in a row of the Cloud Run service.

run_perf_test() {
local URL=$1
local total=0
local counter=10

for i in $(seq 1 ${counter}); do
curl -o /dev/null -s ${URL}/kill
result=$(curl -w "@simple-curl-format.txt" -o /dev/null -s ${URL})
total=$(echo "${total} + ${result}" | bc)
done
AVERAGE_TIME=$(echo " (1000 * ${total}) / ${counter}" | bc)
}

Note that bc must be installed to allow float type handling

The average time taken by this script is displayed

The container size

The container size has no effect on the deployment and start up time. However, at scale, it can have an impact on your billing!

That’s why I went to Artifact registry to get the image size of each container type.

The result summary

To enlarge the tests, I chose to include 2 other features that might impact the performances:

  • CPU Boost
  • First and Second generation execution runtime

The Cloud Run CPU Boost feature

The CPU Boost feature double the number of CPUs at startup (when it’s possible, the total of CPUs can’t exceed 8)

First and second generation

Cloud Run 1st generation is a sandboxed environment (gVisor). Fast to start but with limitations: some Cloud Run features are not available (like mounting external storage)

The second generation is known as slower to start but more agnostic.

So, I tested the difference between the first and second generation of Cloud Run to see what the impacts are.

Test perf results

Finally, the results are here. The table present the average duration of 10 queries in a row in the closest regium from my location

You can reproduce the benchmark on your side by using the perf-test.sh script

This table is a no-brainer and the improvements brough tby native compilation are outstanding.

  • 10x faster
  • 3x less memory
  • 3x smallest container size

Native compilation is all green!

As expected Gen2 is slower to start.
The CPU boost on the legacy image has also the expected impact: 2x more CPU = 2x faster to start

The most unexpected flaw here is the negative impact of the CPU Boost on the Native compilation version. My guess here is the following

Because my solution is very small and simple to load, the time taken to distribute the computation on several CPUs and sync the processes is above the possible/expected benefit.

With a real world application with dozens of beans to initiate, it should not be the case and the CPU Boost should still have a positive impact.

Spring boot back in the game

Surprisingly, Spring Boot native compilation is incredibly fast, the memory footprint low and the container size shrunk. An unexpected comeback that could change many things for all the Java and Spring Boot developers.

Looping back to my article that compare the languages performances, Spring Boot native compilation performs well!

  • 2nd for the cold start (Go is still 30% faster)
  • 2nd in memory footprint
  • 1st for the container size (but the content is not exactly the same)
  • Performance can be expected as any Java application, so 1st place

A real challenger to Golang, and with a real powerful and well-established framework!

By writing this article, I’m thinking of my colleagues at Carrefour that use Spring Boot extensively and that found only GKE and always-on containers/pods to avoid the cold start issue.

Now the road to Cloud Run is widely open!

Personally, I love too much the simplicity and the succinctness of Go, while keeping code safe and modern (in terms of concurrent processing).

But all Java and Spring Boot lovers have a new choice and a new card in their game with Cloud Run and native compilation!

--

--

guillaume blaquiere
Google Cloud - Community

GDE cloud platform, Group Data Architect @Carrefour, speaker, writer and polyglot developer, Google Cloud platform 3x certified, serverless addict and Go fan.