ちょっと話題の記事

爆速?!コンテナイメージからデプロイしたLambdaのコールドスタートについて検証してみた #reinvent

10G近いコンテナイメージのコールドスタートがこんなに早いなんて...
2020.12.06

CX事業本部@大阪の岩田です。 この記事はAWS LambdaとServerless Advent Calendar 2020の6日目 です。

先日のブログでご紹介したように、Lambdaのパッケージフォーマットとしてコンテナイメージがサポートされるようになりました。

従来のZIP形式と比較したメリットの1つとして、イメージサイズの上限が10Gまで許容されるという点があります。ZIP形式の上限はレイヤーと関数コードを合わせて250Mであり、サイズの大きなライブラリや機械学習モデルを利用する場合、この上限に抵触することもあったのではないでしょうか?パッケージフォーマットとしてコンテナイメージを選択することで、こういったユースケースにも対応できるようになりました。

ここで1つ疑問が浮かびます。イメージサイズの上限が10GBとのことですが、10GBの巨大イメージからLambdaがコールドスタートした場合どの程度のオーバーヘッドが発生するのでしょうか?サイズの上限が大きいのは良いことですが、コールドスタートがあまりにも遅いとコンテナイメージの魅力が薄れてしまいます。

という訳で、コンテナイメージからデプロイしたLambdaのコールドスタートについて検証してみましょう。

普通のコンテナイメージからコールドスタートした時の速度を計測する

まずはAWS公式のベースイメージにファイルを1つ追加しただけの最小構成のコンテナイメージを使用してコールドスタートの所要時間を計測してみます。

コンテナイメージの作成

Dockerファイルです。

FROM public.ecr.aws/lambda/python:3.8

COPY app.py ./

CMD ["app.lambda_handler"]

COPYしているapp.pyの中身は以下の通りです。

import json

def lambda_handler(event, context):

    return {
        "statusCode": 200,
        "body": json.dumps(
            {
                "message": "hello world",
            }
        ),
    }

このDockerファイルからコンテナイメージをビルドしてECRにPUSHしておきます。

ECRにPUSHした普通のコンテナイメージ

ECR上のイメージサイズは約173Mです。PUSHできたらこのイメージを使用してLambda関数をデプロイしておきましょう。

コールドスタートの所要時間計測

続いてコールドスタートの所要時間を計測します。S3バケットへのPUTをトリガーにLambdaが起動するように設定し、対象バケットに対してs3 syncで一気にPutObjectすることで大量にコールドスタートを発生させます。計測対象Lambdaへのメモリ割当はデフォルトの128Mとしています。

まずはローカル環境で適当にファイルを作成

$for i in $(seq 1000 );do touch $i;done

s3 syncを実行します。これでLambdaが大量に並列起動するはずです。

$aws s3 sync . s3://<トリガーを設定したS3バケット>

しばらく待ってからCloudWatch Logs Insightsから以下のクエリを実行し、コールドスタートの実績値を分析しましょう。

filter @type = "REPORT" |
parse @message /Init Duration: (?<init>\S+)/ |
stats count(init) as count, min(init) as min,max(init) as max, avg(init) as avg, median(init) as median, pct(init, 95) as tile

クエリの実行結果です

件数 最小値 最大値 平均値 中央値 95%タイル
85 326.43 792.66 471.7341 396.44 751.73

今回は85回のコールドスタートが発生しました。コールドスタートの所要時間は平均471ms、中央値で396msという結果です。

巨大なコンテナイメージからコールドスタートした時の速度を計測する

次はコンテナイメージのサイズがコールドスタートにどのように影響を与えるか検証するため、巨大なコンテナイメージからLambda関数をデプロイしてコールドスタートの所要時間を計測してみましょう。

イメージサイズの大きなコンテナイメージを作成する

まずは検証用にイメージサイズの大きなコンテナイメージを作成しましょう。今回はコンテナイメージビルド時に大容量のファイルをCOPYすることでサイズの大きなコンテナイメージを作成します。

まずは大容量ファイルの作成です。

$head -c 9000m /dev/urandom > bigfile
$du -h bigfile
8.8G	bigfile

/dev/urandomを入力に使用しているところがポイントです。/dev/zero等を指定すると圧縮効率が高くなるので、ECRにPUSHした際のイメージサイズが小さくなってしまいます。今回はサイズの大きなコンテナイメージに関する検証が主目的なので圧縮効率が悪くなるようにコンテナイメージをビルドします。

