【自由研究】Python × AWS Lambda で、コールドスタートでもキャッシュを使う

## TL;DR

たいして速くならないし、速くしたいならコールドスタートを避けるようなプラクティスを採用しましょう

:::note warn
「みなさんぜひやってみてね」というよりは自由研究シリーズです。  
実運用には使わない方がいいかもしれません。
:::

## はじめに

[AWS Lambda](https://aws.amazon.com/jp/lambda/) はサーバーレスでバックエンドサービスのコードを簡単に実行できるサービスです。

AWS Lambda でプログラムを実行した環境は、一定時間経つと破棄されてしまうため、次の実行時にまたソースの取り込みと環境作成分の時間がかかります（コールドスタート）。

定期的に実行したり Provisioned Concurrency を使って環境を保つようにすれば、いざ実行するときに時間があまりかからないし Python のコンパイルキャッシュも効くのですが、私はなぜか「コールドスタートでもキャッシュを効かせてみたい」と思いました。  
キャッシュをためるファイルシステムの分だけお金がかかるんですが、やってみようと思います。

## 前提条件

- AWS のアカウントを作成していること
- AWS Console にログインしていること

## 手順

Lambda 関数を作成し、Python のキャッシュをマウント先の EFS に指定する手順です。

EFS のマウントについてはこちらの記事を参考にいたしました。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-filesystem.html#configuration-filesystem-setup

### EFS の作成

ファイルシステムを作成します。

- Amazon EFS の [File systems](https://console.aws.amazon.com/efs/home#/file-systems) に移動します。
- 「Create file system」をクリックします。
- Name と VPC は以下のように設定します：
  - Name: `lambda-cache`
  - VPC: 今回はデフォルト VPC を使用
- 「Create」をクリックします。

:::note info
`lambda-cache` の「Network」タブを開き、登録されているアベイラビリティゾーンを確認しておいてください。
:::

アクセスポイントを作成します。

- Amazon EFS の [Access points](https://console.aws.amazon.com/efs/home#/access-points) に移動します。
- 「Create access point」をクリックします。
- パラメータは以下のように設定します：
  - File system: 先ほど作ったもの
  - Name: `lambda-cache`
  - Root directory path: `/lambda-cache`
  - User ID: `1001`
  - Group ID: `1001`
  - Owner User ID: `1001`
  - Owner Group ID: `1001`
  - Access point permissions: `0755`
- 「Create access point」をクリックします。

### Lambda 関数の作成と設定

- Lambda の [Functions](https://console.aws.amazon.com/lambda/home#/functions) にアクセスします。
- 「Create function」をクリックします。
- オプションは「Author from scratch」のままで、Basic information は以下のように設定します：
  - Function name: `lambda-cache-test`
  - Runtime: `Python 3.12` (提供されている Python の最新バージョンを選択)
  - Architecture: `x86_64`
- 「Create function」をクリックします。

以下のようにディレクトリ・ファイルを作成します。

```text
lambda-cache-test/
├── lambda_function.py
└── my_package
    └── __init__.py
```

各ファイルの中身は以下のようにします。

```py:my_package/__init__.py
print('my_package imported!')
```

```py:lambda_function.py
import my_package

def lambda_handler(event, context):
    return 0
```

作成したら「Deploy」をクリックして反映します。

テスト用イベントを作成します。

- 「Test」タブをクリックします。
- 各種パラメータは以下のように設定します：
  - Test event action: `Create new event`
  - Event name: `empty`
  - Event sharing settings: `Private`
  - Event JSON: `{}`
- 「Format JSON」をクリックします。
- 「Save」をクリックします。

Lambda のロールにポリシーを追加します。

- 「Configuration」タブをクリックします。
- 左の「Permissions」をクリックします。
- Execution role -> Role name の `lambda-cache-test-role-xxxxxxxx` をクリックします。
- 別タブでロールの詳細が表示されたら、「Permissions」タブ -> Permissions policies の「Add permissions」をクリックし、「Attach policies」をクリックします。
- 「Other permissions policies」のところで以下のポリシーを検索し、チェックを入れます：
  - `AWSLambdaVPCAccessExecutionRole`（[参考](https://dev.classmethod.jp/articles/tsnote-lambda-the-provided-execution-role-does-not-have-permissions-to-call-createnetworkinterface-on-ec2/)）
  - `AmazonElasticFileSystemClientReadWriteAccess`
- 「Add permissions」をクリックします。

Lambda を VPC に紐づけます。

- 「Configuration」タブから、左の「VPC」をクリックします。
- 「Edit」をクリックします。
- パラメータは以下のように設定します：
  - VPC: 前述で EFS を作成したときと同じ VPC（デフォルトの VPC）
  - Subnets: EFS の「Network」一覧にあるもののうち複数を選択
  - Security groups: ここではデフォルト VPC セキュリティグループ
- 「Save」をクリックします。

（VPC の更新には少し時間がかかります）

Lambda から EFS を利用する設定をします。

- 「Configuration」タブから、左の「File systems」をクリックします。
- 「Add file systems」をクリックします。
- パラメータは以下のように設定します：
  - EFS File system: 作成した EFS
  - Access point: 作成したアクセスポイント
  - Local mount path: `/mnt/lambda-cache`
- 「Save」をクリックします。

環境変数を設定します。

- 「Configuration」タブから、左の「Environment variables」をクリックします。
- 「Edit」をクリックします。
- 「Add environment variable」を 2 回クリックして、以下の Key と Value を設定します：
  - Key: `PYTHONPYCACHEPREFIX`, Value: `/mnt/lambda-cache`
  - Key: `PYTHONVERBOSE`, Value: `1`
- 「Save」をクリックします。

---

以上で設定は完了です！

## 実行してみる

Lambda 関数の「Code」タブをクリックし、Test event で Event name に `empty` が選択されていることを確認して「Test」をクリックします。

成功ログが出てきたら、「Details」をクリックして「Log output」を確認します。（長すぎるので抜粋）

```text
# code object from /var/task/lambda_function.py
# created '/mnt/lambda-cache/var/task/lambda_function.cpython-312.pyc'
# code object from /var/task/my_package/__init__.py
# created '/mnt/lambda-cache/var/task/my_package/__init__.cpython-312.pyc'
my_package imported!
import 'my_package' # <_frozen_importlib_external.SourceFileLoader object at 0x7fa444c098e0>
import 'lambda_function' # <_frozen_importlib_external.SourceFileLoader object at 0x7fa444c08290>
START RequestId: a1466a42-8672-4e71-8bda-4652aacdb1c3 Version: $LATEST
END RequestId: a1466a42-8672-4e71-8bda-4652aacdb1c3
REPORT RequestId: a1466a42-8672-4e71-8bda-4652aacdb1c3 Duration: 2.60 ms Billed Duration: 3 ms Memory Size: 128 MB Max Memory Used: 38 MB Init Duration: 2104.92 ms
```

`# created '/mnt/lambda-cache/var/task/my_package/__init__.cpython-312.pyc'` のように出力されており、キャッシュが作成されたことになります。

## キャッシュの検証

環境が破棄されるまで待ってから実行します。

（ログ抜粋）

```text
# /mnt/lambda-cache/var/task/lambda_function.cpython-312.pyc matches /var/task/lambda_function.py
# code object from '/mnt/lambda-cache/var/task/lambda_function.cpython-312.pyc'
# /mnt/lambda-cache/var/task/my_package/__init__.cpython-312.pyc matches /var/task/my_package/__init__.py
# code object from '/mnt/lambda-cache/var/task/my_package/__init__.cpython-312.pyc'
my_package imported!
import 'my_package' # <_frozen_importlib_external.SourceFileLoader object at 0x7fdda2a191c0>
import 'lambda_function' # <_frozen_importlib_external.SourceFileLoader object at 0x7fdda2a19040>
START RequestId: 099ad4cd-b1d5-4c15-8292-9678b19510de Version: $LATEST
END RequestId: 099ad4cd-b1d5-4c15-8292-9678b19510de
REPORT RequestId: 099ad4cd-b1d5-4c15-8292-9678b19510de Duration: 2.17 ms Billed Duration: 3 ms Memory Size: 128 MB Max Memory Used: 35 MB Init Duration: 627.12 ms
```

ログに `# /mnt/lambda-cache/var/task/my_package/__init__.cpython-312.pyc matches /var/task/my_package/__init__.py` のように出力されているため、キャッシュが利用されたことになります。

## 実行時間の比較

何度か実行して、実行時間を比較してみました。

### コールドスタート

コールドスタートの場合、キャッシュの有無で環境構築時間が短くなったようにも見えますが、キャッシュが無い状態を 1 回しか実行していないため、初回だけたまたま遅かった可能性もあり、断言はできないようです。  
また、プログラムが簡単なためか、実行時間とキャッシュの有無には関連がないようです。

|                            | 環境構築時間<br>Init Duration (ms) | 実行時間<br>Duration (ms) |
| -------------------------- | ---------------------------------: | ------------------------: |
| 初回(キャッシュなし)       |                            2104.92 |                      2.60 |
| 2 回目(以降キャッシュあり) |                             627.12 |                      2.17 |
| 3 回目                     |                             459.59 |                      2.26 |
| 4 回目                     |                             738.20 |                      3.96 |
| 5 回目                     |                            1020.69 |                      2.40 |

### ウォームスタート

|        | 環境構築時間<br>Init Duration (ms) | 実行時間<br>Duration (ms) |
| ------ | ---------------------------------: | ------------------------: |
| 1 回目 |                                  - |                      1.36 |
| 2 回目 |                                  - |                      1.77 |
| 3 回目 |                                  - |                      1.52 |
| 4 回目 |                                  - |                      1.45 |
| 5 回目 |                                  - |                      4.36 |

## 結論

キャッシュを使ったところで、コールドスタート時は環境構築時間があるのでたいして速くならない、ということが分かりました。  
処理を速くしたいなら、[AWS 公式ドキュメント](https://docs.aws.amazon.com/lambda/latest/operatorguide/execution-environments.html) に沿ってコールドスタートを避けるのがやはり一番ですね。  
キャッシュを使わないと劇的に遅くなるようなスクリプトなら一定の効果はあるかもしれない、と思いました。

## 所感

Lambda に EFS をマウントする方法や、Python のキャッシュディレクトリを変更する方法などが分かって勉強になりました。何か他のことに生かせるように頑張りたいと思いました。

---

## データのクリーンアップ

同じ方法を試された方は、以下のリソースのクリーンアップを忘れずに行ってください：

- Lambda: `lambda-cache-test`
- IAM: `lambda-cache-test-role-xxxxxxxx`
- EFS
  - アクセスポイント: `lambda-cache`
  - ファイルシステム: `lambda-cache`