はじめに
Lambda でコンテナを使って動かすことができます。このときに、コールドスタートの時間がどれくらいになるか自信がなかったので、手元で簡単に試してみる記事です。コールドスタートの試行回数は数回程度なので、しっかりした検証ではない点を理解したうえで、この記事を活用ください。また、時期によってもパフォーマンスは変わってくると思うのでその点もご留意ください。
検証結果
個人的な意見ですが、コールドスタートでも、起動がかなり高速だと思いました。ざっくり以下の 2 パターンの検証です。
- コンテナイメージサイズ : 以下の 2 種類
- 100 MB
- 10,000 MB (約 10 GB)
- コンテナイメージ内のアプリケーション : bash script を実行
- Lambda 関数のメモリ : 1024 MB
- VPC : VPC 外で実行
この検証結果では、コンテナイメージサイズの違いによる、コールドスタートの時間の大きな違いは見られませんでした。両方のコールドスタートでも、1 秒未満の速度でアプリケーションが立ち上がりました。
ただ、10,000 MB のコンテナについては大きなダミーデータを入れており、実際に読み込みはしていません。こちらの論文で Lambda 上でコンテナイメージを利用する際の高速化について取り上げられています。高速化の仕組みの一つで、必要なデータのみを後からオンデマンドロードする仕組みが説明されています。この検証ではダミーデータを読み取っているわけでないので、リアルな世界だと容量が大きいコンテナイメージはこの検証結果よりコールドスタートが長くなることが予想できるので、この点は注意しましょう。
なお、コールドスタートの時間の計測方法は、X-Ray を有効化した際の以下の時間を活用しています。正確には Invocation の時間も含んでいますが、引き算するのが面倒なのでこのようにしています。
具体的な検証手順を以下に説明します。
Lambda Runtime API とは
手順に入る前に、Lambda Runtime API について触れます。Lambda 上でコンテナを動かす際に、コンテナイメージ内で Lambda Runtime API に準拠した実装が必要です。理解を深めるための概要図はこんな感じです。
ザックリの流れは以下の通りです。(細かいところで間違いがあるかも)
-
画面左の
Lambda Service
は、 AWS 側で動作している Lambda のサービス群 (コントロールプレーン) -
画面右上の
Runtime + Function
が、いわゆる独自のコードを実行するコンポーネント -
右上の
Runtime + Function
が、画面真ん中のAPI Endpoints
をつかって、次に実行するべき Lambda 関数の情報を取得する -
右上の
Runtime + Function
が独自のコードをした結果を、 画面真ん中のAPI Endpoints
をつかって、レスポンスを行う
独自にコンテナイメージを作成するときに、画面真ん中の API Endpoints
をうまく取り扱う必要があります。
うまく取り扱う方法は、大きくわけて 2 パターンあります。
-
既に実装されている AWS が提供するイメージを利用
- Node.js, Python, Java, .NET, Go, Ruby, Rust はすでに実装されているイメージが提供されているので、これを利用するのが楽です
-
独自に実装
- 上記のイメージで実現したいことが満たせない場合は、独自にカスタムランタイムを作ることができます。これによって、上記の提供されている言語以外でも動かすことが可能です。
今回は、理解を深めたかったので「独自に実装」のパターンでやっていきます。
コンテナイメージ作成
では、Lambda Runtime API を意識した、検証用のコンテナイメージを作成していきます。
作業用ディレクトリ作成
mkdir ~/temp/lambda-container
cd ~/temp/lambda-container
コンテナイメージを作成するため、3 つのファイルを作成します。
dockerfile
- ENTRYPOINT を後述の bootstrap とする
- CMD は、bootstrap の引数として利用しており、 main.sh を指定
FROM amazonlinux:2023.4.20240528.0
ENV LAMBDA_RUNTIME_DIR=/var/runtime \
LAMBDA_TASK_ROOT=/var/task
COPY bootstrap /$LAMBDA_RUNTIME_DIR/bootstrap
COPY main.sh $LAMBDA_TASK_ROOT/
RUN yum install -y procps # ps コマンドをインストール
RUN chmod +x /$LAMBDA_RUNTIME_DIR/bootstrap
WORKDIR $LAMBDA_TASK_ROOT
ENTRYPOINT ["/var/runtime/bootstrap"]
CMD ["main.sh"]
bootstrap : AWS Lambda Runtime Interface と通信するためのラッパー
#!/bin/sh
# 実行すべきアプリケーションのファイル名 main.sh を引数から受け取る
_HANDLER=$1
RUNTIME_URL="http://$AWS_LAMBDA_RUNTIME_API/2018-06-01"
# AWS Lambda Runtime Interface に Response を返すための post 関数
function post() {
request_id=$1
response=$2
url="$RUNTIME_URL/runtime/invocation/$request_id/response"
echo post url: $url
curl -X POST $url -d "$response"
}
# 無限ループの記述があるが、常時無限ループしているわけではなく、Lambda Runtime API から呼び出されたときに処理が動くように見える
# AWS Lambda Runtime Interface から新たな Lambda 関数を起動するリクエストを受け取り、main.sh を実行する
while true
do
# ローカルファイルシステム上に Temp ファイルを作成し、その Path を HEADERS 変数に格納
HEADERS="$(mktemp)"
# AWS Lambda Runtime Interface にアクセスし、次に実行すべき Lambda 関数のイベントデータを取得し、Temp ファイルに書き込む
EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "$RUNTIME_URL/runtime/invocation/next")
# イベントデータを引数に渡して main.sh を実行
RESPONSE=$(sh ${LAMBDA_TASK_ROOT}/${_HANDLER} $EVENT_DATA)
# イベントデータからリクエスト ID を取得
REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
# post 関数を実行して、AWS Lambda Runtime Interface に Response を返す
post $REQUEST_ID $RESPONSE
done
main.sh
- 独自アプリケーション部分。引数を受け取って、echo するだけの bash script
#!/bin/sh
EVENT=$1
echo "HelloWorld:$EVENT"
コンテナイメージの build
docker build -t lambda_container_helloworld .
ローカルでテスト実行
docker run --name lambda_container_helloworld -d lambda_container_helloworld
bash 起動
docker exec -it lambda_container_helloworld bash
bootstrap 経由でメインアプリケーションが実行されている様子が確認できる
bash-5.2# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 4.8 0.0 4232 3220 ? Ss 03:00 0:00 /bin/sh /var/runtime/bootstrap main.sh
RIE (Runtime Interface Emulator) をつかってローカル実行
作成したコンテナイメージを、まず手元のローカルで動作確認をしたくなります。そこで、Runtime Interface API をエミュレートする RIE (Runtime Interface Emulator) を活用して、すばやく手元で確認ができます。
RIE をダウンロードします。
curl -Lo aws-lambda-rie https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie
実行権限付与
chmod +x aws-lambda-rie
RIE (Runtime Interface Emulator) を使って、作成したコンテナイメージを実行
-
-v $(pwd)/aws-lambda-rie:/aws-lambda-rie:
ホストマシンの現在のディレクトリにある aws-lambda-rie ディレクトリをコンテナの /aws-lambda-rie にマウントします。これは、AWS Lambda Runtime Interface Emulator(RIE)が含まれているディレクトリです。 -
--entrypoint="/aws-lambda-rie"
: コンテナのエントリポイントを/aws-lambda-rie
に設定します。これは、コンテナが起動するときに最初に実行されるコマンドです。
docker run --rm -p 9000:8080 \
-v $(pwd)/aws-lambda-rie:/aws-lambda-rie \
--entrypoint="/aws-lambda-rie" \
lambda_container_helloworld /var/runtime/bootstrap main.sh
curl で localhost の 9000 に通信して動作確認をします。
curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '"John"'
実行結果例 : いい感じに動いています。
> curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '"John"'
HelloWorld:"John
コンテナイメージを ECR にアップロード
うまくローカルで動いたので、ECR にイメージをアップロードします。AWS アカウント ID をマスクしているので、環境に合わせてコマンドを変更してください。
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com
docker tag lambda_container_helloworld:latest xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/lambda_container_helloworld:0.0.1
docker push xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/lambda_container_helloworld:0.0.1
アップロードされました。99.56 MB となっており、約 100 MB のイメージです。
Lambda 関数作成
作成したコンテナイメージをつかって、Lambda 関数を作成します。
ECR の URI を指定します。
約 100 MB の Lambda 関数を実行し、コールドスタートを確認
適当に作成した Lambda 関数を実行します。
aws lambda invoke --function-name Lambda_Container_Helloworld --payload '{ "key": "value" }' --cli-binary-format raw-in-base64-out output.json
実行したあと Monitor タブを確認します。
他と比べて時間が伸びているのが、コールドスタートの可能性が高いため、詳細画面を確認します。
Initialization が見えているということは、コールドスタートであったと判断できます。この時の Duration 843 ms の時間を検証結果として記録します。
他のコールドスタートの例 1
他のコールドスタートの例 2
約 10 GB の コンテナイメージを作成
では、次に、約 10 GB のコンテナイメージを作成してみます。コンテナイメージサイズを大きくする目的で、9500 MB のファイルを作成します。
head -c 9500m /dev/urandom > bigfile
これを dockerfile に追加してイメージサイズを大きくします。
FROM amazonlinux:2023.4.20240528.0
ENV LAMBDA_RUNTIME_DIR=/var/runtime \
LAMBDA_TASK_ROOT=/var/task
COPY bootstrap /$LAMBDA_RUNTIME_DIR/bootstrap
COPY main.sh $LAMBDA_TASK_ROOT/
COPY bigfile /$LAMBDA_RUNTIME_DIR/bigfile
RUN yum install -y procps # ps コマンドをインストール
RUN chmod +x /$LAMBDA_RUNTIME_DIR/bootstrap
WORKDIR $LAMBDA_TASK_ROOT
ENTRYPOINT ["/var/runtime/bootstrap"]
CMD ["main.sh"]
build
docker build -t lambda_container_helloworld_10gb .
ECR Push
docker tag lambda_container_helloworld_10gb:latest xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/lambda_container_helloworld_10gb:0.0.1
docker push xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/lambda_container_helloworld_10gb:0.0.1
10064 MB のイメージができました。
Lambda 関数を作成
約 10 GB の Lambda 関数を実行し、コールドスタートを確認
Lambda 関数を実行します。
aws lambda invoke --function-name Lambda_Container_Helloworld_10GB --payload '{ "key": "value" }' --cli-binary-format raw-in-base64-out output.json
コールドスタートになっていますが、実行時間が短いことがわかります。すごい。
Tips : Lambda でコンテナ起動の高速化に関する論文
Lambda でコンテナを動かす際の高速化に関する論文と動画がインターネットに公開されています。
https://www.usenix.org/conference/atc23/presentation/brooker
また、これを取り上げている日本語の記事もあります。
https://developers.cyberagent.co.jp/blog/archives/44067/
上記も参照いただきつつ、個人的に気になった記述を以下に備忘録的に吐き出しておきます。
オンデマンドロード (スパースロード) について
パッケージサイズの増加とコールドスタート数を掛け合わせると、150ペタビット/秒の帯域幅が必要になり、それだけの余剰ネットワーク容量はないということです。そのため、コンテナイメージを分割し、S3にチャンクとして保存することにしました。コールドスタート時にLambdaワーカーがS3からコンテンツを取得し、キャッシュに保存します。さらに詳しく説明すると、ワーカーにはローカルエージェントがあり、チャンクされたコンテナイメージをブロックデバイスとしてFirecrackerマイクロVMに提示します。Firecrackerはそのブロックデバイスをゲストに空のデバイスとして提示しますが、ゲストがデータを読み取ろうとすると、必要なデータがオンデマンドでロードされます。このようにして、アプリケーションを変更することなく、オンデマンドのデータロードを実現しています。
固定サイズのチャンクは512KiBです。 より小さなチャンクは偽共有を最小限に抑えることで重複排除をより良くし、ランダムアクセスパターンの高いワークロードの読み込みを高速化できます。より大きなチャンクはメタデータサイズを削減し、データを読み込むために必要なリクエスト数を減らし(したがってスループットを改善)、シーケンシャルワークロードに自然な先読みを提供します。最適な値はシステムが進化するにつれて時間とともに変化し、お客様がシステムを使用する方法に関する理解が進むにつれて、将来のシステムの反復ではさまざまなチャンクサイズを選択する可能性があります。
キャッシュについて
公式の Docker alpine、ubuntu、および nodejs などのベースコンテナイメージは、非常に広く使用されています。 それぞれが人気のあるコンテナリポジトリ DockerHub から 10 億回以上のダウンロードを誇っています。このようなベースイメージから出発し、アプリケーションの特別なニーズに合わせてカスタマイズすることは、新しいコンテナイメージを作成する一般的な方法です。人気のあるベースイメージを使用すると、セクション 2 で説明した決定論的フラット化プロセスにより、カスタマイズされた部分に対してユニークなチャンクが生成され、同じベースを持つ他のイメージと同じチャンクがコモンパーツに生成されます。これらの共有チャンクは、重複排除の大きな機会を生み出します。 これらのチャンクの単一のコピーのみを格納すれば、データ移動が少なくて済み、ストレージ消費量が少なくなり、キャッシュの効果が高まります。
ワーカーがローカルキャッシュにチャンクを持っていない場合、リモートの可用性ゾーンレベル(AZレベル)の共有キャッシュからそれらを引き出そうとします (図3に示されています)。チャンクがこのキャッシュにない場合、ワーカーはS3からダウンロードし、キャッシュにアップロードします。 このAZレベルのキャッシュは、かなり標準的な設計のカスタム実装です。チャンクはHTTP2を介してフェッチされ、データストレージは、ホットチャンク用のインメモリ層とコールドチャンク用のフラッシュ層の2層構造になっており、evictionはLRU-k [29](Least Recently Usedの走査に強いバリアント)です。チャンクは、一貫したハッシュ[19]スキームのバリアントを使用してAZレベルのキャッシュに分散されます。この際、負荷分散を改善するための最適化(Chenら[7]のアプローチと同様)が行われます。このキャッシング層はフェッチのパフォーマンスを大幅に改善します。ワーカーの観点から見ると、AZレベルキャッシュへのヒットの中央値は550μs、S3のオリジンからのフェッチは36ms(99.9パーセンタイル値は3.7msに対し175ms)です。
図7は、これら3つのキャッシュ層の効果を示しています。大規模なAWSリージョンでの1週間の実稼働使用において、中央値で67%のチャンクがワーカーのキャッシュから読み込まれ、32%がAZレベルの分散キャッシュから、残りの0.06%がバッキングストアからロードされました。 (2 つのレイヤーのキャッシュが存在しており、ほぼキャッシュヒットを実現できている)
Tips : Lambda Runtime API の定義
Lambda Runtime API の定義が以下の Document に記載がある。
アプリケーションが処理を実行した後のレスポンスは、文字列を返却することが可能。単なる平文でもいいし、JSON で返してあげてもいい。
参考 URL
コンテナイメージ作成 : 非 AWS ベースイメージを使用する
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/images-create.html#images-ric
Lambda Runtime API
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/runtimes-api.html
チュートリアル: カスタムランタイムの構築
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/runtimes-walkthrough.html
Lambda 高速化に関する論文
https://www.usenix.org/conference/atc23/presentation/brooker
この論文を取り上げている記事
https://developers.cyberagent.co.jp/blog/archives/44067/