コールドスタート高速化のために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 コアライブラリなど) を使用します。

AWS 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処理の所要時間
  • トータルの所要時間

を出力します。マネコンから見るとこんなイメージです

X-Rayのトレース結果

この検証用プログラムを

  • スリム化した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'

というエラーが発生して動きませんでした。。。なんて事故は絶対に避けたいですよね?

ソースコードを静的解析にかければある程度自動的に事故は防止できそうですが、正直積極的に採用したいチューニング手法ではありません。 とはいえ一定の改善効果があることは事実なので、やむにやまれぬ事情でどうしても数十ミリ秒レベルの改善を追及せざるを得ない状況の方がいれば参考にしてみてください。