DEV Community

Cover image for AWS Lambda SnapStart - Part 4 Measuring Java 11 Lambda cold starts with Spring Boot framework
Vadym Kazulkin for AWS Community Builders

Posted on • Updated on

AWS Lambda SnapStart - Part 4 Measuring Java 11 Lambda cold starts with Spring Boot framework

Introduction

In the first, second and third part of the series we talked about the SnapStart in general and made the first tests to compare the cold start of Lambda written in Plain Java with AWS SDK for Java version 2 and using Micronaut and Quarkus Frameworks with and without SnapStart enabled. We saw that enabling SnapStart led to a huge decrease in the cold start times in all 3 cases for our example application. In this part of the series we are going to measure the performance of SnapStart using Spring Boot which is probably the most popular Java framework for writing web-based applications.

Spring Boot Framework

Spring Boot makes it easy to create stand-alone, production-grade Spring based Applications that you can "just run".
To its features belong:

  • Create stand-alone Spring applications
  • Embed Tomcat, Jetty or Undertow directly (no need to deploy WAR files)
  • Provide opinionated 'starter' dependencies to simplify your build configuration
  • Automatically configure Spring and 3rd party libraries whenever possible
  • Provide production-ready features such as metrics, health checks, and externalized configuration
  • Absolutely no code generation and no requirement for XML configuration

Writing AWS Lambda with Spring Boot

It was probably not a very good idea to write Lambda using Java programming language and Spring Boot Framework. Despite the well-spread usage and knowledge of this framework, the fact that Spring (Boot) heavily uses reflection and takes time to start the embedded Web Application Server led to very big cold starts which we'll explore in the next section. But now with SnapStart on AWS and GraalVM Native Image Support we have two more options how to optimize those cold starts.

So let's explore how to write Lambda function using the Spring Boot. The code of this sample application (the same as for the first 3 parts but rewritten to use Spring Boot) can be found here. It provides AWS API Gateway and 2 Lambda functions: "CreateProduct" and "GetProductById". The products are stored in the Amazon DynamoDB. We'll use AWS Serverless Application Model (AWS SAM) for the infrastructure as a code.

Let's explore how the code looks like. In the AWS SAM Template (template.yaml) we point all defined AWS Lambda functions to the same implementation:

Globals:
  Function:
    Handler: software.amazonaws.example.product.handler.StreamLambdaHandler::handleRequest
Enter fullscreen mode Exit fullscreen mode

provided by us. This StreamLambdaHandler implements com.amazonaws.services.lambda.runtime.RequestStreamHandler interface. After this we instatiate SpringBootLambdaContainerHandler handler like this

private static SpringBootLambdaContainerHandler<AwsProxyRequest, AwsProxyResponse> handler;
    static {
        try {
            handler = SpringBootLambdaContainerHandler.getAwsProxyHandler(Application.class);
            // For applications that take longer than 10 seconds to start, use the async builder:
            // handler = new SpringBootProxyHandlerBuilder<AwsProxyRequest>()
            //                    .defaultProxy()
            //                    .asyncInit()
            //                    .springBootApplication(Application.class)
            //                    .buildAndInitialize();
        } catch (ContainerInitializationException e) {
            // if we fail here. We re-throw the exception to force another cold start
            e.printStackTrace();
            throw new RuntimeException("Could not initialize Spring Boot application", e);
        }
    }

Enter fullscreen mode Exit fullscreen mode

For this we use aws-serverless-java-container-springboot2 project dependency provided in pom.xml. aws-serverless-java-container-springboot2 project is maintained by AWS with the goal to proxy the AWS API Gateway requests between the generic Lambda handler and the appropriate method of the Spring Boot Controller.

SpringBootLambdaContainerHandler uses SpringBootInitializer class Application as kind of entry point of the Spring Boot application

@Import({ProductController.class })
@SpringBootApplication(exclude={DataSourceAutoConfiguration.class})
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
Enter fullscreen mode Exit fullscreen mode

Please notice, we uses @Import instead of @ComponentScan Spring Boot annotation to further reduce the cold start as we mainly use only one Controller class in our application. In other cases with multiple controllers it may make more sense to let Spring Boot discover them all with @ComponentScan.

ProductController implementation is the Spring Boot implementation of our Rest Controller

@RestController
@EnableWebMvc
public class ProductController {

    @Autowired
    private DynamoProductDao productDao;

