Keeping your .NET Lambda Function Warm with Provisioned Concurrency
Want to learn more about AWS Lambda and .NET? Check out my A Cloud Guru course on ASP.NET Web API and Lambda.
Download full source code.
Introduction
Some .NET developers who use or want to use AWS Lambda functions worry about cold starts.
Cold starts occur when there is no available execution environment to handle the incoming request. Cold starts take a bit of time because the execution environment needs to be created, the code to run needs to be downloaded, the memory allocated, runtime prepared, and any initialization steps in your code (such as the constructor) performed.
When a Lambda function is not invoked for a period of time, the execution environment is removed, the also results in a cold start.
Two of the ways cold starts can affect you include:
- You have a Lambda function that is called infrequently, but when it is called, it needs to respond quickly.
- Your Lambda function needs to respond to bursty traffic. In that case, you may have warm execution environments available, but not enough of them to handle the burst.
This post will show you how to use provisioned concurrency to keep an infrequently called Lambda function warm, and ready to respond quickly. The price for provisioned concurrency may vary depending on the resources allocated to your Lambda function. In the example here, the price is $2.79 a month to keep a single Lambda execution environment warm. Price information is available in the AWS Console UI.
0. Get the tools
Install the latest tooling, this lets you deploy and run Lambda functions.
dotnet tool install -g Amazon.Lambda.Tools
Install the latest .NET project templates.
dotnet new --install Amazon.Lambda.Templates
Get the latest version of the AWS CLI, from here.
1. Create the Lambda Function
From the command line run -
dotnet new lambda.EmptyFunction --name LambdaFunctionProvisionedConcurrency
2. Edit the function code
Navigate to LambdaFunctionProvisionedConcurrency/src/LambdaFunctionProvisionedConcurrency
and open the Function.cs
file.
Replace its contents with the following -
using Amazon.Lambda.Core;
// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class.
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
namespace LambdaFunctionProvisionedConcurrency;
public class Function
{
private readonly string _functionId;
private readonly string _functionCreationTime;
public Function()
{
Console.WriteLine("Starting slow constructor");
Task.Delay(5000).GetAwaiter().GetResult();
_functionId = Guid.NewGuid().ToString();
_functionCreationTime = DateTime.Now.ToString("HH:mm:ss.fff");
Console.WriteLine("Finishing slow constructor");
}
public Response FunctionHandler(ILambdaContext context)
{
Response response = new Response()
{
FunctionId = _functionId,
FunctionCreationTime = _functionCreationTime,
FunctionInvocationTime = DateTime.Now.ToString("HH:mm:ss.fff"),
ARN = context.InvokedFunctionArn
};
return response;
}
}
public class Response
{
public string FunctionId { get; set; }
public string FunctionCreationTime { get; set; }
public string FunctionInvocationTime { get; set; }
public string ARN { get; set; }
}
Note the constructor has a deliberate 5 second delay. This is to simulate a constructor that does a lot during the initialization phase of your function.
3. Deploy the function
Run the following -
dotnet lambda deploy-function LambdaFunctionProvisionedConcurrency
You will be asked to select an IAM role, or create a new one, at the bottom of the list will be *** Create new IAM Role ***
, type in the associated number.
You will be asked for a role name, enter LambdaFunctionProvisionedConcurrencyRole
.
After this you will be prompted to select the IAM Policy to attach to the role, choose AWSLambdaBasicExecutionRole
, it is number 6 on my list.
After a few seconds, the function will be deployed.
4. Invoke the function
Run the following -
dotnet lambda invoke-function LambdaFunctionProvisionedConcurrency
You will see output like -
Payload:
{"FunctionId":"d91e2762-132d-45b7-afae-63cfcc5a8ce0","FunctionCreationTime":"00:37:09.355","FunctionInvocationTime":"00:37:09.527","ARN":"arn:aws:lambda:us-east-1:xxxxxxxxxxxxxx
:function:LambdaFunctionProvisionedConcurrency"}
Log Tail:
2022-11-04T00:37:04.338Z info Starting slow constructor
2022-11-04T00:37:09.356Z info Finishing slow constructor
START RequestId: 349dd763-645d-4914-b064-f4d52f25661d Version: $LATEST
END RequestId: 349dd763-645d-4914-b064-f4d52f25661d
REPORT RequestId: 349dd763-645d-4914-b064-f4d52f25661d Duration: 254.39 ms Billed Duration: 255 ms Memory Size: 256 MB Max Memory Used: 61 MB Init Duration: 5223.20
ms
The log tail shows the 5 second delay between the start and finish of the constructor.
The payload shows the FunctionInvocation
time, 5 seconds after the constructor started.
The report at the bottom of the output includes an Init Duration
of 5223.20 ms. This is included when the function is cold started.
Run the function again, if you do it while the execution environment is still active, you will see something like -
Payload:
{"FunctionId":"d91e2762-132d-45b7-afae-63cfcc5a8ce0","FunctionCreationTime":"00:37:09.355","FunctionInvocationTime":"00:42:04.137","ARN":"arn:aws:lambda:us-east-1:xxxxxxxxxxxxxx
:function:LambdaFunctionProvisionedConcurrency"}
Log Tail:
START RequestId: 8ffdd14a-502d-4ca4-abfe-6e771c40270f Version: $LATEST
END RequestId: 8ffdd14a-502d-4ca4-abfe-6e771c40270f
REPORT RequestId: 8ffdd14a-502d-4ca4-abfe-6e771c40270f Duration: 1.10 ms Billed Duration: 2 ms Memory Size: 256 MB Max Memory Used: 62 MB
The FunctionCreationTime
is the same as the previous invocation, the constructor was not called again. You are using the same execution environment.
There is no Init Duration
in the report because the function was not cold started.
If you wait long enough before the next invocation, the execution environment will be destroyed. Then, when you run the function again, you will see similar output as the first invocation. And that is the problem with cold starts, when the execution environment is destroyed, the next invocation will be slow.
Provisioned concurrency to the rescue.
5. Enable provisioned concurrency
There are a couple of steps to enable provisioned concurrency. You have to create a version or alias for the function. I will use a version in this example, then set the provisioned concurrency count for that version.
5.1 Create a version
From the command line run -
aws lambda publish-version --function-name LambdaFunctionProvisionedConcurrency --description "Provisioned"
You will see output like -
{
"FunctionName": "LambdaFunctionProvisionedConcurrency",
"FunctionArn": "arn:aws:lambda:us-east-1:xxxxxxxxxxxxx:function:LambdaFunctionProvisionedConcurrency:1",
"Runtime": "dotnet6",
"Role": "arn:aws:iam::xxxxxxxxxxxxx:role/LambdaFunctionProvisionedConcurrencyRole",
....
Note the number at the end of the FunctionArn
, you will need it in the next step.
5.2 Setup provisioned concurrency on the function version
From the command line run -
aws lambda put-provisioned-concurrency-config --function-name LambdaFunctionProvisionedConcurrency --qualifier 1 --provisioned-concurrent-executions 1
This creates a provisioned concurrency configuration for the function version. The --qualifier
is the number at the end of the FunctionArn
from the previous step. The --provisioned-concurrent-executions
is the number of concurrent executions you want to provision.
You will see output like -
{
"RequestedProvisionedConcurrentExecutions": 1,
"AvailableProvisionedConcurrentExecutions": 0,
"AllocatedProvisionedConcurrentExecutions": 0,
"Status": "IN_PROGRESS",
"LastModified": "2022-11-04T01:07:03+0000"
}
To check if the provisioned concurrency is ready, run the following -
aws lambda get-provisioned-concurrency-config --function-name LambdaFunctionProvisionedConcurrency --qualifier 1
It will say Status: READY
when complete.
6. Invoke the function with provisioned concurrency
To invoke the function with provisioned concurrency, you add the version number to the command -
dotnet lambda invoke-function LambdaFunctionProvisionedConcurrency:1
You will see output like -
Payload:
{"FunctionId":"a33759f9-d1ff-4489-b360-508bbaeb40fc","FunctionCreationTime":"13:48:03.771","FunctionInvocationTime":"13:54:06.617","ARN":"arn:aws:lambda:us-east-1:xxxxxxxxxxxx:function:LambdaFunctionProvisionedConcurrency:6"}
Log Tail:
2022-11-04T13:47:58.690Z info Starting slow constructor
2022-11-04T13:48:03.771Z info Ending slow constructor
START RequestId: 3f54d880-8925-4048-8c68-b219993aa77c Version: 6
END RequestId: 3f54d880-8925-4048-8c68-b219993aa77c
REPORT RequestId: 3f54d880-8925-4048-8c68-b219993aa77c Duration: 334.11 ms Billed Duration: 335 ms Memory Size: 256 MB Max Memory Used: 62 MB Init Duration: 6742.27 ms
There are a few things to note in the output, of both the payload and log tail.
- The
FunctionCreationTime
is a few minutes earlier than theFunctionInvocationTime
. This is because the execution environment was created and the function was initialized when provisioned concurrency became active. - In the log tail, you can see that the constructor was called, and it took 5 seconds to complete. The log entries show that the constructor ran a few minutes before the function was invoked, at the time provisioned concurrency became active.
- An
Init Duration
is included in the report at the bottom of the output. This is because the execution environment was created prior to this invocation. Subsequent invocations that use the same execution environment will not have anInit Duration
in the output. - The
Duration
field shows the time it took to execute the function, including the initialization time, but the initialization happened when the provisioned concurrency became active, not when the function was invoked.
If you invoke the function a few more times the FunctionCreationTime
, and FunctionId
will remain the same. The log won’t contain messages from the constructor, and the Init Duration
will not be included in the report. You will also see that the Duration
is much less than the first invocation, more like 1-20 ms.
However, after some period of time, the execution environment will be destroyed and recreated (this is normal). On the invocation, after the execution environment is recreated, you will see similar output as the invocation shown above - constructor messages in the log, and Init Duration
.
7. Conclusion
For the scenario outlined at the start where you need to keep an infrequently called function warm, using provisioned concurrency in the manner described is an easy, and cheap way to reduce cold start times for your Lambda function.
Download full source code.