DEV Community

Vadym Kazulkin for AWS Heroes

Posted on

Micronaut 4 application on AWS Lambda- Part 1 Introduction to the sample application and first Lambda performance measurements

What will we explore and learn in this article series?

In this article series, we will explore some ways to develop, deploy and run applications on AWS Lambda using the Micronaut framework. Of course, we will measure performance (the cold and warm start times) of the Lambda function. We will also show how we can optimize performance of Lambda functions using Lambda SnapStart (including various priming techniques) and GraalVM Native Image deployed as AWS Lambda Custom Runtime. You can find code examples for the whole series in my GitHub Account.

Sample application with the Micronaut framework on AWS Lambda

To explain this, we will use our sample application, the architecture of which is shown below.

In this application, we will create products and retrieve them by their ID and use Amazon DynamoDB as a NoSQL database for the persistence layer. We use Amazon API Gateway which makes it easy for developers to create, publish, maintain, monitor and secure APIs and AWS Lambda to execute code without the need to provision or manage servers. We also use AWS SAM, which provides a short syntax optimised for defining infrastructure as code (hereafter IaC) for serverless applications. For this article, I assume a basic understanding of the mentioned AWS services, serverless architectures in AWS, Micronaut framework and GraalVM including its Native Image capabilities.

In order to build and deploy the sample application, we need the following local installations: Java 21, Maven, AWS CLI and SAM CLI. For the GraalVM example, we also need GraalVM and Native Image. For the GraalVM example, additionally GraalVM and Native Image. I used GraalVM 23 for my measurements, but you can use the newest version.

Now let's look at relevant source code fragments and start with the sample application that we will run directly on the managed Java 21 runtime of AWS Lambda. AWS Lambda only supports managed Java LTS versions, so version 21 is currently the latest. This sample application uses standard AWS Lambda functions and Micronaut annotations to inject other services. We'll look at another option later which re-uses Micronaut Controller and REST API, which makes the solution more portable at least between Serverless, Containers and pure servers  at least on AWS as we're locked-in by using Amazon DynamoDB.

First, let's take a look as an example at the source code of the GetProductByIdHandler Lambda function. This Lambda function куекшумуы the product based on its ID and returns it.

