Spring Native on Google Cloud

1. Overview

In this codelab, we'll be learning about the Spring Native project, building an app that uses it, and deploying it on Google Cloud.

We'll go over its components, the recent history of the project, some use cases, and of course the steps required for you to use it in your projects.

The Spring Native project is currently in an experimental phase, so it will require some specific configuration to get started. However, as announced at SpringOne 2021, Spring Native is set to be integrated into Spring Framework 6.0 and Spring Boot 3.0 with first class support, so this is the perfect time to take a closer look at the project a few months before its release.

While the just-in-time compilation has been very well optimized for things like long running processes, there are certain use cases in which ahead-of-time compiled applications perform even better, which we'll be discussing during the codelab.

You'll learn how to

  • Use Cloud Shell
  • Enable the Cloud Run API
  • Create and deploy a Spring Native app
  • Deploy such an app to Cloud Run

What you'll need

Survey

How will you use this tutorial?

Read it through only Read it and complete the exercises

How would you rate your experience with Java?

Novice Intermediate Proficient

How would you rate your experience with using Google Cloud services?

Novice Intermediate Proficient

2. Background

The Spring Native project makes use of several technologies to deliver native application performance to developers.

To fully understand Spring Native, it's helpful to understand a few of these component technologies, what they enable for us, and how they work together here.

AOT compilation

When developers run javac normally at compile time, our .java source code is compiled into .class files which are written in bytecode. This bytecode is only meant to be understood by the Java Virtual Machine, so the JVM will have to interpret this code on other machines in order for us to run our code.

This process is what gives us Java's signature portability - allowing us to "write once and run everywhere", but it is expensive when compared to running native code.

Fortunately, most implementations of the JVM use just-in-time compilation to mitigate this interpretation cost. This is achieved by counting the invocations for a function, and if it's invoked often enough to pass a threshold ( 10,000 by default), it is compiled to native code at run time to prevent further expensive interpretation.

Ahead-of-time compilation takes the opposite approach, by compiling all reachable code into a native executable at compile time. This trades portability for memory efficiency and other performance gains at run time.

5042e8e62a05a27.png

This is of course a trade off, and isn't always worth taking. However, AOT compilation can shine in certain use cases such as:

  • Short lived applications where startup time is important
  • Highly memory constrained environments where JIT may be too costly

As a fun fact, AOT compilation was introduced as an experimental feature in JDK 9, though this implementation was expensive to maintain, and never quite caught on, so it was quietly removed in Java 17 in favor of developers just using GraalVM.

GraalVM

GraalVM is a highly optimized open source JDK distribution that boasts extremely fast startup times, AOT native image compilation, and polyglot capabilities that allow developers to mix multiple languages into a single application.

GraalVM is in active development, gaining new capabilities and improving existing ones all the time, so I encourage developers to stay tuned.

Some recent milestones are:

  • A new, user friendly native image build output ( 2021-01-18)
  • Java 17 support ( 2022-01-18)
  • Enabling multi-tier compilation by default to improve polyglot compile times ( 2021-04-20)

Spring Native

Simply put - Spring Native enables the use of GraalVM's native-image compiler to turn Spring applications into native executables.

This process involves performing a static analysis of your application at compile time to find all methods in your application that are reachable from the entry point.

This essentially creates a "closed-world" conception of your application, where all code is assumed to be known at compile time, and no new code is allowed to be loaded at runtime.

It is important to note that native image generation is a memory intensive process that takes longer than compiling a regular application, and imposes limitations on certain aspects of Java.

In some cases, no code changes are required for an application to work with Spring Native. However, some situations require specific native configuration to work properly. In those situations, Spring Native often provides Native Hints to simplify this process.

3. Setup/Prework

Before we start implementing Spring Native, we'll need to create and deploy our app to establish a performance baseline that we can compare to the native version later.

1. Creating the project

We'll start by getting our app from start.spring.io:

curl https://start.spring.io/starter.zip -d dependencies=web \
           -d javaVersion=11 \
           -d bootVersion=2.6.4 -o io-native-starter.zip

This starter app uses Spring Boot 2.6.4, which is the latest version that the spring-native project supports at time of writing.

Note that since the release of GraalVM 21.0.3, you could use Java 17 for this sample as well. We will still be using Java 11 for this tutorial to minimize the config involved.

Once we have our zip on the command line, we can create a subdirectory for our project and unzip the folder in there:

mkdir spring-native

cd spring-native

unzip ../io-native-starter.zip

2. Code changes

Once we have the project open, we'll quickly add a sign of life and showcase the Spring Native performance once we run it.

Edit the DemoApplication.java to match this:

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.Duration;
import java.time.Instant;

@RestController
@SpringBootApplication
public class DemoApplication {
    private static Instant startTime;
    private static Instant readyTime;

    public static void main(String[] args) {
        startTime = Instant.now();
                SpringApplication.run(DemoApplication.class, args);
    }

    @GetMapping("/")
    public String index() {
        return "Time between start and ApplicationReadyEvent: "
                + Duration.between(startTime, readyTime).toMillis()
                + "ms";
    }

    @EventListener(ApplicationReadyEvent.class)
    public void ready() {
                readyTime = Instant.now();
    }
}

At this point our baseline app is ready to go, so feel free to build an image and run it locally in order to get an idea of the startup time before we convert it to a native application.

