Cloud Run: The Spring Boot rebirth with GraalVM native compilation
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!