public class GetProductByIdHandler extends
        MicronautRequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent>  {

    @Inject
    private ProductDao productDao;
    @Inject
    private JsonMapper objectMapper;

    private static final Logger logger = LoggerFactory.getLogger(GetProductByIdHandler.class);

    @Override
    public APIGatewayProxyResponseEvent execute(APIGatewayProxyRequestEvent requestEvent) {
        String id = requestEvent.getPathParameters().get("id");
        Optional<Product> optionalProduct = productDao.getProduct(id);
        try {
            if (optionalProduct.isEmpty()) {
                logger.info(" product with id " + id + " not found ");
                return new APIGatewayProxyResponseEvent().withStatusCode(HttpStatusCode.NOT_FOUND)
                        .withBody("Product with id = " + id + " not found");
            }
            logger.info(" product " + optionalProduct.get() + " found ");
            return new APIGatewayProxyResponseEvent().withStatusCode(HttpStatusCode.OK)
                    .withBody(objectMapper.writeValueAsString(optionalProduct.get()));
        } catch (Exception je) {
            je.printStackTrace();
            return new APIGatewayProxyResponseEvent().withStatusCode(HttpStatusCode.INTERNAL_SERVER_ERROR)
                    .withBody("Internal Server Error :: " + je.getMessage());
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

This class extends io.micronaut.function.aws.MicronautRequestHandler. We inject the ProductDao (which injects its only implementation DynamoProductDao) and io.micronaut.json.JsonMapper by using Jakarta EE jakarta.inject.Inject annotation. We implement execute method which uses DynamoDBDao to talk to the DynamoDB to retrieve the product by its ID.

You can also use traditional Spring annotations which are mapped to Micronaut annotations at compilation time. Please take a look at the following sources: Run a Spring Boot application as a Micronaut application and Micronaut for Spring.

The source code of the Product entity looks very simple:

@Introspected
@Serdeable.Deserializable
@Serdeable.Serializable
public record Product(String id, String name, BigDecimal price) {
}
Enter fullscreen mode Exit fullscreen mode

We use Micronaut Serialization which enables serialization/deserialization in Micronaut applications using build time information. It also consumes less memory and has a much smaller runtime component. @Serdeable annotations come from the Micronaut Serialization.

For this we need to add compile-time serde dependencies in the pom.xml :

<dependency>
    <groupId>io.micronaut.serde</groupId>
    <artifactId>micronaut-serde-jackson</artifactId>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>io.micronaut.aws</groupId>
    <artifactId>micronaut-aws-lambda-events-serde</artifactId>
    <scope>compile</scope>
</dependency>
Enter fullscreen mode Exit fullscreen mode

The implementation of the DynamoProductDao persistence layer uses AWS SDK for Java 2.0 to write to or read from the DynamoDB. Here is an example of the source code of the getProductById method, which we used in the ProductController described above:

  public Optional<Product> getProduct(String id) {
    GetItemResponse getItemResponse= dynamoDbClient.getItem(GetItemRequest.builder()
      .key(Map.of("PK", AttributeValue.builder().s(id).build()))
      .tableName(PRODUCT_TABLE_NAME)
      .build());
    if (getItemResponse.hasItem()) {
      return Optional.of(ProductMapper.productFromDynamoDB(getItemResponse.item()));
    } else {
      return Optional.empty();
    }
  }
Enter fullscreen mode Exit fullscreen mode

Here we use the instance of DynamoDbClient Client to build GetItemRequest to query DynamoDB table, whose name we get from environment variable (which we will set in AWS SAM template) by invoking System.getenv("PRODUCT_TABLE_NAME"), for the product based on its ID. If the product is found, we use the custom written ProductMapper to map the DynamoDB item to the attributes of the product entity.

We have not yet seen any dependencies on the AWS SDK so far as our application looks like a standard Micronaut REST application. We can see how everything wires together in pom.xml. Apart from dependencies to the Micronaut framework (we are using version 4.9.0, but you are welcome to upgrade to the newer version and most of it should work the same), AWS SDK for Java and other AWS artefacts, we see the following dependency,

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

which in turn also brings the [micronaut-function-aws]https://micronaut-projects.github.io/micronaut-aws/latest/guide/#lambda) dependency.  Micronaut-function-aws-api-proxy module supports both Payload format version v1 and v2. For functions of type Application, use the handler ApiGatewayProxyRequestEventFunction (this is what we use in our application) for Payload format version 1.0 and APIGatewayV2HTTPEventFunction for 2.0.

Now let's look at the last missing part, namely IaC with AWS SAM, which is defined in template.yaml. There we declare Amazon API Gateway (incl. UsagePlan and API Key), AWS Lambdas and DynamoDB table. We first look at the definition of the lambda function GetProductByIdFunction (CreateProductFunction looks similar):

  GetProductByIdFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: GetProductByIdWithWithMicronaut49
      Handler: software.amazonaws.example.product.handler.GetProductByIdHandler::execute
      AutoPublishAlias: liveVersion
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref ProductsTable
      Events:
        GetRequestById:
          Type: Api
          Properties:
            RestApiId: !Ref MyApi
            Path: /products/{id}
            Method: get 
Enter fullscreen mode Exit fullscreen mode

We see that the Lambda function Handler strictly points to the execute method of the software.amazonaws.example.product.handler.GetProductByIdHandler.

Now we have to build the application with mvn clean package  and deploy it with sam deploy -g. We will see our customised Amazon API Gateway URL in the return. We can use it to create products and retrieve them by ID. The interface is secured with the API key. We have to send the following as HTTP header: "X-API-Key: a6ZbcDefQW12BN56WEM49", see MyApiKey definition in template.yaml. To create the product with ID=1, we can use the following curl query:

curl -m PUT -d '{ "id": 1, "name": "Print 10x13", "price": 0.15 }' -H "X-API-Key: a6ZbcDefQW12BN56WEM49" https://{$API_GATEWAY_URL}/prod/products

For example, to query the existing product with ID=1, we can use the following curl query:

curl -H "X-API-Key: a6ZbcDefQW12BN56WEM49" https://{$API_GATEWAY_URL}/prod/products/1

In both cases, we need to replace the {$API_GATEWAY_URL} with the individual Amazon API Gateway URL that is returned by the sam deploy -g command. We can also search for this URL when navigating to our API in the Amazon API Gateway service in the AWS console.

Measurements of cold and warm start times of our application

In the following, we will measure the performance of our GetProductByIdFunction Lambda function, which we will trigger by invoking curl -H "X-API-Key: a6ZbcDefQW12BN56WEM49" https://{$API_GATEWAY_URL}/prod/products/1. Two aspects are important to us in terms of performance: cold and warm start times. It is known that Java applications in particular have a very high cold start time. The article Understanding the Lambda execution environment lifecycle provides a good overview of this topic.

The results of the experiment are based on reproducing more than 100 cold starts and about 100,000 warm starts with the Lambda function GetProductByIdFunction (we ask for the already existing product with ID=1 ) for the duration of about 1 hour. We give Lambda function 1024 MB memory, which is a good trade-off between performance and cost. We also use (default) x86 Lambda architecture. For the load tests I used the load test tool hey, but you can use whatever tool you want, like Serverless-artillery or Postman.

We will measure with tiered compilation (which is default in Java 21, we don't need to set anything separately) and compilation option XX:+TieredCompilation -XX:TieredStopAtLevel=1. To use the last option, you have to set it in template.yaml in JAVA_OPTIONS environment variable as follows:

Globals:
  Function:
    CodeUri: target/aws-lambda-micronaut-4.9-1.0.0-SNAPSHOT.jar
    Handler: io.micronaut.function.aws.proxy.payload1.ApiGatewayProxyRequestEventFunction
    Runtime: java21
    ...
    Environment:
      Variables:
        JAVA_TOOL_OPTIONS: "-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
Enter fullscreen mode Exit fullscreen mode

Also we'd like to measure the Lambda performance without SnapStart being activated for the Lambda function first. So make sure that two corresponding lines are commented out as stated above.

Cold (c) and warm (w) start time with tiered compilation in ms:

c p50 c p75 c p90 c p99 c p99.9 c max w p50 w p75 w p90 w p99 w p99.9 w max
4948 5038 5155 5387 5403 5404 5.37 6.01 7.10 16.01 52.05 1535

Cold (c) and warm (w) start time with -XX:+TieredCompilation -XX:TieredStopAtLevel=1 compilation in ms:

c p50 c p75 c p90 c p99 c p99.9 c max w p50 w p75 w p90 w p99 w p99.9 w max
4993 5145 5392 5697 5852 5856 5.33 5.91 6.88 15.50 52.47 1616

Conclusion

In the first part of our series about how to develop, run and optimize Micronaut web applications on AWS Lambda, we demonstrated how to write a sample application which uses the Micronaut framework, AWS Lambda, Amazon API Gateway and Amazon DynamoDB. We also made the first Lambda performance (cold and warm start time) measurements and observed quite a big cold start time. In the next parts of the series we'll introduce approaches and techniques to reduce the Lambda cold start time with AWS Lambda SnapStart (including various priming techniques) and GraalVM Native Image and also measure their impact on the Lambda warm start time. Stay tuned!

Top comments (0)