Google Cloud Run と AWS Lambda のコールドスタート時間を言語別に観察してみる

![plot_256.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95370/1b04e167-0378-dfc1-dd0c-dbaea3b9d6ac.png)

コンテナを**リクエスト処理時間ベースの料金体系**で実行できるサーバレス環境としては、Google の Cloud Run（[2019年11月GA](https://cloud.google.com/run/docs/release-notes#November_14_2019)）と AWS Lambda（[2020年12月にコンテナに対応](https://aws.amazon.com/jp/blogs/news/new-for-aws-lambda-container-image-support/)）が特に有名でしょう。

これらの環境は、一度起動したコンテナインスタンスをしばらく生かしておき、その後のリクエストに使いまわします。しかし、生きているインスタンスが足りない場合は新たなコンテナの起動から始めるいわゆる「コールドスタート」となり、応答のオーバーヘッドが大きく増加します。用途によっては、このコールドスタートにかかる時間が問題になります。

Cloud Run と Lambda でのコールドスタートの様子を観察するため、いくつかの言語で "Hello, World!" を返すだけのWebアプリコンテナを作り、コールドスタートの時間を「雑に」観察してみました。

### 注意: コストや性能は考慮しない

AWS Lambda と Cloud Run は、リクエスト処理の並行性や料金体系などが異なるため、コストや性能の面でどちらが優位かは用途やワークロードの性質によってまちまちでしょう。

Cloud Run は基本的に1つのインスタンスが複数のリクエスト（デフォルトの上限は80個）を同時に処理でき、リクエストを処理中の**インタンス数**ぶんの時間料金がかかります。一方で Lambda は１つのインスタンスが同時に１つのリクエストのみを処理し、**リクエスト数**ぶんの時間料金がかかります。このような性質上、vCPUやメモリ量の単価の捉え方なども両者で異なります。

そのほかにも多様な要素が絡み、**フェアな比較は不可能**ですので、ここではコストや性能などには触れずに、限定された条件でのコールドスタートの様子を大雑把に観察するにとどめます。

また、Cloud Run には「[最小インスタンス数](https://cloud.google.com/run/docs/configuring/min-instances)」、AWS Lambda には「[Provisioned Concurrency](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/provisioned-concurrency.html)」といった、一定の課金でコールドスタートの発生を減らす仕組みがあります。これらは今回使いませんし、これらの機能を使った場合のコストも単純には比較できません。

## 実験方法

"Hello, World!" を返すだけのWebアプリケーションのコンテナをいくつかの言語で作って、それぞれを Google Cloud Run と AWS Lambda ([AWS Lambda Web Adapter](https://github.com/awslabs/aws-lambda-web-adapter)) で動かします。

Cloud Run は、マネージドな[Knative](https://knative.dev/docs/)であり、Webアプリ（HTTPサーバ）のコンテナをそのまま動かすことができます。また、AWS Lambda でも似たようなことをするために、AWS Labs が提供する [AWS Lambda Web Adapter](https://github.com/awslabs/aws-lambda-web-adapter) というものを使うことにします。


### サンプルリポジトリ

実験に使った Dockerfile などは以下に置いてあります。

https://github.com/ciscorn/coldstart

### 小さなコンテナイメージを用意する

コンテナ系のサーバレス環境では、コンテナのイメージサイズがコールドスタート時間などに少なからず影響するため、イメージはなるべく小さく作るのがよいとされます。

そこで各言語のコンテナイメージを作る際には、いわゆる "distroless" なベースイメージを使い、マルチステージビルドも行って、なるべく小さなイメージになるようにしました。ベースイメージは、今回は [Chainguard](https://images.chainguard.dev/) が提供しているものを使いました。

以下の言語でイメージを作りました。結果のイメージサイズ（圧縮後）も書いておきます。

- **Python (Gunicorn)** - イメージサイズ: 28.4 MB
    - WSGI アプリ（フレームワーク未使用）を、Gunicorn を使って動かします。
- **Python (Uvicorn)**  - イメージサイズ: 28.9 MB
    - ASGI アプリ（フレームワーク未使用）を、Gunicorn + UvicornWorker を使って動かします（uvicorn コマンドで動かすよりも gunicorn + UvicornWorker のほうが起動が早いようだったため）。
- **JavaScript** (**Bun**) - イメージサイズ: 39.1 MB
    - [Hono](https://hono.dev/) というWebフレームワークを使いました。
    - ビルド時に `bun bundle --minify` でバンドルしておき、それを Bun で実行します。
    - （Node 20 のイメージでのクロスプラットフォームビルドにおいて現在問題が起きるようで、今回は Node でなく Bun を使いました。）
- **Go** - イメージサイズ: 4.2MB
    - Go 1.22 の標準ライブラリ (`net/http`) だけを使いました。
    - ビルドした実行可能ファイルを、[static](https://images.chainguard.dev/directory/image/static/overview) ベースイメージ上で動かします。 
- **Rust** - イメージサイズ: 1.5 MB
    - [axum](https://github.com/tokio-rs/axum) というWebフレームワークを使いました。
    - ビルドした実行可能ファイルを、[static](https://images.chainguard.dev/directory/image/static/overview) ベースイメージ上で動かします。 

（追記： Node でも少しだけ調べたので末尾に結果を置いておきます）

いずれも "Hello, Rust!" のようなテキストを返すだけの内容です。

### コールドスタートの観察方法

今回は「たまに呼ばれるWeb API」の用途を想定して、東京のVPSにあるサーバから、Cloud Run (東京) のサービスのURLと、Lambda (東京) のFunction URLに，20分ごとにアクセスし、応答までの時間を測ります。これを数時間続けます。

アクセスする際には、10リクエストを並行に送り、各リクエストの応答が返ってくるまでの所用時間を記録します。 

なお、20分ほどの間隔を開けてリクエストすれば、基本的にコールドスタートになるようです。

また、各環境において、コンテナレジストリとコンテナランタイムの間に何らかのキャッシュが存在すると考えられるため、始めに1時間ほどは意味のないリクエストを送ってから、測定を開始しています。

### 各環境の設定

- **Cloud Run**
    - CPUの割り当ては「リクエストの処理中にのみ CPU を割り当てる」にします。（ほかに「[常時CPUを割り当てる](https://cloud.google.com/run/docs/configuring/cpu-allocation)」モードも選べます）
    - 実験の目的上、「[最小インスタンス](https://cloud.google.com/run/docs/configuring/min-instances)」の機能は使いません（比較的小さいコストでインスタンスをウォーム状態に保てる機能です）
    - vCPU数は 1 にします
    - 実行環境（世代）の選択は「デフォルト」にして Cloud Run に委ねます
    - Startup CPU boost は有効にします（デフォルト値のまま）
- **AWS Lambda**
    - 実験の目的上、[Provisioned Concurrency](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/provisioned-concurrency.html)（プロビジョニング済み同時実行）は使いません。（待機中の小さなコストと、実行中のコストを増やすことで、一定数のインスタンスをウォーム状態に保てる機能です）

いずれも東京リージョンで、CPUアーキテクチャは x86_84 です。


## 実験結果

念のためメモリサイズをいくつか替えてみて実験し、測定結果を Matplotlib と [seaborn](https://seaborn.pydata.org/) の [violinplot](https://seaborn.pydata.org/generated/seaborn.violinplot.html) でプロットしました。縦軸がリクエストの応答時間です。

（ちなみに、コールドスタートでない場合は、いずれのターゲットも0.1秒以下で応答が返ってきます。）

### メモリ 128MiB のとき

いずれの環境もメモリを128MiBにした場合の結果です。

![plot_128.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95370/9354b74d-e6ae-0b03-8caa-a614cd04ef21.png)

- 全体的に Cloud Run のほうが少し早いです（特に、RustやGoのようにWebアプリケーションの起動コストが少ない場合に）。
- Cloud Run での Bun の結果が良く、Go や Rust に肉薄しているのが意外でした（実験の手違いではないようです）。Lambdaにおいても悪くないです。
- Cloud Run は、イメージサイズがコールドスタートに与える影響が、Lambda よりも小さいかもしれません（Bunはイメージサイズが大きいが、その影響をあまり受けていないように見える）。
- Python の2つは、コンテナの実行環境でなく、Gunicorn や Uvicorn の起動に要する時間が結果に大きく影響しているように思います（詳細は未調査）。

### メモリ: 256MiB のとき

![plot_256.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95370/1b04e167-0378-dfc1-dd0c-dbaea3b9d6ac.png)

- Cloud Run、Lambda いずれも、128MiBのときと比べて有意な変化はないように見えます。

### メモリ: 512MiB のとき

![plot_512.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95370/565407ec-077e-6c8b-d7dc-b031f995f390.png)

- Cloud Run の応答時間の分散が大きくなっている（平均値はほぼ変わらないが）
    - 推察： 今回は実行環境（世代）の選択を Cloud Run に任せています。メモリが 512MiB 以上の場合は第2世代が使えるようになるため、Cloud Run が第2世代を自動選択した場合にコールドスタートの時間が増加するのかもしれません（第2世代のほうがコールドスタートの時間が長いとされる）。ただし、試行を進めるにつれて、（実行パターンが収集されているのか？）応答時間が安定していくように見えました。
- AWS Lambda のほうは、有意な変化はないように見えます。

## 今回調べなかったこと

今回の検証は以上です。コストや性能は用途によりけりだと思いますし、両プラットフォームに存在する「コールドスタートを抑える追加オプション」の存在やそのコストの問題もありますので、どちらが優れているなどの分かりやすい結論は示せません。

ほかに、個人的に気になることとしては以下があります。

- Node.js や Deno のケース
- サイズの大きいコンテナイメージを作った場合のコールドスタートへの影響
    - まったく異なる結果になる可能性もあるでしょう
- よくある用途での Cloud Run と Lambda のコストの違い
    - 特に、Cloud Run の「最小インスタンス数」などを使った場合
- Python環境の起動時間をもう少し改善できないか
- 巨大なコンテナイメージの場合
- など


## 追記: Node.js

Node.js 20 でもやってみました。

- Node.js 20 + Hono
- メモリ128MiBのケースのみ
- Honoの "Hello, World!" のTypeScriptコードはコンテナイメージ作成時にトランスパイルしておく。

![plot_node.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95370/9dacbb9e-da70-3d02-70de-4dc0871088c2.png)

Cloud Run は vCPU が 1 あるのが効いているかも。Lambda はメモリ128MiBだとvCPUはかなり低い値になるはず。