コールドスタート高速化のためにboto3をスリム化して改善効果を測定してみた
Lambda実行環境にバンドルされているboto3はバージョンが古いため、新し目の機能を利用したい場合はデプロイパッケージに自前でboto3を組み込む必要があります。 しかし、boto3はパッケージサイズが大きいため、コールドスタートの速度に悪影響を及ぼします。 この記事では、デプロイパッケージに含めるboto3をスリム化することでコールドスタートのオーバーヘッドを改善する手法をご紹介します。
なお、ご紹介する手法はシステムの保守性という観点で懸念事項が残ります。また改善効果も50ミリ秒程度のものです。このレベルのチューニングが必要になるのであれば、本来はコールドスタートという概念のあるLambdaを選択すべきではありません。が、そうはいっても色々なしがらみから「アーキテクチャは見直せないけど少しでもコールドスタートを早くする必要がある」というケースがあるかもしれません。あまり推奨できるチューニング手法ではありませんが、せっかく調査したので結果をご紹介します。
環境
今回使用した環境です
- Python: 3.7
- boto3: 1.9.233
- botocore: 1.12.233
- Lambdaのメモリ割り当て: 128M
botocoreとboto3の構成について
botocoreとboto3はdata
というディレクトリ内にAWSサービス別のJSONファイルを内包しており、各サービスのClientクラス生成時や、実際のAPIリクエスト作成時にこのJSONファイルを参照しています。
ちょっと中身を覗いてみましょう
$ head -n 30 botocore/data/s3/2006-03-01/service-2.json
中身はこんな感じです。
{ "version":"2.0", "metadata":{ "apiVersion":"2006-03-01", "checksumFormat":"md5", "endpointPrefix":"s3", "globalEndpoint":"s3.amazonaws.com", "protocol":"rest-xml", "serviceAbbreviation":"Amazon S3", "serviceFullName":"Amazon Simple Storage Service", "serviceId":"S3", "signatureVersion":"s3", "uid":"s3-2006-03-01" }, "operations":{ "AbortMultipartUpload":{ "name":"AbortMultipartUpload", "http":{ "method":"DELETE", "requestUri":"/{Bucket}/{Key+}", "responseCode":204 }, "input":{"shape":"AbortMultipartUploadRequest"}, "output":{"shape":"AbortMultipartUploadOutput"}, "errors":[ {"shape":"NoSuchUpload"} ], "documentationUrl":"http://docs.amazonwebservices.com/AmazonS3/latest/API/mpUploadAbort.html", "documentation":"<p>Aborts a multipart upload.</p> <p>To verify that all parts have been removed, so you don't get charged for the part storage, you should call the List Parts operation and ensure the parts list is empty.</p>" },
こんな感じのJSONがサービスごとのディレクトリに保存されています。
AWSには数多くのサービスが存在するため、これらのJSONファイルを合算するとそれなりのファイルサイズになります。ファイルサイズを確認してみます。
$ du -d 0 -h botocore/data/ 36M botocore/data/ $ du -d 0 -h boto3/data/ 672K boto3/data/
botocoreでは36M、boto3では672Kの容量を占めています。知っての通りAWSは新サービス、新機能がどんどんリリースされるので、この調子で行くとbotocore、boto3のサイズもどんどん肥大化してしまいます。そうするとboto3を自前でパッケージングしたLambdaのコールドスタートがどんどん遅くなってしまいます。
AWSのドキュメントでは、不要なモジュールはデプロイパッケージに含めずデプロイパッケージのサイズを最小化することをベストプラクティスとして紹介しています。
デプロイパッケージのサイズをランタイムに必要な最小限のサイズにします。これにより、呼び出しに先立ってデプロイパッケージをダウンロードして解凍する所要時間が短縮されます。Java または .NET Core で作成した関数の場合は、デプロイパッケージの一環として AWS SDK ライブラリ全体をアップロードしないようにします。代わりに、SDK のコンポーネントを必要に応じて選別するモジュール (DynamoDB モジュール、Amazon S3 SDK モジュール、Lambda コアライブラリなど) を使用します。
しかし、boto3に関しては必要なモジュールだけをパッケージングするような仕組みは提供されていません。ということで、自力でdata
ディレクトリ配下のJSONファイルを削除してboto3をスリム化しようというのがこの記事の内容です。
不要なJSONファイルを削除してみる
では、実際にdata
ディレクトリ配下のJSONを削除してみましょう。
Lambdaから利用することの多いDynamoDBだけを残してdata
ディレクトリ配下をすべて削除してみます。削除後のディレクトリ構成は以下のようになります。
$ ls small_pkg/botocore/data/ _retry.json dynamodb endpoints.json $ls small_pkg/boto3/data/ dynamodb
この状態で容量を確認します。
du -d0 -h small_pkg/boto3 844K small_pkg/boto3 du -d0 -h small_pkg/botocore 5.6M small_pkg/botocore
削除前と比較しましょう
$ du -d0 -h fat_pkg/boto3 1.3M fat_pkg/boto3 $ du -d0 -h fat_pkg/botocore 41M fat_pkg/botocore
boto3が約500K、botocoreは約35Mのダイエットに成功しました!!
試しにsmall_pkg
配下でPythonのインタラクティブシェルを使用し、boto3の動作を確認します。
>>> import boto3 >>> s3 = boto3.client('s3') Traceback (most recent call last): File "<stdin>", line 1, in <module> File "/Users/xxxxxxx/small_pkg/boto3/__init__.py", line 91, in client return _get_default_session().client(*args, **kwargs) File "/Users/xxxxxxx/small_pkg/boto3/session.py", line 263, in client aws_session_token=aws_session_token, config=config) File "/Users/xxxxxxx/small_pkg/botocore/session.py", line 839, in create_client client_config=config, api_version=api_version) File "/Users/xxxxxxx/small_pkg/botocore/client.py", line 79, in create_client service_model = self._load_service_model(service_name, api_version) File "/Users/xxxxxxx/small_pkg/botocore/client.py", line 117, in _load_service_model api_version=api_version) File "/Users/xxxxxxx/small_pkg/botocore/loaders.py", line 132, in _wrapper data = func(self, *args, **kwargs) File "/Users/xxxxxxx/small_pkg/botocore/loaders.py", line 378, in load_service_model known_service_names=', '.join(sorted(known_services))) botocore.exceptions.UnknownServiceError: Unknown service: 's3'. Valid service names are: dynamodb
s3なんて知らねーよ!と怒られました。Valid service names are: dynamodb
とのことです。data
配下のディレクトリを削除したことでDynamoDBのClientしか利用できなくなっています。想定通りの動作です。
コールドスタートのオーバーヘッドを計測する
では、スリム化する前のboto3とスリム化した後のboto3でコールドスタートの所要時間を比較してみましょう。 まずはデプロイ用のLambdaを用意します。
import boto3 import os def handler(event, context): return os.environ['_X_AMZN_TRACE_ID']
コードはこれだけです。 初期化処理の中でboto3をimportしつつ、あとで分析しやすいようにX-RayのトレースIDを返却します。
このファイルをboto3および依存ライブラリと一緒にパッケージングします。 パッケージの中身は以下のような構成になります。
$ ls small_pkg __pycache__ boto3-1.9.233.dist-info docutils python_dateutil-2.8.0.dist-info six.py app.py botocore docutils-0.15.2.dist-info s3transfer urllib3 bin botocore-1.12.233.dist-info jmespath s3transfer-0.2.1.dist-info urllib3-1.25.5.dist-info boto3 dateutil jmespath-0.9.4.dist-info six-1.12.0.dist-info
このパッケージをLambda Functionとしてデプロイし、以下の検証用のプログラムを動かしてコールドスタートの所要時間を計測します。※検証用の簡易なコードなのでエラーチェック等がっつり省略しています
import boto3 import os import json import time FUNC_NAME= '<デプロイしたLambda Functionの名前>' XRAY_BATCH_SIZE = 5 xray = boto3.client('xray') lambda_clinet = boto3.client('lambda') xray_trace_ids = [] for i in range(0, 50): lambda_clinet.update_function_configuration( FunctionName=FUNC_NAME, Environment={ 'Variables': { 'DUMMY': str(i) } }) time.sleep(2) res = lambda_clinet.invoke( FunctionName=FUNC_NAME, Payload=json.dumps({}) ) ret_val = res['Payload'].read().decode('utf-8') xray_trace_id = ret_val.split(';')[0].split('=')[1] xray_trace_ids.append(xray_trace_id) time.sleep(10) batches = [xray_trace_ids[idx:idx + XRAY_BATCH_SIZE] for idx in range(0, len(xray_trace_ids), XRAY_BATCH_SIZE)] for batch in batches: res = xray.batch_get_traces(TraceIds=batch) for trace in res['Traces']: segments = ) for i in trace['Segments']] lambda_segment = [i for i in segments if i.get('origin') == 'AWS::Lambda'][0] start_time = lambda_segment['start_time'] end_time = lambda_segment['end_time'] sub_segments = [i['subsegments'] for i in segments if 'subsegments' in i] if sub_segments == []: continue initialization_seg = [i for i in sub_segments[0] if i['name'] == 'Initialization'][0] init_start_time = initialization_seg['start_time'] init_end_time = initialization_seg['end_time'] print(f'{trace["Id"]}, {round((init_start_time - start_time) * 1000)},' f'{round((init_end_time - init_start_time) * 1000)},' f'{round((end_time - start_time) * 1000)}')
冒頭の
for i in range(0, 50): lambda_clinet.update_function_configuration( FunctionName=FUNC_NAME, Environment={ 'Variables': { 'DUMMY': str(i) } })...
部分でLambda Functionの設定変更を行うことで意図的にコールドスタートを発生させ、レスポンスに含まれるX-RayのトレースIDを保存しておきます。 保存したトレースIDのリストは
batches = [xray_trace_ids[idx:idx + XRAY_BATCH_SIZE] for idx in range(0, len(xray_trace_ids), XRAY_BATCH_SIZE)]
の部分でX-Rayのbatch_get_tracesに指定可能な最大バッチサイズである5づつに分割します。あとは
for batch in batches: res = xray.batch_get_traces(TraceIds=batch) for trace in res['Traces']: ...
の部分でループを回しながらX-Rayのトレース結果を分析し
- Initialization開始までの所要時間
- Initialization処理の所要時間
- トータルの所要時間
を出力します。マネコンから見るとこんなイメージです
この検証用プログラムを
- スリム化したboto3を使って作成したLambdaFunction
- 通常のboto3を使って作成したLambdaFunction
それぞれに実行して計測を行いました。
結果
両パターン100回ほど数値を計測し、平均をとるとこのような結果になりました。
パッケージの構成 | 〜Initialization | Initialization | トータル |
---|---|---|---|
通常のboto3 | 140.3448276 | 282.5632184 | 461.2643678 |
スリム化したboto3 | 99.59302326 | 277.1511628 | 416.4883721 |
Initialization処理実家開始までのオーバーヘッドが平均で約50ms高速化しました!!パッケージサイズが35Mほど削減されているので、パッケージのDLと展開処理がその分高速になったためと考えられます。
まとめ
Lambdaのデプロイパッケージに自前でboto3を詰め込む場合、利用しないAWSサービスの定義を削除することでコールドスタートを50ms程度改善できることが分かりました。実際にこの手法を採用する場合は、導入当初は利用していなかったAWSサービスを後から追加で利用することがないか、構成管理が課題となります。
Lambda Functionをデプロイして動かしたら
botocore.exceptions.UnknownServiceError: Unknown service: 'xxxxx'
というエラーが発生して動きませんでした。。。なんて事故は絶対に避けたいですよね?
ソースコードを静的解析にかければある程度自動的に事故は防止できそうですが、正直積極的に採用したいチューニング手法ではありません。 とはいえ一定の改善効果があることは事実なので、やむにやまれぬ事情でどうしても数十ミリ秒レベルの改善を追及せざるを得ない状況の方がいれば参考にしてみてください。