Spring Native on established codebases

In this blog post we will check out Java Native applications from the perspective of a company that already has an established codebase, and how intrusive it would be to convert an existing application to run natively.
19.08.2021
Gustavo Monarin de Sousa
Tags

Spring Native Beta was recently announced. It provides support for compiling Spring applications to small executables that do not require the Java Virtual Machine (JVM) to run.

In this blog post we will check out Java Native applications from the perspective of a company that already has an established codebase, and how intrusive it would be to convert an existing application to run natively.

Established Spring codebase

The Spring Framework and especially Spring Boot is without doubt one of the most common frameworks for building web applications, and from our experience, the most common framework for the JVM.

In my experience, companies often build Spring Boot-based AutoConfigurations and common libraries for developers to integrate their business applications with the underlying infrastructure platform. Thus, providing an out-of-box experience and boosting developer productivity. Another common usage is to extend Spring Boot dependency management to the company supported versions.

One of the reasons Spring Boot is the most common framework is because it has already been battle tested for almost a couple of decades, and brings a huge ecosystem of tailored and independent modules - from databases to message brokers, from command line tools to web and cloud native applications. However, the maturity, size of the ecosystem and wide adoption has the potential to slow down innovation and to prevent the introduction of completely new concepts. An example for this is the reactive support. It was introduced by other frameworks like Akka several years ago, but was only supported in the Spring framework in version 5 and its integration with the project Reactor.

Framework evolution and challenges

It is interesting to consider that when Spring framework first appeared, it was to challenge the heaviness of the EJB platform by providing a slim alternative. Lately, new frameworks such as Micronaut are competing with Spring on the same ground, proposing more lightweight alternatives.

Ahead-of-time configuration and compilation were some of the most relevant features of the last years and where Micronaut was one of the pioneers. The project was created by the developers behind the Grails framework that had a deep understanding of the Spring framework. Instead of scanning the classpath at application startup time to configure the application, Micronaut analyses and generates the needed configuration during compilation time, boosting the application startup time among other benefits.

The ahead-of-time configuration is one of the pre-requirements for native applications using GraalVM, which adds constraints on the usage of reflection and runtime access. The applications are smaller in size, memory consumption and start-up time. The considerably new project Quarkus became a reference of native applications with a strong community and strong marketing from RedHat.

Spring support for ahead-of-time configuration and native application support using the reasonable new GraalVM is clearly behind the challenger frameworks mentioned before. Below we will check if a company that has an established codebase would be able to rely on the Spring framework to run applications natively in the near future.

Native support in Spring framework?

Before we start, it is important to note that native support is not the silver bullet for all resource and performance problems. It is definitely a good fit for cloud applications - especially for serverless scenarios where start-up time matters. However, in some cases the JVM and the JIT/Hotspot could provide better performance with optimizations at runtime, which is not possible with ahead-of-time compiled applications like native applications and the GraalVM.

Spring Native 0.9.2 has many small samples for almost all the modules from the Spring family: Batch, Cloud, Data and many more. The Spring Native documentation is also really straightforward and well documented. Too good to be true?

This article tries to apply Spring Native beta support in a more complete scenario by simulating an existing application that combines Spring Web and Spring Data for Elasticsearch. This fits better to the dynamic load scenario described in the previous paragraph. More details about the application code used and the Spring Data Elasticsearch support can be found in this article.

The first step is to check the current version of the existing application. Spring Native requires Spring boot > 2.4.5.
SpringNative

Secondly, when using Maven, a new spring-native dependency is required, which will also provide additional annotations to further customize the application as @NativeHint.

<dependency>
  <groupId>org.springframework.experimental</groupId>
  <artifactId>spring-native</artifactId>
  <version>0.9.2</version>
</dependency>

At this point we can follow two directions: compiling the application locally and running directly as fat jar and native binary, or using Spring Cloud Build Packs to compile and generate Docker images within Docker. Since most of the applications nowadays will run as a Docker image, we choose cloud build packs. The first approach would be quite similar and is also well documented. Gradle instructions also exist for both cases.

The following example adds the cloud build pack plugin and the Spring ahead-of-time plugin, responsible for generating the configuration and start-up code based on the application AutoConfiguration and available classpath:

<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>true</BP_NATIVE_IMAGE>
              </env>
           </image>
        </configuration>
     </plugin>

     <plugin>
        <groupId>org.springframework.experimental</groupId>
        <artifactId>spring-aot-maven-plugin</artifactId>
        <version>0.9.2</version>
        <executions>
           <execution>
              <id>test-generate</id>
              <goals>
                 <goal>test-generate</goal>
              </goals>
           </execution>
           <execution>
              <id>generate</id>
              <goals>
                 <goal>generate</goal>
              </goals>
           </execution>
        </executions>
     </plugin>
  <!-- ... -->
  </plugins>
</build>

This configuration is all that is needed for an existing project to create GraalVM native images. Impressively, it worked out-of-the box for the chosen project with little to almost no changes and observations:

  • Since the spring-aot-maven-plugin and spring-native artifacts are still in beta, you might need to add the snapshot repositories if they are not there yet:
<repositories>
  <!-- ... -->
  <repository>
     <id>spring-release</id>
     <name>Spring release</name>
     <url>https://repo.spring.io/release</url>
  </repository>
