「aws lambdaでpythonを実行するときのチューニング案を試してみた!」というタイトルで登壇しました。

2020.07.06

こんにちは(U・ω・U)
AWS事業部の深澤です。

先日、remote.pyというイベントに参加させていただき、「aws lambdaでpythonを実行するときのチューニング案を試してみた!」というタイトルで発表したのでその時の登壇資料と内容を本ブログにまとめました。

登壇資料

Youtube

登壇内容

以下のようなアジェンダで発表させていただきました。

AWS Lambdaとは

まずは簡単にAWS Lambda(以降、Lambda)のおさらいをしましょう。クラウドサービスのジャンルとしてはFunction as a Service(FaaS)と呼ばれるジャンルに位置していて、コードをZIPにまとめてアップロードするだけで簡単にそのコードが実行でき、かつサーバを意識しなくて良いが特徴のサービスです。Lambdaの全てをここで説明するのは割愛させていただきますが、AWSの公式ドキュメントやBlackbekltの内容をみていただくことでより理解を深めることができると思います。

ではここから本題です。

実行コンテキストを活用した初期化処理の再利用

まずはLambdaのライフサイクルを整理しましょう。Lambdaでは実行命令がくるとサーバ(VM)を立ち上げて、その中でデプロイパッケージ(アップロードしたZIP)のダウンロードや解凍、その梱包物の実行環境を整えたコンテナの起動を行いコードを実行します。このサーバ立ち上げから実行環境の整備、コードの実行までをコールドスタートと呼び、立ち上げられた環境を実行コンテキストと呼びます。

こちらのコールドスタート関連については弊社岩田の資料が大変わかりやすいのでオススメです。

では次のコードを見てください。

import time


class sleep_class():
    def __init__(self):
        time.sleep(2.0)
        self.init_time = time.time()


def lambda_handler(event, context):
    s = sleep_class()
    print(s.init_time)

初期化に2秒ほどかかるsleepクラスがあります。これをLambdaで複数回実行すると次のような結果になります。

2020-07-02T01:30:50.730+09:00 START RequestId: a4d4224e-bc6f-4a95-aa49-db42de4b2cfb Version: $LATEST
2020-07-02T01:30:52.733+09:00 1593621052.7334466
2020-07-02T01:30:52.734+09:00 END RequestId: a4d4224e-bc6f-4a95-aa49-db42de4b2cfb
2020-07-02T01:30:52.734+09:00 REPORT RequestId: a4d4224e-bc6f-4a95-aa49-db42de4b2cfb Duration: 2003.79 ms Billed Duration: 2100 ms Memory Size: 128 MB Max Memory Used: 51 MB Init Duration: 127.12 ms

2020-07-02T01:31:12.019+09:00 START RequestId: 318fdbbd-a78f-493f-b0b7-6cfa71d79641 Version: $LATEST
2020-07-02T01:31:14.025+09:00 1593621074.025526
2020-07-02T01:31:14.026+09:00 END RequestId: 318fdbbd-a78f-493f-b0b7-6cfa71d79641
2020-07-02T01:31:14.026+09:00 REPORT RequestId: 318fdbbd-a78f-493f-b0b7-6cfa71d79641 Duration: 2003.55 ms Billed Duration: 2100 ms Memory Size: 128 MB Max Memory Used: 51 MB

実はLambdaの中のコードはハンドラー関数以外はコールドスタートの一回のみ実行するという特徴があります。これについては弊社藤井のブログも大変読みやすいので良かったら参照して下さい。

つまり先ほどのコードを、コールドスタートの一回のみ実行される箇所とLambdaを実行する都度呼び出される箇所に分けると次のようになります。

なので、この2秒かかるロードについてはハンドラー関数外に置くことでコールドスタートの時のみ呼び出されるようにすることができます。コードを次のように修正します。

import time


class sleep_class():
    def __init__(self):
        time.sleep(2.0)
        self.init_time = time.time()


s = sleep_class()


def lambda_handler(event, context):
    print(s.init_time)

結果は次のようになります。

