「緊急開催!サーバーレス座談会 in JAWS-UG 大阪」にて「AWS SDKのClientはFactory経由で作ろう」というテーマでLTしてきました #jawsugosaka #jawsug

2023/9/26(火)に開催された「緊急開催!サーバーレス座談会 in JAWS-UG 大阪」というイベントの発表資料です
2023.09.27

CX事業本部@大阪の岩田です。

2023/9/26(火)に開催された「緊急開催!サーバーレス座談会 in JAWS-UG 大阪」というイベントで「AWS SDKのClientはFactory経由で作ろう」というテーマでLTさせて頂きました。

イベントの詳細はこちら

発表資料

資料はこちらです

発表内容

発表の内容を簡単に解説します。サンプルコードはPython & boto3からDynamoDBに書き込む例ですが、他言語のSDKや他のAWSサービスにも共通している部分がほとんどです。

AWS SDKのClientの使い方を最適化したい

LambdaからAWSのサービスに通信する場合、Lambda実行環境で生成するTCPのソケットは1つで十分なケースがほとんどです。逐次処理でDynamoDBに2回アクセスする際に、TCPのソケットを2つ生成するような実装をするとオーバーヘッドが大きくなります。この辺の話は以前ブログにしています。

例えば以下のコードは悪い例です。

import boto3


class TableA:
    def __init__(self):
        self._client = boto3.client('dynamodb')

    def put_item(self, item):
        self._client.put_item(TableName='tableA', Item=item)


class TableB:
    def __init__(self, client):
        self._client = boto3.client('dynamodb')

    def put_item(self, item):
        self._client.put_item(TableName='tableB', Item=item)


def handler(event, context):
    table_a = TableA()
    table_a.put_item({'foo': 'bar’})
    table_b = TableB()
    table_b.put_item({'hoge': 'fuga'})

従量課金モデルのLambdaにおいてパフォーマンスの悪化はコスト増にもつながる問題であり、このような実装は避けるべきです。

Init処理でClientを初期化してグローバル変数に入れる

よくある解決策としては以下のように実装を修正することです。

import boto3

client = boto3.client('dynamodb’)


class TableA:
    def __init__(self, client):
        self._client = client

    def put_item(self, item):
        self._client.put_item(TableName='tableA', Item=item)


class TableB:
    def __init__(self, client):
        self._client = client

    def put_item(self, item):
        self._client.put_item(TableName='tableB', Item=item)


def handler(event, context):
    table_a = TableA(client)
    table_a.put_item({'foo': 'bar'})
    table_b = TableB(client)
    table_b.put_item({'hoge': 'fuga'})

Init処理でClientを初期化してグローバル変数に設定し、Clientクラスが必要な処理は引数でClientクラスを受け取るようにして共通のClientクラスを利用するようコードを修正しています。これで単一のTCPソケットを利用するようになり、パフォーマンスが改善されます。

これで無事に問題が解決。めでたしめでたし。と言いたいところですが、現実世界のアプリケーションはもっと複雑で関数の呼び出し階層はもっと深くなります。呼び出し先の呼び出し先の呼び出し先...にClientクラスを伝搬させるのはとても面倒です。また、上記のサンプルコードでは省略していますが、Clientクラスを初期化する際はタイムアウト値の調整など他にも色々とやるべきことがあります。

Factoryクラスを使おう

そこで提案したいのがClient生成用のFactoryクラスを使うことです。サンプル実装は以下のようになります。

import boto3

class Boto3ClientFactory:
    # 生成したclientクラスのインスタンスをクラス変数に保持しておく
    _clients = {}

    @classmethod
    def get_singleton_client(cls, service_name, **kwargs):

        # 対象サービスのclientクラスを生成済みならクラス変数のキャッシュから返却
        # 複数リージョンを扱う場合はキャッシュキーにリージョンを含めるなど追加の考慮が必要
        if service_name in cls._clients:
            return cls._clients[service_name]

        client = boto3.client(service_name, **kwargs)
        cls._clients[service_name] = client
        return client

クラス変数にClientクラスのインスタンスをキャッシュすることで、Clientクラスの同一インスタンスを使い回すことが可能です。

※Factoryを経由せずにClientのインスタンスを作られるとシングルトンにはならないですが

このサンプルはシンプルな実装ですが、実際にはタイムアウト値の調整やフック処理の登録などもう少し色々と設定を調整することになると思います。

Lambdaの実装ではこのFactoryクラスを利用してClientクラスのインスタンスを生成するようにすれば

  • シンプルな記述で適切に設定されたClientクラスのインスタンスが取得できる
  • 呼び出し階層の深いところまで引数でClientクラスを引き回さなくても同一のインスタンスが取得できる

といったメリットが享受できます

補足

Provisioned Concurrencyを利用する場合はInit処理の中でClientクラスの生成を空打ちしておくと良いでしょう。boto3...というよりbotocoreの実装に依存した話ですが、最初にClientクラスのインスタンスを作る際はJSONファイルのロードやクラスを動的に定義する処理が入るため、どうしても処理コストが高くなってしまうためです。2回目以後のインスタンス生成についてはキャッシュが効くため高速化されます。※もっともFactoryクラスを使う場合はインスタンス生成が2回以上動くことは無いかもしれませんが

まとめ

最近は関西でもオフラインイベントが開催が増えてきたので、今後も機会を見つけてイベント参加や登壇していきたいです。

運営に関わられた皆さまありがとうございました&お疲れ様でした