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.

Pricing information in the AWS Console
Pricing information in the AWS Console

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.

  1. The FunctionCreationTime is a few minutes earlier than the FunctionInvocationTime. This is because the execution environment was created and the function was initialized when provisioned concurrency became active.
  2. 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.
  3. 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 an Init Duration in the output.
  4. 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.

comments powered by Disqus

Related