2020-07-02T01:33:30.619+09:00 START RequestId: 22ae4fca-ce7b-433a-9379-24ad7336cb21 Version: $LATEST
2020-07-02T01:33:30.620+09:00 1593621210.6173654
2020-07-02T01:33:30.620+09:00 END RequestId: 22ae4fca-ce7b-433a-9379-24ad7336cb21
2020-07-02T01:33:30.620+09:00 REPORT RequestId: 22ae4fca-ce7b-433a-9379-24ad7336cb21 Duration: 1.24 ms Billed Duration: 100 ms Memory Size: 128 MB Max Memory Used: 51 MB Init Duration: 2117.97 ms

2020-07-02T01:33:58.629+09:00 START RequestId: d17c86aa-ad1a-4a44-a7ca-fe4b6c31e64f Version: $LATEST
2020-07-02T01:33:58.634+09:00 1593621210.6173654
2020-07-02T01:33:58.635+09:00 END RequestId: d17c86aa-ad1a-4a44-a7ca-fe4b6c31e64f
2020-07-02T01:33:58.635+09:00 REPORT RequestId: d17c86aa-ad1a-4a44-a7ca-fe4b6c31e64f Duration: 0.97 ms Billed Duration: 100 ms Memory Size: 128 MB Max Memory Used: 51 MB

前者と比べてLambdaの開始速度が速くなって、かつinit_timeの値が実行前後で同じになっている点に注目してください。このようにLambdaではハンドラー関数外の処理はコールドスタートの一回のみに実行されて、2回目の実行以降はこのコールドスタートで実行された値が再利用されることが分かります。実行速度が改善された反面、Init Duration(初期所要時間) が増加している点も注目ですね。

使い道としては、今回のように初期化に時間のかかり、かつ同じインスタンスでも構わない場合に初期化処理を事前にやったり、DBへの接続キャッシュ(コネクションプーリング?)を事前に用意しておくことで活用ができます。DBへの接続キャッシュとして利用する場合は接続ライブラリの仕様によっては接続がキープされてしまう点に注意して下さい。DBのコネクションが枯渇してしまう恐れがあります。また数日前にはRDS Proxyと呼ばれるプロキシサービスがリリースされたので、コストに余裕があり、かつDBにRDSを利用されている場合はこちらを選択された方が良いかもしれません。

boto3の軽量化

続いてboto3の軽量化についてご紹介します。本手法は過去に弊社の岩田が紹介しているものになります。

まずboto3についてですが、こちらはAWSが提供しているPython用のSDKです。これを用いてAWSのAPIをコールしサービス(リソース)の操作をpythonで行えるようになります。

Lambdaではpipインストールが必要なライブラリについてはローカルインストール(pip install -t ./)してそれをデプロイパッケージに含める必要がありますが、boto3については既にLambdaの実行環境に含まれているので通常はデプロイパッケージに含めずともimportするだけで利用ができます。

関数が SDK for Python (Boto3) 以外のライブラリに依存している場合は、pip を使用してローカルディレクトリにインストールし、デプロイパッケージに含めます。

このboto3、もちろんデプロイパッケージに含めることもできます。主な理由としてはリリース直後のAWSの新機能を利用したい等です。

ですがboto3は量が多いのでLambda初期起動時(コールドスタート)の動作が重たくなります。Lambdaでは初期起動時にデプロイパッケージをダウンロード、解凍するからです。パッケージサイズを小さくすることについてはlambdaのベストプラクティスとして公式ドキュメントでも紹介されています。

デプロイパッケージのサイズをランタイムに必要な最小限のサイズにします。これにより、呼び出しに先立ってデプロイパッケージをダウンロードして解凍する所要時間が短縮されます。

なのでこのboto3の不必要な箇所を削ぎ落とすことでコールドスタートの短縮を狙うチューニング案を試してみましょう。boto3及びbotocoreではdataディレクトリ配下に扱えるサービス分のデータ(json)を持っています。

具体例をみてみましょう。例えばboto3を用いてs3を扱いたい場合、初期化(インスタンス化)は次のように行います。