    @RequestMapping(path = "/products/{id}", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
    public Optional<Product> getProductById(@PathVariable("id") String id) {
        ....
    }

    @RequestMapping(path = "/products/{id}", method = RequestMethod.PUT, consumes = MediaType.APPLICATION_JSON_VALUE)
    public void createProduct(@PathVariable("id") String id, @RequestBody Product product) {
        ....    
    }
}
Enter fullscreen mode Exit fullscreen mode

where all requests will be routed to from the StreamLambdaHandler through SpringBootLambdaContainerHandler proxy.

Measuring the cold starts

Let's give the GetProductById Lambda Function 1024 MB of memory and first measure its the cold start without enabling the SnapStart on it. The CloudWatch Logs Insights Query for the /aws/lambda/GetProductByIdWithSpringBoot Log Group for it is

filter @type="REPORT" | fields greatest(@initDuration, 0) + @duration as duration, ispresent(@initDuration) as coldStart| stats count(*) as count,
pct(duration, 50) as p50,
pct(duration, 90) as p90,
pct(duration, 99) as p99,
max(duration) as max by coldStart
Enter fullscreen mode Exit fullscreen mode

Here are the results after experiencing 100 cold starts for the same Lambda version:

p50 6184.13
p90 6249.58
p99 6563.64

If we compare these metrics with AWS Lambda with plain Java (and AWS SDK for Java version 2) with Micronaut and Quarkus we'll notice that the cold starts using the Spring Boot Framework are by far the highest (because of reflection and other factors mentioned above).

Let's enable SnapStart GetProductById Lambda Function like this

Image description or directly in the SAM template.

It's also important to define AutoPublishAlias on this function in the SAM template

  GetProductByIdFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: GetProductByIdWithSpringBoot
      AutoPublishAlias: liveVersion
...
Enter fullscreen mode Exit fullscreen mode

as we can use SnapStart only on published function versions and aliases that point to versions. In this case alias always points to the latest version by default. The CloudWatch Logs Insights Query for the /aws/lambda/GetProductByIdWithSpingBoot Log Group for it is

filter @type = "REPORT"
  | parse @message /Restore Duration: (?<restoreDuration>.*?) ms/
  | stats
count(*) as invocations,
pct(@duration+coalesce(@initDuration,0)+coalesce(restoreDuration,0), 50) as p50,
pct(@duration+coalesce(@initDuration,0)+coalesce(restoreDuration,0), 90) as p90,
pct(@duration+coalesce(@initDuration,0)+coalesce(restoreDuration,0), 99) as p99
group by function, (ispresent(@initDuration) or ispresent(restoreDuration)) as coldstart
  | sort by coldstart desc
Enter fullscreen mode Exit fullscreen mode

Here are the results after experiencing 100 cold starts for the same Lambda version:

p50 1222.52
p90 1877.08
p99 1879.78

If we compare these metrics with AWS Lambda with plain Java (and AWS SDK for Java version 2), Micronaut and Quarkus Framework with SnapStart enabled, we'll notice that cold starts using Spring Boot were quite comparable with other solutions at p50, but the highest at p90 and p99.

If we build GraalVM Native Image of our Serverless application and deploy it as AWS Custom Runtime (which is beyond the scope if this article), we can further reduce the cold start to between 600 and 750ms, see the measurements for the comparable application.

Conclusions and next steps

In this blog post we looked into the Spring Boot Framework and explored how to write AWS Lambda function using it. We also measured the cold start with and without enabling SnapStart for our scenario: Lambda receives the event from the Amazon API Gateway and reads the item from the Amazon DynamoDB. Without SnapStart enabled Spring Boot showed the highest cold starts comparing to the plain AWS SDK Version 2 Lambda function and Micronaut and Quarkus Frameworks which is quite logical. With SnapStart enabled the cold start with SpringBoot were quite comparable with other solutions at p50, but the highest at p90 and p99. The well known programming model of the Spring Boot is nevertheless a huge productivity booster.

In the next part of series we'll discuss further topics like comparing end to end AWS API Gateway request latency with and without SnapStart enabled, explore how much additional time the Lambda deployment with SnapStart enabled takes and look at other optimization techniques like Priming to further reduce the cold starts.

Update: you can significantly reduce the cold start times of the Lambda function with SnapStart enabled further by applying the optimization technique called priming. Learn more about it in my article.

Top comments (0)