Introduction
In the part 2 of the series we introduced AWS Serverless Java Container and in the part 3 we demonstrated how to write AWS Lambda with AWS Serverless Java Container using Java 21 and Spring Boot 3.2. In this article of the series, we'll measure the cold and warm start time including enabling SnapStart on the Lambda function but also applying various priming techniques like priming the DynamoDB invocation and priming the whole API Gateway request without going through the network. We'll use Spring Boot 3.2 sample application for our measurements, and for all Lambda functions use JAVA_TOOL_OPTIONS: "-XX:+TieredCompilation -XX:TieredStopAtLevel=1" and give them all 1024 MB memory.
Measuring cold starts and warm time with AWS Serverless Java Container and using Java 21 and Spring Boot 3.2
First of all I'd like to explore Lambda SnapStart (as the only way to have competitive cold start times) and various priming techniques. For the introduction to SnapStart I refer to my article Initial measuring of Java 11 Lambda cold starts . Enabling SnapStart on Lambda function is only a matter of configuration like :
SnapStart:
ApplyOn: PublishedVersions
applied in the Lambda function Properties or Globals Lambda function section of the SAM template, so I'd like to dive deeper to how to use various priming techniques on top of it for our use case. I explained the ideas behind various priming techniques in my article AWS Lambda SnapStart - Part 5 Measuring priming, end to end latency and deployment time. So please read it first.
1) The code for priming of DynamoDB request can be found here.
This class additionally implements import org.crac.Resource interface of the CraC project.
With this invocation
Core.getGlobalContext().register(this);
StreamLambdaHandlerWithDynamoDBRequestPriming class registers itself as CRaC resource.
We additionally prime the DynamoDB invocation by implementing beforeCheckpoint method from the CRaC API.
@Override
public void beforeCheckpoint(org.crac.Context<? extends Resource> context) throws Exception {
productDao.getProduct("0");
}
which we'll invoked during the deployment phase of the Lambda funtion and before Firecracker microVM snapshot will be taken.
2) The code for priming of the whole API Gateway request without going through the network can be found here.
This class also additionally implements import org.crac.Resource interface as in the example above. We'll re-use a bit ugly technique, which I described in my article AWS Lambda SnapStart - Part 6 Priming the request invocation for Java 11 and Micronaut, Quarkus and Spring Boot frameworks . I don't recommend using this technique in production, but it demonstrates the further potential to reduce the cold start using priming of the whole API Gateway request by pre-loading the mapping between Sping Boot Web annotation model and Lambda model performing also DynamoDB invocation priming.
What will be doing in the beforeCheckpoint method is to
construct and proxy the whole /products/{id} invocation by invoking directly Lambda function with payload (as JSON) which looks identical to the APIGateway proxy request event but without going over the network. API Gateway request construction of the /products/{id} with id equals to 0 API Gateway request using the com.amazonaws.serverless.proxy.model.AwsProxyRequest abstraction looks like this:
private static AwsProxyRequest getAwsProxyRequest () {
final AwsProxyRequest awsProxyRequest = new AwsProxyRequest ();
awsProxyRequest.setHttpMethod("GET");
awsProxyRequest.setPath("/products/0");
awsProxyRequest.setResource("/products/{id}");
awsProxyRequest.setPathParameters(Map.of("id","0"));
final AwsProxyRequestContext awsProxyRequestContext = new AwsProxyRequestContext();
final ApiGatewayRequestIdentity apiGatewayRequestIdentity= new ApiGatewayRequestIdentity();
apiGatewayRequestIdentity.setApiKey("blabla");
awsProxyRequestContext.setIdentity(apiGatewayRequestIdentity);
awsProxyRequest.setRequestContext(awsProxyRequestContext);
return awsProxyRequest;
}
@Override
public void beforeCheckpoint(org.crac.Context<? extends Resource> context) throws Exception {
handler.proxy(getAwsProxyRequest(), new MockLambdaContext());
}
With that the following method
@RequestMapping(path = "/products/{id}", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
public Optional<Product> getProductById(@PathVariable("id") String id) {
return productDao.getProduct(id);
}
of the ProductController with id equals to 0 will be invoked during priming which also sebsequently primes DynamoDB invocation.
The results of the experiment below were based on reproducing more than 100 cold and approximately 100.000 warm starts with Lambda function with 1024 MB memory setting for the duration of 1 hour. For it I used the load test tool hey, but you can use whatever tool you want, like Serverless-artillery or Postman.
I ran all these experiments on our GetProductByIdWithSpringBoot32 Lambda function with 4 different scenarios :
1) No SnapStart enabled
in template.yaml use the following configuration:
Handler: software.amazonaws.example.product.handler.StreamLambdaHandler::handleRequest
#SnapStart:
#ApplyOn: PublishedVersions
2) SnapStart enabled but no priming applied
in template.yaml use the following configuration:
Handler: software.amazonaws.example.product.handler.StreamLambdaHandler::handleRequest
SnapStart:
ApplyOn: PublishedVersions
3) SnapStart enabled with DynamoDB invocation priming
in template.yaml use the following configuration:
Handler: software.amazonaws.example.product.handler.StreamLambdaHandlerWithDynamoDBRequestPriming::handleRequest
SnapStart:
ApplyOn: PublishedVersions
4) SnapStart enabled with API Gateway request invocation priming
in template.yaml use the following configuration:
Handler: software.amazonaws.example.product.handler.StreamLambdaHandlerWithWebRequestPriming::handleRequest
SnapStart:
ApplyOn: PublishedVersions
So let's provide the results of the measurement. Abbreviation c stays for the cold start and w is for the warm start.
Cold (c) and warm (w) start time in ms:
Scenario Number | 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 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
No SnapStart enabled | 6358.97 | 6461.48 | 6664.82 | 7417.11 | 7424.53 | 7425.65 | 7.88 | 8.80 | 10.49 | 24.61 | 1312.02 | 1956.15 |
SnapStart enabled but no priming applied | 1949.28 | 2061.49 | 2522.70 | 2713.64 | 2995.89 | 2996.9 | 8.00 | 9.08 | 10.99 | 26.30 | 267.05 | 1726.98 |
SnapStart enabled with DynamoDB invocation priming | 952.96 | 1024.06 | 1403.01 | 1615.35 | 1644.67 | 1645.05 | 8.00 | 9.24 | 11.45 | 26.44 | 143.19 | 504.61 |
SnapStart enabled with API Gateway request invocation priming | 741.52 | 790.50 | 1168.49 | 1333.29 | 1384.90 | 1386.11 | 7.63 | 8.53 | 10.16 | 23.09 | 123.07 | 346.89 |
Conclusion
By enabling SnapStart on the Lambda function alone, it reduces the cold start time of the Lambda function significantly. By additionally using DynamoDB invocation priming and especially web request invocation priming (which I don't recommend using this technique in production though) we were be able to achieve cold starts only slightly higher than cold starts described in my article AWS SnapStart -Measuring cold and warm starts with Java 21 using different memory settings where we measured cold and warm starts for the pure Lambda function without the usage of any frameworks including 1024 MB memory setting like in our scenario.
In the next part of the series I'll make the introduction to the AWS Lambda Web Adapter and explain how our Serverless Spring Boot application on AWS can make use of it.
Top comments (0)