To build our image:

mvn spring-boot:build-image

You can also use docker images demo to get an idea of the size of the baseline image: 6ecb403e9af1475e.png

To run our app:

docker run --rm -p 8080:8080 demo:0.0.1-SNAPSHOT

3. Deploy baseline app

Now that we have our app we'll deploy it and take note of the times, which we'll compare to our native app startup times later on.

Depending on the type of application you're building there are several different hosting your stuff.

However, since our example is a very simple, straightforward web application, we can keep things simple and rely on Cloud Run.

If you're following along on your own machine, make sure to have the gcloud CLI tool installed and updated.

If you're on the Cloud Shell that will all be taken care of and you can simply run the following in the source directory:

gcloud run deploy

4. Application Configuration

1. Configuring our Maven repositories

Since this project is still in the experimental phase, we'll have to configure our app to be able to find experimental artifacts, which are not available in maven's central repository.

This will involve adding the following elements to our pom.xml, which you can do in the editor of your choice.

Add the following repositories and pluginRepositories sections to our pom:

<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>

2. Adding our dependencies

Next, add the spring-native dependency, which is required to run a Spring application as a native image. Note: this step is not necessary if you are using Gradle

<dependencies>
    <!-- ... -->
    <dependency>
        <groupId>org.springframework.experimental</groupId>
        <artifactId>spring-native</artifactId>
        <version>0.11.2</version>
    </dependency>
</dependencies>

3. Adding/enabling our plugins

Now add the AOT plugin to improve native image compatibility and footprint ( Read more):

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

Now we'll update the spring-boot-maven-plugin to enable native image support and use the paketo builder to build our native image:

<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>    
</plugins>

Note that the tiny builder image is just one of several options. It's a good choice for our use case because it has very few extra libraries and utilities, which helps to minimize our attack surface.

If for example you were building an app that needed access to some common C libraries, or you weren't yet sure of your app's requirements, the full-builder may be a better fit.

5. Build and Run native app

Once that's all in place, we should be able to build our image and run our native, compiled app.

Before running the build, here are a few things to keep in mind:

  • This will take more time than a regular build (a few minutes) d420322893640701.png
  • This build process can take a lot of memory (a few gigabytes) cda24e1eb11fdbea.png
  • This build process requires the Docker daemon to be reachable
  • While in this example we're going through the process manually, you can also configure your build phases to automatically trigger a native build profile.

To build our image:

mvn spring-boot:build-image

Once that's built, we're ready to see the native app in action!

To run our app:

docker run --rm -p 8080:8080 demo:0.0.1-SNAPSHOT

At this point we're in a great position to see both sides of the native application equation.

We've given up a bit of time and extra memory usage at compile time, but in exchange we get an application that can start up far more quickly, and consume significantly less memory (depending on the workload).

If we run docker images demo to compare the size of the native image to the original, we can see a dramatic reduction:

e667f65a011c1328.png

We should also note that in more complex use cases, there are additional modifications needed to inform the AOT compiler of what your app will do at runtime. For that reason, certain predictable workloads (such as batch jobs) may be very well suited to this, while others may be a bigger lift.

6. Deploying our native app

In order to deploy our app to Cloud Run, we'll need to get our native image into a package manager like Artifact Registry.

1. Preparing our Docker repository

We can start this process by creating a repository:

gcloud artifacts repositories create native-image-docker-repo --repository-format=docker \
--location=us-central1 --description="Repository for our native images"

Next, we'll want to make sure that we're authenticated to push to our new registry.

The gcloud CLI can simplify that process quite a bit:

gcloud auth configure-docker us-central1-docker.pkg.dev

2. Pushing our image to Artifact Registry

Next we'll tag our image:

export PROJECT_ID=$(gcloud config list --format 'value(core.project)')


docker tag  demo:0.0.1-SNAPSHOT \
us-central1-docker.pkg.dev/$PROJECT_ID/native-image-docker-repo/quickstart-image:tag2

And then we can use docker push to send it to Artifact Registry:

docker push us-central1-docker.pkg.dev/$PROJECT_ID/native-image-docker-repo/quickstart-image:tag2

3. Deploying to Cloud Run

We are now ready to deploy the image we've stored in Artifact Registry to Cloud Run:

gcloud run deploy --image us-central1-docker.pkg.dev/$PROJECT_ID/native-image-docker-repo/quickstart-image:tag2

Since we've built and deployed our app as a native image, we can rest assured that our application is making excellent use of our infrastructure costs as it runs.

Feel free to compare the startup times of our baseline app to this new native one for yourself!

6dde63d35959b1bb.png

7. Summary/Cleanup

Congratulations on building and deploying a Spring Native application on Google Cloud!

Hopefully this tutorial encourages you to get more familiar with the Spring Native project and keep it in mind should it meet your needs in the future.

Optional: Clean up and/or disable service

Whether you created a Google Cloud project for this codelab or are reusing an existing one, take care to avoid unnecessary charges from the resources we made use of.

You can delete or disable the Cloud Run services we created, delete the image we hosted, or shut down the whole project.

8. Additional resources

While the Spring Native project is currently a new and experimental project, there is already a wealth of good resources to help early adopters troubleshoot issues and get involved:

Additional resources

Below are online resources which may be relevant for this tutorial:

License

This work is licensed under a Creative Commons Attribution 2.0 Generic License.