続いてDockerfileです。作成した大容量ファイルと、先程のapp.pyをCOPYするだけの内容です。

FROM public.ecr.aws/lambda/python:3.8

COPY app.py bigfile ./

CMD ["app.lambda_handler"]

準備ができたのでコンテナイメージをビルドしてECRにPUSHしておきましょう。

ECRにPUSHした大容量のコンテナイメージ

10G近いコンテナイメージが準備できました。今度はこのコンテナイメージからLambda関数をデプロイしてコールドスタートの所要時間を計測します。

コールドスタートの所要時間計測

先程と同じ用にs3 syncからLambdaを並列起動してコールドスタートの実績値を分析します。CloudWatchLogs Insightsの集計結果は以下のようになりました。

件数 最小値 最大値 平均値 中央値 95%タイル
114 339.52 1063.8 566.5846 581.0391 875.3408

今回は114回のコールドスタートが発生しました。コールドスタートの所要時間は平均566ms、中央値で581msという結果です。イメージサイズ170Mの時よりも気持ち遅くなった気がしますが、誤差のような気もします。約10GBのイメージから起動していることを踏まえると爆速と言えるのではないでしょうか?従来のZIPパッケージに置き換えて考えると、10GBのパッケージからデプロイしたLambda関数がこんなに早く起動することは考え辛いですよね?

なぜこんなにコールドスタートが早いのか?

ここまでの検証結果から10G近いコンテナイメージからデプロイしたLambda関数であっても、コールドスタートのオーバーヘッドは600ms未満ということが分かりました。コンテナイメージからデプロイしたLambda関数はなぜこんなにコールドスタートが早いのでしょうか?少し考察してみます。AWSブログを確認するとコンテナイメージのデプロイについて以下のように説明されています。

When creating or updating the code of a function, the Lambda platform optimizes new and updated container images to prepare them to receive invocations. This optimization takes >a few seconds or minutes, depending on the size of the image. After that, the function is ready to be invoked. I test the function in the console.

New for AWS Lambda – Container Image Support

コンテナイメージがデプロイされると、Lambdaのプラットフォームはイメージの最適化を行いLambda関数呼び出しの準備を行うとのことです。実際コンテナイメージをデプロイするとマネコンの画面上部に「関数xxxを更新中です。」と表示され、Lamba関数が実行可能になるまでしばらく待つ必要があります。そしてコンテナイメージのサイズが大きくなると、この待ち時間は明らかに長くなります。

コンテナイメージからLambda関数をデプロイした直後の表示

合わせてLambda実行環境のアーキテクチャについて考えてみましょう。まず、Lambdaの実行基盤は以下のようなレイヤ構成になっています。

Lambda実行基盤のアーキテクチャ

Workerと呼ばれるベアメタルのEC2インスタンスの上でFirecrackerによって多数のMicroVMが起動し、さらにMicroVMの上にLambdaの実行環境が構築されます。このアーキテクチャを踏まえてLambdaのコールドスタートを早くする方法を考えてみましょう。ここから先は私の勝手な想像を多分に含むので、実際の動作とは異なる可能性があることにご注意下さい。

真っ先に思いつくのはコンテナイメージのキャッシュです。Worker上の適当なディレクトリにコンテナイメージをDLしておき、各MicroVMはReadOnlyでWorker上のディレクトリをマウント、事前にDLされたコンテナイメージからLambda実行環境を起動すれば、コールドスタートのオーバーヘッドを最小化できそうですね。

Lambda実行基盤のアーキテクチャに関する妄想

そういえば以下のブログで紹介したのですが、Lambda実行環境からfindmntを実行すると/var/taskのマウントソースとして/dev/vdb[/opt/amazon/asc/worker/tasks/<AWSアカウントID>/<Lambda関数名>/<UUIDらしきランダムな文字列>]といういかにもキャッシュに利用されていそうなディレクトリが見えることがあります。

あなたのLambdaが動いているのはEC2の上?それともFirecrackerの上?

これらのことを踏まえて考えると、コンテナイメージをデプロイした際の「最適化」処理の過程でECRからWorkerへのコンテナイメージDLが実行されていると考えるのが自然では無いでしょうか?このアーキテクチャであれば、コールドスタート発生時にコンテナイメージのDLが発生しないため、イメージサイズが10G近くても最低限のオーバーヘッドでLambda関数を起動することができそうです。

