はじめに
AWS Lambdaのコールドスタート対策として、Provisioned Concurrencyという機能があります。Provisioned Concurrencyを利用する上で、そのメリットを最大限活用するために、どのような実装をすべきかについて、実際に動かして速度を比較して検証してみました。
事前準備
事前準備としてLambdaにサンプルプログラムをデプロイします。言語はC#で、実装としてはLambdaからDynamoDBを検索し、DynamoDBに登録されている内容を出力するだけのシンプルな内容となっています。実行結果はAWS X-rayに出力し、どの部分が遅いのかを確認できるようにしています。
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.DataModel;
using Amazon.Lambda.Core;
using Amazon.XRay.Recorder.Handlers.AwsSdk;
using Microsoft.Extensions.DependencyInjection;
// 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 TestColdStart;
public class Function
{
private readonly ITestColdStartRepository _testColdStartRepository;
public Function()
{
AWSSDKHandler.RegisterXRayForAllServices();
var serviceCollection = new ServiceCollection();
serviceCollection.AddAWSService<IAmazonDynamoDB>();
serviceCollection.AddTransient<IDynamoDBContext, DynamoDBContext>();
serviceCollection.AddTransient<ITestColdStartRepository, TestColdStartRepository>();
var serviceProvider = serviceCollection.BuildServiceProvider();
_testColdStartRepository = serviceProvider.GetService<ITestColdStartRepository>();
}
public async Task<string> FunctionHandler(object input, ILambdaContext context)
{
var data = await _testColdStartRepository.GetDataAsync("0000000001", "TEST").ConfigureAwait(false);
return $"description:{data.Description}";
}
}
public interface ITestColdStartRepository
{
Task<TestColdStart> GetDataAsync(string id, string code);
}
public class TestColdStartRepository : ITestColdStartRepository
{
private readonly IDynamoDBContext _dynamoDbContext;
public TestColdStartRepository(IDynamoDBContext dynamoDbContext)
{
_dynamoDbContext = dynamoDbContext;
}
public async Task<TestColdStart> GetDataAsync(string id, string code)
{
var testColdStart = await _dynamoDbContext.LoadAsync<TestColdStart>(id, code).ConfigureAwait(false);
return testColdStart;
}
}
public class TestColdStart
{
[DynamoDBHashKey]
public string Id { get; set; }
[DynamoDBRangeKey]
public string Code { get; set; }
public string Description { get; set; }
}
検証1:何も対策なし
まずは速度対策について何も対策していない状態で速度を計測してみます。コールドスタート時とウォームスタート時のそれぞれで計測しました。
コールドスタート時の実行速度
Initialization | DescribeTable | GetItem | Total | Description | |
---|---|---|---|---|---|
検証1 | 436ms | 3420ms | 419ms | 5430ms | 何も対策なし(コールドスタート) |
DynamoDBのDescribeTableに3420msもかかっているのが一番のボトルネックであることがわかります。
ウォームスタート時の実行速度
Initialization | DescribeTable | GetItem | Total | Description | |
---|---|---|---|---|---|
検証1 | 436ms | 3420ms | 419ms | 5430ms | 何も対策なし(コールドスタート) |
ウォームスタート | 0ms | 0ms | 19ms | 31ms | 何も対策なし(ウォームスタート) |
コールドスタート時と比較して、InitializationとDynamoDBのDescribeTableが無くなっています。
ちなみに以降の検証で、ウォームスタート時については誤差レベルの変動しかなかったため、ウォームスタート時の記録については割愛しています。
検証2:メモリ増設
1つ目の速度対策として、メモリ増設が挙げられます。今回は128MBのメモリを1024MBにして速度を比較してみました。
Initialization | DescribeTable | GetItem | Total | Description | |
---|---|---|---|---|---|
検証1 | 436ms | 3420ms | 419ms | 5430ms | 何も対策なし(コールドスタート) |
検証2 | 442ms | 328ms | 52ms | 1050ms | メモリ増設 |
ウォームスタート | 0ms | 0ms | 19ms | 31ms | 何も対策なし(ウォームスタート) |
メモリ増設前と比較して、DescribeTableの速度が3420ms→328msと大幅に改善しています。GetItemについても419ms→52msと全体的に高速に処理されていることがわかりました。
検証3:Provisioned Concurrency+メモリ増設
続いて、Provisioned Concurrencyを有効にして検証してみます。メモリは1024MBのままです。
Initialization | DescribeTable | GetItem | Total | Description | |
---|---|---|---|---|---|
検証1 | 436ms | 3420ms | 419ms | 5430ms | 何も対策なし(コールドスタート) |
検証2 | 442ms | 328ms | 52ms | 1050ms | メモリ増設 |
検証3 | 0ms | 323ms | 47ms | 459ms | Provisioned Concurrency+メモリ増設 |
ウォームスタート | 0ms | 0ms | 19ms | 31ms | 何も対策なし(ウォームスタート) |
Provisioned Concurrency適用前と比較して、Initializationが無くなっているのがわかります。ですが、DescribeTableの処理は実施されているため、ウォームスタート時ほどは速くなりませんでした。
検証4:Provisioned Concurrency+メモリ増設+DescribeTableキャッシュ
DescribeTableを無くすために調査した結果、コンストラクタでのキャッシュ作成が必要という結論に至りました。以下のリンクに記載がありますが、DynamoDB検索処理実行時にDescribeTableが呼ばれ、テーブルメタデータのキャッシュが作成され、次回以降の検索処理時にはキャッシュが利用されるようです。
このため、コンストラクタでキャッシュを作成するために、検索処理を一度実行し、テーブルメタデータのキャッシュを作成するように修正して速度検証してみました。
Initialization | DescribeTable | GetItem | Total | Description | |
---|---|---|---|---|---|
検証1 | 436ms | 3420ms | 419ms | 5430ms | 何も対策なし(コールドスタート) |
検証2 | 442ms | 328ms | 52ms | 1050ms | メモリ増設 |
検証3 | 0ms | 323ms | 47ms | 459ms | Provisioned Concurrency+メモリ増設 |
検証4 | 0ms | 0ms | 37ms | 90ms | Provisioned Concurrency+メモリ増設+DescribeTableキャッシュ |
ウォームスタート | 0ms | 0ms | 19ms | 31ms | 何も対策なし(ウォームスタート) |
狙い通り、DynamoDBのDescribeTable処理が無くなり、高速に処理できるようになりました。
検証5:Provisioned Concurrency+メモリ増設+DescribeTableキャッシュ+GetItemキャッシュ
最後の検証はGetItemで取得するデータもキャッシュするパターンです。これはデータ次第だと思いますがある程度静的なデータであれば実現可能だと思います。DescribeTableでキャッシュされるテーブルメタデータはSDK内部でキャッシュされるようですが、取得したデータ自体は独自でキャッシュする必要があります。
Provisioned Concurrencyで常駐しているインスタンスもCloudWatchLogsのログを見る限り、定期的(大体1~2時間おき)に再起動しているように見えます。※このあたりの情報ソースは見つかりませんでした。
Initialization | DescribeTable | GetItem | Total | Description | |
---|---|---|---|---|---|
検証1 | 436ms | 3420ms | 419ms | 5430ms | 何も対策なし(コールドスタート) |
検証2 | 442ms | 328ms | 52ms | 1050ms | メモリ増設 |
検証3 | 0ms | 323ms | 47ms | 459ms | Provisioned Concurrency+メモリ増設 |
検証4 | 0ms | 0ms | 37ms | 90ms | Provisioned Concurrency+メモリ増設+DescribeTableキャッシュ |
検証5 | 0ms | 0ms | 0ms | 50ms | Provisioned Concurrency+メモリ増設+DescribeTableキャッシュ+GetItemキャッシュ |
ウォームスタート | 0ms | 0ms | 19ms | 31ms | 何も対策なし(ウォームスタート) |
GetItem処理も無くなりました。ここまでくると当初のウォームスタートと殆ど変わらない速度となりました。
おわりに
今回は、Lambdaのコールドスタートをどこまで対策できるかを実際に速度比較して検証してみました。言語がC#だったため、snap startは利用できませんでしたが、速度としては十分な結果を得ることができました。Provisioned Concurrencyは事前に初期処理をして待機しておく機能のため、利用する場合は、キャッシュ作成などを初期処理に含めておくと、メリットが得られるように思います。ただし、Provisioned Concurrencyはサーバーレスの大きなメリットであるコスト面を犠牲にするため、適用は十分検討をした上で行うのが良いかと思います。