</repositories>

<pluginRepositories>
  <!-- ... -->
  <pluginRepository>
     <id>spring-release</id>
     <name>Spring release</name>
     <url>https://repo.spring.io/release</url>
  </pluginRepository>
</pluginRepositories>
  • To build the native application, GraalVM requires lots of memory. My initial 2G mem +1G swap Docker configuration failed early in the process. After increasing it to 7G, it worked nicely:
    SpringNative4

  • In order to simplify the start-up and to not depend on the running Elasticsearch described in the Spring Web / Elastic article, a docker-compose file was created:

version: '3.8'
services:

 product-search-app:
   image: productsearchapp:0.0.1-SNAPSHOT
   environment:
     - SPRING_ELASTICSEARCH_REST_URIS=elastic-search:9200
   ports:
     - "8080:8080"
   depends_on:
     elastic-search:
       condition: service_healthy

 elastic-search:
   image: docker.elastic.co/elasticsearch/elasticsearch:7.10.0
   environment:
     - discovery.type=single-node
     - bootstrap.memory_lock=true
     - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
   ports:
     - "9200:9200"
   healthcheck:
     test: [ "CMD-SHELL", "curl --silent --fail localhost:9200/_cluster/health || exit 1" ]
     interval: 3s
     timeout: 30s
     retries: 10

Now to run everything, all that is needed is:
SpringNative6

Some other small adjustments had to be made in the specific project. Most were not relevant to the Spring Native adaptation though. Instead, as is common on any established and legacy codebase, they were done for code modernization:

  • During the Spring Boot upgrade, a deprecated property was removed in the new version (more details in the commit, but not relevant here as it is really specific from the source code). This is common on any update and highlights the importance of keeping projects updated to avoid accumulation of small changes.
  • The application was not using the Spring Boot Elasticsearch starter. Thus, cloud native capabilities such as configuring the Elasticsearch address based on environment variables were missing. Migrating it to Spring Boot starter made it much easier to integrate / run in the Docker environment for instance.
  • Also, the used application was using a highly customized logging configuration, including logging to file which actually was failing for some reason when running natively. In a cloud native scenario, as described in the Twelve-Factor App, logging, routing and decision making should not be the responsibility of the application. The Spring Boot defaults were restored, which complies nicely with the Twelve-Factor App / cloud native standards and worked out-of-the box with Spring Native.
  • The project only used Spring supported projects such as Spring Data and Spring Web. However, in a more complex scenario with custom auto configurations, you might need to provide hints about the AutoConfiguration and some reflection cases. Once again the official documentation looks complete and extensive, diving into it would require an article on its own.

The final code is available here. A more elaborated branch is also available, which uses different profiles: One for build packs with the traditional fat jar and another for Spring Native without actually changing the original default profile. This makes it simple to create different builds for comparison.

Spring Native application numbers

Now that we have the application running on the JVM and on the GraalVM as a native image, let’s have a look at the numbers.

Docker image building times are considerably different for the JVM and GraalVM. Although they should not considerably affect the local development red/green refactor lifecycle, it is important to notice the building time jump from less than 30s while building the docker image with a fully featured JVM to more than 7 minutes for the GraalVM. The GraalVM build needs extra resources, especially memory.

The resulting images are quite different in terms of size. The image containing the JVM application traditionally packed as jars and using multiple layers (for caching and incremental build), libraries and the application itself summed approximately 57Mb. JVM and initialization tools layers were 150Mb. The total JVM image size including the base image and certificates was 301Mb.
On the other hand, the native image had a unique application file and layer of 121Mb, which consists of the application, libraries and the GraalVM itself. The total native image size was 144Mb. Important to note that docker base images are different and also added to the total image size as visible in the following image:
^2 The JVM and the GralIVM docker images respectively, inspected by the dive tool

The start-up time of the Spring Boot Rest and Elasticsearch application was inline with the promoted numbers by GraalVM and Spring community when comparing a fully featured JVM and the GraalVM. While the traditional Spring Boot application took on average around 6 seconds to start up, the Spring Native application using GraalVM took around 500 milliseconds to start up as seen on the following image. For this test, the clean-up and population of the Elasticsearch document store was disabled.
GraalVM 1

There is also a reduction of around 4 times when it comes to memory consumption. While the fully featured JVM consumed more than 200Mb of memory on average after multiple startups, the GraalVM consumed less than 50Mb as visible in the following image:
GraalVM

More metrics beyond the startup time could be elaborated, especially together with load testing. However, this could deviate from the initial scenario of dynamic and serverless workloads that GraalVM seems to be promising to more traditional static and long running workloads for which the JVM Just-In-Time compiler has already proved its value.

To compare these numbers between different frameworks is important when starting a new project. However, on established codebases normally it is not an option or trivial task to move to a different framework.

Conclusion

The Spring team has done an amazing job supporting Spring Native applications out-of-the box to the Spring ecosystem composed of a multitude of modules.

Companies with an extensive code base will be able to run their applications as a native applications soon with almost no changes. The current beta version startup metrics have the potential to already enable established codebases to run in a more dynamic environment as serverless.

Spring Native is not as mature as the competitors’ frameworks yet. However, this gap tends to be reduced quickly as all the companies using Spring join the native world.