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 part 1 of this 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 the popular Micronaut Framework.

Micronaut Framework and its features

Micronaut Framework is a 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) integration, supports Maven and 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 Code.

Let's look at 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 the Lambda Event definition and matching the Controller Java implementation (which is how we implement the 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 the SAM template matches the Micronaut @get("/products/{id}") in the Controller. Micronaut provides its own set of annotations like @Controller, @get, @Put, @Delete, and @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 on 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 that run in containers. So, how can we let Micronaut know that we'll run our application in AWS as a set of Lambda functions?

The whole beauty and magic occur in pom.xml where we define a 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 an AWS Lambda function that receives 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 cold start without enabling the SnapStart on it. The CloudWatch Logs Insights Query for the /aws/lambda/GetProductByIdWithMicronaut Log Group 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 the SnapStart GetProductById Lambda Function like this:


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 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 compile our application with GraalVM Native Image and deploy our Lambda as a Custom Runtime (which is beyond the scope of 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 an AWS Lambda function that receives the event from AWS API Gateway. We also measured the cold start with and without enabling SnapStart. Especially in the case of enabled SnapStart, the cold starts were only slightly higher compared to the Lambda written with plain Java. As Micronaut Framework increases the productivity of the developers by providing lots of features (see the list above), it's worth considering using it. In the next part of the series, we'll explore Quarkus for writing Lambda functions and measure the cold start with and without enabling the SnapStart.

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)