Micronaut 4 application on AWS Lambda- Part 6 REST API application
Introduction
In the part 1 we introduced our sample application. We basically used AWS Lambda Functions like GetProductByIdHandler where we extended io.micronaut.function.aws.MicronautRequestHandler and injected DynamoProductDao and other services by using Jakarta EE jakarta.inject.Inject annotation. While this is a valid approach, sometimes you have existing Micronaut REST application which runs on containers or servers, and you’d like to port it to Serverless with as little effort as possible. As we use here DynamoDB we’re locked-in, so it’s more about making our application portable between AWS services like EC2, ECS (also with Fargate), EKS (also with Fargate) and Lambda. Of course, you can replace DynamoDB repository layer with RDS, Aurora, Aurora Serverless or newly released Aurora DSQL which will make business logic at least more portable to other cloud provider or datacenter. In this article, we’ll show how to develop this type of application which architecture remains the same as described in the part 1.
Sample REST API application with the Micronaut framework on AWS Lambda
Now let’s look at relevant source code fragments of the sample application.
First, let’s take a look at the ProductCotroller.
@Controller
public class ProductController {
@Inject
private ProductDao productDao;
private static final Logger logger = LoggerFactory.getLogger(ProductController.class);
@Put("/products")
public void createProduct(@Body Product product) {
productDao.putProduct(product);
}
@Get("/products/{id}")
public Optional<Product> getProductById(@PathVariable String id) {
Optional<Product> optionalProduct = productDao.getProduct(id);
if (optionalProduct.isPresent())
logger.info(" product found : " + optionalProduct.get());
else
logger.info(" product with id " + id + " not found ");
return optionalProduct;
}
}
We use io.micronaut.http.annotation.Controller annotation to annotate this class as controller. We inject the ProductDao (which injects its only implementation DynamoProductDao by using Jakarta EE jakarta.inject.Inject annotation. We have 2 methods in this controller: createProduct to create the product and getProductById to retrieve the products by its ID. They both use DynamoProductDao to talk to the Amazon DynamoDB. Method getProductById is annotated with the io.micronaut.http.annotation.Get(“/products/{id}”) annotation which means that all HTTP Get requests to the product/{id} URL will be mapped to this method of the controller. Method createProduct is annotated with the io.micronaut.http.annotation.PUT(“/products/) annotation which means that all HTTP Put requests to the product/ URL will be mapped to this method of the controller.
We also see other Micronaut standard annotations like
io.micronaut.http.annotation.Body and
io.micronaut.http.annotation.PathVariable are used in this controller.
You can also use traditional Spring annotations which are mapped to Micronaut annotations at compilation time. Please 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) {
}
@Serdeable annotations come from the 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.
For this we need to add 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>
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();
}
}
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 standard Micronaut Rest application. We can see how everything wires together in the 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 artifacts, we see the following dependency,
<dependency>
<groupId>io.micronaut.aws</groupId>
<artifactId>micronaut-function-aws-api-proxy</artifactId>
</dependency>
which in turn also brings the micronaut-function-aws dependency. The latter one provides ways to create Micronaut AWS Lambda functions.
Now let’s look at the last missing part, namely IaC with AWS SAM, which is defined in the 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:
GetProductByIdFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: GetProductByIdWithWithMicronaut49Rest
AutoPublishAlias: liveVersion
Policies:
- DynamoDBReadPolicy:
TableName: !Ref ProductsTable
Events:
GetRequestById:
Type: Api
Properties:
RestApiId: !Ref MyApi
Path: /products/{id}
Method: get
But how is the correct Micronaut controller’s method resolved? We see that this Lambda function is connected to the HTTP GET method and the path /products/{id} with the API Gateway (see the Events section above). Micronaut framework scans all classes annotated with @Controller annotation and looks for methods annotated with @ Get(“/products/{id}”) annotation. That’s why this Lambda function will be resolved to the getProductById method of the ProductController. It works similar for the PutProductFunction Lambda function.
The resolution itself is performed by the generic io.micronaut.function.aws.proxy.payload1.ApiGatewayProxyRequestEventFunction Lambda handler, which is defined in template.yaml in the Globals section of the Lambda functions 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:
PRODUCT_TABLE_NAME: !Ref ProductsTable
Micronaut-function-aws-api-proxy module supports both Payload format version v1 and v2. Use the handler ApiGatewayProxyRequestEventFunction for the payload format with version 1.0 (used with Amazon API Gateway REST API) and APIGatewayV2HTTPEventFunction the payload format with version for 2.0 (used with Amazon API Gateway HTTP API).
Other parameters are also defined there that are valid for all defined Lambda functions, such as Java runtime environment Java 21 (I hope Java version 25 will be supported soon) and CodeURI. We have also set DynamoDB table name as environment variable, which is used in the DynamoProductDao class.
Now we have to build the application with mvn clean package and deploy it with sam deploy -g. We will see our customized Amazon API Gateway URL in the output. 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: a6ZbcDefQW12BN56WEM49Rest”, 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: a6ZbcDefQW12BN56WEM49Rest" 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: a6ZbcDefQW12BN56WEM49Rest" 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: a6ZbcDefQW12BN56WEM49Rest” 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 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"
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 :
SnapStart:
ApplyOn: PublishedVersions
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 |
---|---|---|---|---|---|---|---|---|---|---|---|
5419 | 5540 | 5708 | 6315 | 6449 | 6453 | 8.00 | 8.93 | 13.09 | 28.94 | 65.02 | 1816 |
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 |
---|---|---|---|---|---|---|---|---|---|---|---|
5496 | 5646 | 5894 | 6239 | 6378 | 6383 | 8.00 | 9.68 | 13.51 | 29.40 | 68.19 | 1817 |
Conclusion
In this part of the series, we learned how to develop a pure Micronaut REST application and deploy it on AWS Lambda. We also measured cold and warm start times of the Lambda function without applying optimization approaches like AWS Lambda SnapStart first.
If I’ll find time in the future, I’ll Lambda function performance measurements similarly as I did in the previous articles of this series:
- with enabling SnapStart but without any priming (see part 2 for further explanations).
- with enabling SnapStart and with additional priming techniques (see parts 3 and 4 for further explanations).
- by deploying our application as a GraalVM Native Image on the Lambda Custom Runtime (see part 5 for further explanations).