import boto3

client = boto3.client('s3')

そして初期化したインスタンス(上記だとclient)から扱えるメソッドは次の通りです。

ここに書かれているメソッドは結局はAPIをコールするためのメソッドです。メソッドを呼び出した際に実際にどんなhttpリクエストを発行するのかは先ほど紹介したdataディレクトリ配下のそれぞれのサービス毎にjsonで定義されています。s3の場合はこんな具合です。

以下、一部引用したものになります。

```
{
  "version":"2.0",
  "metadata":{
〜〜〜〜〜〜
  },
  "operations":{
    "AbortMultipartUpload":{
〜〜〜〜〜〜
    },
    "CompleteMultipartUpload":{
〜〜〜〜〜〜
    },
    "CopyObject":{
〜〜〜〜〜〜
    },
〜〜〜〜〜〜 
  }
〜〜〜〜〜〜
```

つまり、例えばs3しかboto3から呼び出さないのであれば他のパッケージは不要なものとなるわけです。
※この方法は決して推奨ではありません。本家のパッケージに手を加える行為は今後運用していくことを考えると良い行為とは言えません。運用に取り入れられる際には十分ご注意下さい。

このboto3とbotocoreのdataディレクトリ配下のサイズを確認してみましょう。本ブログ執筆時点(2020年7月3日)では以下のような結果になりました。

$ du -d 0 -h botocore/data/
44M	botocore/data/
$ du -d 0 -h boto3/data/
672K	boto3/data/

これをs3が扱える最低限のデータを残し全て削除します。結果は次のようになりました。

$ du -d 0 -h botocore/data/
968K	botocore/data/
$ du -d 0 -h boto3/data/
40K	boto3/data/

ではこれをLambdaから呼び出してみましょう。Lambda側のサンプルコードは以下のようにシンプルなものを採用しました。

def lambda_handler(event, context):
    import boto3
    client = boto3.client('s3')
    print(client.__class__)

ちなみに通常通り、boto3をデプロイパッケージに含めない場合には次のようになりました。

2020-07-02T13:12:45.849+09:00 START RequestId: 5b3c44ee-0979-4ff0-896e-341e2fbb94bb Version: $LATEST
2020-07-02T13:12:47.395+09:00 <class 'botocore.client.S3'>
2020-07-02T13:12:47.396+09:00 END RequestId: 5b3c44ee-0979-4ff0-896e-341e2fbb94bb
2020-07-02T13:12:47.396+09:00 REPORT RequestId: 5b3c44ee-0979-4ff0-896e-341e2fbb94bb Duration: 1547.16 ms Billed Duration: 1600 ms Memory Size: 256 MB Max Memory Used: 75 MB Init Duration: 128.65 ms

続いてデプロイパッケージに含めた場合は次の通りです。

2020-07-02T13:16:40.640+09:00 START RequestId: c8690226-eb9b-4a21-ace6-a3f72a587ef3 Version: $LATEST
2020-07-02T13:16:44.622+09:00 <class 'botocore.client.S3'>
2020-07-02T13:16:44.640+09:00 END RequestId: c8690226-eb9b-4a21-ace6-a3f72a587ef3
2020-07-02T13:16:44.640+09:00 REPORT RequestId: c8690226-eb9b-4a21-ace6-a3f72a587ef3 Duration: 4000.09 ms Billed Duration: 4100 ms Memory Size: 256 MB Max Memory Used: 76 MB Init Duration: 130.93 ms

最初のコールドスタートが1547.16 msから4000.09 msになり大幅に遅くなっていることが分かります。続いてs3が使える必要最低限までdataディレクトリを削ぎ落とした結果をみてみましょう。

