DEV Community

Cover image for AWS Lambda SnapStart - Part 2 Measuring Java 11 Lambda cold starts with Micronaut framework
Vadym Kazulkin for AWS Community Builders

Posted on • Edited on

AWS Lambda SnapStart - Part 2 Measuring Java 11 Lambda cold starts with Micronaut framework

Introduction

In the first 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 with and without SnapStart enabled. We saw that enabling SnapStart led to a huge decrease in the cold start times. In this part we'll do the same but using popular Micronaut Framework.

Micronaut Framework and its features

Micronaut Framework is modern, JVM-based, full-stack framework for building modular, easily testable microservice and serverless applications. It gives us the possibility to create our application using the launcher or CLI, provides customer validation, API Gateway integration, GraalVM (Native Image) integation, supports Maven und Gradle and much more. What is very important, is that the Micronaut processes annotations at compile time and doesn't use reflection, runtime byte code generation, runtime generated proxies and dynamic class loading. On the other hand Micronaut doesn't support MicroProfile.

Writing AWS Lambda with Micronaut

We'll use the same application as in the first part of this series, but we'll rewrite it to use Micronaut Framework. The code of this sample application can be found here. It basically 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 look how to implement it with Micronaut. In the AWS SAM Template (template.yaml) we point the AWS Lambda function handler to the generic MicronautLambdaHandler implementation:

Globals:
  Function:
    Handler: io.micronaut.function.aws.proxy.MicronautLambdaHandler
Enter fullscreen mode Exit fullscreen mode

The binding of the defined Lambda function and SAM to the concrete implementation in code happens through mapping through Lambda Event definition and matching Controller Java implementation (which is how we implement AWS Lambda function in Micronaut).

For example for the GetProductByIdFunction

  GetProductByIdFunction:
    Type: AWS::Serverless::Function
     .....
      Events:
        GetRequestById:
          Type: Api
          Properties:
            RestApiId: !Ref MyApi
            Path: /products/{id}
            Method: get    
Enter fullscreen mode Exit fullscreen mode

The matching Micronaut Controller implementation is

@Controller
public class GetProductByIdController {

  private final ProductDao productDao;

  public GetProductByIdController(ProductDao productDao) {
    this.productDao = productDao;
  }

  @Get("/products/{id}")
  public Optional<Product> getProductById(@PathVariable String id) {
    return productDao.getProduct(id);
  }

}
Enter fullscreen mode Exit fullscreen mode

as the API Gateway Method "get" and path /products/{id} defined in SAM template matches the Micronaut @Get("/products/{id}") in the Controller. Micronaut provides its own set of annotations like @Controller, @Get, @Put, @Delete, @PathVariable which help us write HTTP/REST based applications. Working with such annotations is very similar to Spring Web or Spring Boot annotations. It's worth noting that the Controller implementation itself doesn't contain any dependency to AWS SDK Lambda Runtime API classes (like RequestHandler and APIGatewayProxyRequestEvent), so it's also portable to other cloud providers (if we replace the DAO tier and DynamoDB implementation) or can be used to write microservices which run in containers. So how let we Micronaut know, that we'll run our application in AWS as set of Lambda functions?

The whole beauty and magic occurs in pom.xml where we defin bunch of Micronaut dependencies

    <dependency>
      <groupId>io.micronaut.aws</groupId>
      <artifactId>micronaut-function-aws-api-proxy</artifactId>
      <scope>compile</scope>
    </dependency>
...
Enter fullscreen mode Exit fullscreen mode

with the defined MicronautLambdaRuntime function handler in the AWS SAM Template

  Globals:
  Function:
    Handler: io.micronaut.function.aws.proxy.MicronautLambdaHandler 
Enter fullscreen mode Exit fullscreen mode

which wires everything together producing AWS Lambda function that recieves the event from the AWS API Gateway.

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/GetProductByIdWithMicronaut 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 5401.69
p90 5747.15
p99 5786.1

If we compare these metrics with AWS Lambda with plain Java (and AWS SDK for Java version 2) we'll notice that using the Micronaut Framework the average cold start increased from 4,5 to 5.4 seconds.

Now let's enable SnapStart GetProductById Lambda Function like this

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

  GetProductByIdFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: GetProductByIdWithMicronaut
      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/GetProductByIdWithMicronaut 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 1468.18
p90 1595.61
p99 1641.23

If we compare these results with AWS Lambda with plain Java (and AWS SDK for Java version 2) with SnapStart enabled we'll notice that using the Micronaut Framework the average cold start only slightly increased from 1,27 to 1.47 seconds. If we'll compile our application with GraalVM Native Image and deploy our Lambda as Custom Runtime (which is beyond the scope if this article), we can further reduce the cold start to between 600 and 700ms.

Conclusions and next steps

In this blog post we looked into the Micronaut Framework and learned how to write AWS Lambda function which receives the event from AWS API Gateway. We also measured the cold start with and without enabling SnapStart. Especially in case of enabled SnapStart the cold starts were only slightly higher compared to the Lambda written with the plain Java. As Micronaut Framework increases the productivity of the developers by providing lots of features (see the list above), it's worth considering to use it. In the next part of series we'll explore Quarkus for writing Lambda functions and measure the cold start with and without enabling of the SnapSart.

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)