コンテナ形式のLambdaはPythonライブラリのimportが早いか検証してみる


AWS LambdaでWebアプリケーションを開発する場合、コールドスタートが課題になる事が多いです。特にpandasやboto3のような重いライブラリを利用する場合は、その影響は無視出来ないものになります。

このコールドスタートについては、ユーザー側で対策出来る事があまりなく、いつも悩みの種になるのですが、つい最近、コンテナ形式のLambdaのコールドスタートについて[検証されている記事](https://note.com/kind_ixia1/n/n10ee12d0856b)を読み、pandasやboto3をimportする場合はコンテナ形式のLambdaの方がzip形式のLambdaよりもコールドスタートが短いと言う結論になっているのを見て、とても興味を持ちました。

何故かと言うと、その記事で比較しているのがinit durationである事を考えると、差がついているのはライブラリのimport時間ぐらいしか思い当たるものがなく、ライブラリのimport時間がzip形式とコンテナ形式で差が出るとは思わなかったからです。

もし、コンテナ形式のLambdaの方がライブラリのimport時間が短い(結果としてコールドスタートが短い)のであれば、[Lambdalith](https://rehanvdm.com/blog/should-you-use-a-lambda-monolith-lambdalith-for-the-api)のようなコールドスタートが比較的課題になりやすいようなケースでの活用が考えられると思います。

以上から、コンテナ形式のLambdaのライブラリのimport時間を計測して、本当にzip形式のLambdaよりも短いのか確認し、import時間が短い理由を考察します。

# 事前知識
この記事は、下記の検証記事の続編の位置付けになりますので、先にこちらを軽く読んで頂くと、以降の内容をスムーズに理解して頂けると思います。

https://qiita.com/yasuaki9973/items/639336a009f04f77be12

# 検証の前提条件

今回の検証では、特に明記しない場合は以下の設定で検証を行います。

言語：Python3.12
Lambdaの形式：コンテナ形式
VPC内/VPC外：VPC外
CPUアーキテクチャ：arm64
メモリサイズ：512MB

また、今回はそれぞれの条件での計測を11回行い、その平均を用いる事にします。
(デプロイ直後の最初のリクエストはコールドスタートがとても長い為、それを除いて10回分のデータを利用して平均を計算しています)

# 検証で利用するイメージ
[AWSの公式ブログ](https://aws.amazon.com/jp/blogs/news/optimizing-lambda-functions-packaged-as-container-images/)にもあるように、コンテナ形式のLambdaの場合はAWS提供のベースイメージを利用するのが良いとされているので、今回はAWS提供のベースイメージを利用します。

この点に関しては、公式ではありませんが、コンテナ形式のLambdaがコンテナのデータをロードする仕組みについて、[論文の要点を纏めてくださっている記事](https://developers.cyberagent.co.jp/blog/archives/44067/)がありますので、そちらも合わせて読んで頂くと理解が深まると思います。

# 検証で利用するライブラリ

今回の検証では、以下のライブラリ(バージョン)を利用して検証を行います。

pandas(2.2.3)
boto3(1.35.66)

# 検証結果

下記の内容でデプロイを行い、Lambda関数を実行してimport時間を計測します。

```diff_python:lambda_function.py
import time

start_time = time.time()

# boto3の検証をする場合は、importのコメントアウトを切り替える
# import boto3
import pandas as pd

elapsed_time = time.time() - start_time
print(f'elapsed_time: {elapsed_time} ')


def handler(event, context):
    return {}

```

```diff:requirements.txt
pandas==2.2.3

```

```diff:Dockerfile
FROM public.ecr.aws/lambda/python:3.12

# 必要なライブラリをインストールする
COPY requirements.txt ${LAMBDA_TASK_ROOT}
RUN pip install --no-cache-dir -r requirements.txt

# 関数コードのコピーする
COPY lambda_function.py ${LAMBDA_TASK_ROOT}

# コンテナ起動時に登録されるhandler関数を指定する
CMD [ "lambda_function.handler" ]

```

計測した結果を表に纏めたものが下記になります。

表を見ると、zip形式よりもコンテナ形式の方がライブラリのimport時間が最大で70%近く短いことが分かります。ここまで劇的な効果が出るのであれば、重いライブラリを利用する必要があるWebアプリケーションの場合は、まずコンテナ形式のLambdaを検討するのが良さそうです。

|ライブラリ名 |import時間(ms)|import時間(ms) ※zip形式 |コンテナ/zip(%)|
|---        |---        |---                |---     |
|pandas     |664        |2209               |30.0    |
|boto3      |244        |603                |40.4     |

# ライブラリのimportにかかる時間が短縮される理由

ライブラリのimport時間が短縮される理由を理解する上で、まず、`__pycache__`について理解する必要があります。簡単に言うと、`__pycache__`はPythonスクリプトの初回実行時に生成されて、2回目以降のそのPythonスクリプトの読み込みを高速化してくれます。

AWS Lambdaでの`__pycache__`の取り扱いについては、例えば[こちらの記事](https://tmokmss.hatenablog.com/entry/analyze-and-improve-lambda-python-coldstart)でも言及されていますが、コンテナ形式の場合はコンテナ内でOSやアーキテクチャの差異は発生しない為、生成された`__pycache__`を有効に活用することが出来ます。

今回の、pandasやboto3のimport時間が短縮される現象は、この`__pycache__`がイメージ作成時点で存在していることによって発生していると考えられます。

以降で、`__pycache__`の挙動を確認する為に、いくつか簡単な実験をしてみます。

## compileall で、`__pycache__`を明示的に生成する

最初の実験では、compileallを利用して`__pycache__`を生成した上で、Lambda関数を実行してimport時間を計測します。

```diff:Dockerfile
FROM public.ecr.aws/lambda/python:3.12

# 必要なライブラリをインストールする
COPY requirements.txt ${LAMBDA_TASK_ROOT}
RUN pip install --no-cache-dir -r requirements.txt

+ # 各ライブラリの__pycache__を生成する
+ RUN python -m compileall -f -j 0 /var/lang/lib/python3.12/site-packages/

# 関数コードのコピーする
COPY lambda_function.py ${LAMBDA_TASK_ROOT}

# コンテナ起動時に登録されるhandler関数を指定する
CMD [ "lambda_function.handler" ]

```

結果を下記の表に纏めていますが、pandasは僅かに早くなるものの、boto3は逆に遅くなりました。変動の幅も誤差の範囲のように見え、あまり`__pycache__`の効果は感じられません。考えられる理由としては、`__pycache__`がそもそも有効ではないか、既に存在するかのどちらかだと考えられます。

|ライブラリ名 |import時間(ms)|import時間(ms) ※zip形式 |コンテナ/zip(%)|
|---        |---        |---                |---     |
|pandas     |637        |2209               |28.8    |
|boto3      |262        |603                |43.4     |

## `__pycache__`を明示的に削除する

2つ目の実験では、pipでライブラリをインストールした後、`__pycache__`を削除した上で、Lambda関数を実行してimport時間を計測します。

```diff:Dockerfile
FROM public.ecr.aws/lambda/python:3.12

# 必要なライブラリをインストールする
COPY requirements.txt ${LAMBDA_TASK_ROOT}
RUN pip install --no-cache-dir -r requirements.txt

+ # 各ライブラリの__pycache__を削除する
+ RUN dnf install -y findutils
+ RUN find /var/lang/lib/python3.12/site-packages/. -name '__pycache__' -type d -exec rm -r {} +

# 関数コードのコピーする
COPY lambda_function.py ${LAMBDA_TASK_ROOT}

# コンテナ起動時に登録されるhandler関数を指定する
CMD [ "lambda_function.handler" ]

```

この実験の結果は、zip形式の時と比べてほとんど差がないぐらいにライブラリのimportが遅くなりました。`__pycache__`が削除された為にimportが遅くなった(つまり、pip install 時点で`__pycache__`が存在した)と考えるのが妥当だと思います。

|ライブラリ名 |import時間(ms)|import時間(ms) ※zip形式 |コンテナ/zip(%)|
|---        |---        |---                |---     |
|pandas     |2121        |2209               |96.0    |
|boto3      |586        |603                |97.1     |

## `__pycache__`を明示的に削除した後、再生成する

3つ目の実験では、2つ目の実験と同様の方法で`__pycache__`を削除した後、compileallを利用して`__pycache__`を生成した上で、Lambda関数を実行してimport時間を計測します。

```diff:Dockerfile
FROM public.ecr.aws/lambda/python:3.12

# 必要なライブラリをインストールする
COPY requirements.txt ${LAMBDA_TASK_ROOT}
RUN pip install --no-cache-dir -r requirements.txt

+ # 各ライブラリの__pycache__を削除する
+ RUN dnf install -y findutils
+ RUN find /var/lang/lib/python3.12/site-packages/. -name '__pycache__' -type d -exec rm -r {} +

+ # 各ライブラリの__pycache__を生成する
+ RUN python -m compileall -f -j 0 /var/lang/lib/python3.12/site-packages/

# 関数コードのコピーする
COPY lambda_function.py ${LAMBDA_TASK_ROOT}

# コンテナ起動時に登録されるhandler関数を指定する
CMD [ "lambda_function.handler" ]

```

この実験の結果は、最初の実験とほぼ同じ結果になりました。compileallによって`__pycache__`が生成された結果、ライブラリのimport時間が短縮されたと考えられます。

2つ目の実験結果と合わせて、`__pycache__`がライブラリのimport時間短縮に寄与していると証明出来たと言って良いと考えます。

|ライブラリ名 |import時間(ms)|import時間(ms) ※zip形式 |コンテナ/zip(%)|
|---        |---        |---                |---     |
|pandas     |644        |2209               |29.1    |
|boto3      |255        |603                |42.2     |

## --no-compileオプションで、`__pycache__`を生成しない

最後の実験では、pip install 時のオプションとして --no-compile を指定する事で`__pycache__`の生成を抑止した上で、Lambda関数を実行してimport時間を計測します。

```diff:Dockerfile
FROM public.ecr.aws/lambda/python:3.12

# 必要なライブラリをインストールする
COPY requirements.txt ${LAMBDA_TASK_ROOT}
- RUN pip install --no-cache-dir -r requirements.txt
+ RUN pip install --no-cache-dir --no-compile -r requirements.txt

# 関数コードのコピーする
COPY lambda_function.py ${LAMBDA_TASK_ROOT}

# コンテナ起動時に登録されるhandler関数を指定する
CMD [ "lambda_function.handler" ]

```

この実験の結果は、pandasはzip形式の時と同じぐらいのimport時間でしたが、boto3の方はかなり短い時間でimport出来る事が分かりました。※ 少し予想外の結果でしたが、ベースイメージの時点で既にboto3がインストールされている事が影響しているのかもしれません。

|ライブラリ名 |import時間(ms)|import時間(ms) ※zip形式 |コンテナ/zip(%)|
|---        |---        |---                |---     |
|pandas     |2047        |2209               |92.6    |
|boto3      |288        |603                |47.7     |

# 結論

コンテナ形式のLambdaはzip形式のLambdaと比べて、ライブラリのimport時間が最大で70%近く短縮出来る事が分かりました。

その理由は、コンテナ形式のLambdaの場合はコンテナ内でOSやアーキテクチャの差異は無く、生成された`__pycache__`を有効に活用する事が出来るので、イメージ作成時点で存在していた`__pycache__`によってライブラリのimport時間が短縮されていると考えられます。

この特徴を活用して、例えばWebアプリケーションのバックエンドをLambdalithで構築する場合など、コールドスタートの長さが課題となるようなケースにおいて、コンテナ形式のLambdaを採用すると非常に有効だと考えられます。

# 最後に

この記事を最後まで読んで頂き、ありがとうございました。

出来るだけ正確な情報を記載するように心掛けましたが、もし誤った内容がありましたらコメントでお知らせ頂けるととてもありがたいです。