仮にこの予想が正しいと仮定した場合、次に気になるのがWorker上にコンテナイメージが存在しない状態でコールドスタートが発生することはあるのか?もし発生したらどうなるのか?という疑問です。Lambdaの実行基盤では多数のWorkerが稼働していますが、あるLambad関数用のコンテナイメージが全てのWorker上にDLされていることはないでしょう。そんなことをしていたらWorkerのストレージはあっという間にパンクするので、あるLambda関数用のコンテナイメージは一部のWorker上にだけDLされると考えるのが自然です。では、意図的にコンテナイメージを持たないWorker上でLambda関数を起動することはできないでしょうか?

Workerの実体はベアメタルEC2インスタンスなので、保有するリソースは限られています。メモリ128MのLambdaを10個起動するぐらいは余裕でしょうが、これがメモリ10GのLambda1,000個になるとどうでしょう?きっと1台のWorkerでは処理しきれず、複数のWokerが必要になるはずです。ここで追加で割り当てられたWorkerがコンテナイメージをDL済でなければ...コールドスタートはメチャクチャ遅くなりそうな気がします。

Lambda実行基盤のアーキテクチャに関する妄想2

こんな状況が起き得るか実験してみましょう。

メモリを10G割り当てたコンテナイメージ形式のLambdaを1,000並列で起動する

Lambdaのメモリ割り当てを10Gに変更し、先程と同様s3 syncで大量に並列起動しましょう。大量のコールドスタートを発生させたいので、Lambdaのコードを以下のように変更し、sleepを追加します。

import json
import time

def lambda_handler(event, context):
    time.sleep(10)
    return {
        "statusCode": 200,
        "body": json.dumps(
            {
                "message": "hello world",
            }
        ),
    }

今回の検証は空ファイル1,000個をs3 syncする処理からLambdaを起動しているので、10秒もsleepしておけば全てのLambda Invokeがコールドスタートになってくれそうです。これでメモリ10GのMicroVMが大量に起動し、大量のWorkerが必要になりそうです。果たして...

実行結果

s3 sync完了後に先ほどと同様CloudWatch Logs Insightsから集計を実施します。結果は以下のようになりました。

件数 最小値 最大値 平均値 中央値 95%タイル
999 320.13 2150.35 479.6907 395.4445 775.6285

しゅごい...最大値こそ2秒超えですが、平均すると479ms、中央値も395msに収まっています。

メトリクスを確認しても特にエラーは発生していません。※1,000オブジェクトのsyncに対してコールドスタートが999件でしたが、1件についてはwarmスタートしていたことが確認できました。Lambdaの実行自体は1,000件全て正常終了しています。

計測時時のLambdaのメトリクス

一応1,2回目に計測したメモリ割り当て128Mかつsleep挟まない版の計測結果と比較してみました。

メモリ割当 イメージサイズ 最小値 最大値 平均値 中央値 95%タイル
128M 約173M 326.43 792.66 471.7341 396.44 751.73
128M 約9613M 339.52 1063.8 566.5846 581.0391 875.3408
10G 約9613M 320.13 2150.35 479.6907 395.4445 775.6285

メモリの割当と比例して利用可能なCPUクレジットが増えた分、微妙に早くなっているように見えます。正直大量にエラーが発生することを予想していたのですが、問題なく10Gのコンテナイメージが999並列で起動できました。裏側のアーキテクチャは一体どうなってるんでしょうか?すごいですね。

まとめ

Lambda関数のパッケージ形式としてコンテナイメージを選択した場合、イメージサイズが大きくてもコールドスタートのオーバーヘッドが小さいことが分かりました(もしかするとイメージサイズは影響しない??)。Lambdaの基盤をイジめて起動エラーを出してやろうぐらいの気持ちで検証していたのですが、1度もエラーは出ず、Lambdaというサービスの基盤が優秀であることを再認識しました。いったい裏側のアーキテクチャはどうなっているんでしょうね?re:inventのunder the hood系セッションでこのあたりの詳細が公開されることに期待したいです。

※Lambdaに関するセッションSVS404でこのあたりのアーキテクチャについて解説がありました。答えを知りたい方は是非こちらをご覧ください