2020-07-02T14:47:00.893+09:00 START RequestId: 6984f7be-2ba2-4857-81e1-96a08b0e8589 Version: $LATEST
2020-07-02T14:47:04.682+09:00 <class 'botocore.client.S3'>
2020-07-02T14:47:04.700+09:00 END RequestId: 6984f7be-2ba2-4857-81e1-96a08b0e8589
2020-07-02T14:47:04.700+09:00 REPORT RequestId: 6984f7be-2ba2-4857-81e1-96a08b0e8589 Duration: 3806.98 ms Billed Duration: 3900 ms Memory Size: 256 MB Max Memory Used: 75 MB Init Duration: 119.95 ms

ここまでの検証結果をまとめると以下のようになります。

ケース 起動にかかった時間(ms)
デプロイパッケージに何も含めない(初期) 1547.16
軽量化しないboto3をパッケージin 4000.09
軽量化したboto3をパッケージin 3806.98

4000.09msかかっていたのを3806.98msまで短縮できました…!とは言え、やはりboto3はデプロイパッケージに含めない方が圧倒的に早そうですね。この方法はよほど尖った要求がない限り使うことはないでしょう。

スケールアップ

さてお次のテーマはスケールアップについてです。次のようなCPUバウンドな処理があったとします。

def fibonacci(n):
    a, b = 1, 0
    for _ in range(100):
        a, b = b, a + b


def lambda_handler(event, context):
    for i in range(1000000):
        fibonacci(i)

このコードをLambdaで実行しどのくらいまで早く実行できるかを検証してみます。Lambdaでは調節できるリソースはメモリのみとなっています。

ですが、Lambdaはメモリの大きさによって割り当てられるCPUの大きさも肥大化していきます。なので単純にメモリの量を上げていけば処理はどんどん高速化していくはずです。この関数を実際にLambdaで実行してみた結果とその時のメモリ値は以下のようになりました。

メモリ数(MB) 最大使用メモリ(MB) 実行時間(ms)
256 50 49336.74
512 50 24109.06
1024 51 11774.09
1344 51 9091.66
1792 52 6899.40
2048 52 6938.64
2432 52 6792.72
2816 52 6879.32
3008 53 6788.53

グラフにすると次のようになります。

メモリ量が1792MBまでは良い感じに実行時間が短くなっていますが、それ以降は頭打ちになっています。これはLambdaにとって1792MBが1つのフルvCPUに相当するためと考えられます。

Lambda では、構成されているメモリの量に比例して CPU パワーが直線的に割り当てられます。1,792 MB では、関数は 1 つのフル vCPU (1 秒あたりのクレジットの 1 vCPU 秒) に相当します。

お気づきになった方もいらっしゃるかもしれないのですが、このコードはマルチプロセス(multiprocessingのPool)で実行することによって実行スピードを改善できます。次のような具合に改善できますね。

from multiprocessing import Pool


def fibonacci(n):
    a, b = 1, 0
    for _ in range(100):
        a, b = b, a + b


def lambda_handler(event, context):
    num_list = list(range(1000000))
    with Pool(processes=2) as p:
        p.map(func=fibonacci, iterable=num_list)

実際に手元の環境で比べてみたところ次のようになりました。

  • シングルプロセス
    python app.py  5.00s user 0.05s system 99% cpu 5.058 total
  • マルチプロセス
    python app.py  5.20s user 0.14s system 193% cpu 2.755 total

マルチプロセスの方がスピードが速くなっていますね。ですが残念ながらLambdaでこのマルチプロセスは実行することができません。次のようなエラーが発生します。

[ERROR] OSError: [Errno 38] Function not implemented

これはLambda内でマルチプロセスの実行権限が許可されていないためです。正確にはmultiprocessingのPoolでは/dev/shm(共有メモリ)を用いてプロセス間のデータ共有を行うのですが、LambdaではこのディレクトリへのアクセスがサポートされていないためmultiprocessingのPoolが使えないのです。なので今回のような計算を行うCPUバウンドな処理の場合、処理を複数のLambda関数で分けるとか、別サーバ(サービス)を用いる等を検討した方が良いかと思います。

まとめ

さて今回は次のような内容を発表しました。

最後に

いかがでしたでしょうか!本ブログを通してLambdaへの理解が深まったなら幸いです。

以上、深澤(@shun_quartet)でした!