Lambda PythonでSQLAlchemyを使ってWarm Start時だけでもConnection Poolingする

記事タイトルとURLをコピーする

こんにちは。先日、サーバーレスおじさん担当を拝命したわけですが、ちょっと久しぶりにMySQLの話ばっかりしている状況が楽しい照井@さっぽろです。RDSのIAM認証が来て、LambdaからRDSを使いやすくなったので、しょうがないということにしておきましょう。

さて、IAM認証の登場によってオンライン処理でLambdaからRDSを使う際の一番の課題であった、セキュリティと遅延のトレードオフである「VPC Lambda 10秒の壁」(参考)が一定の解決をみたわけですが、依然としてLambdaからRDSを使う際の課題は残っています。

今回の課題

それは、RDSへのコネクション数の増大と接続遅延です。コネクション数の増大はトラフィック量によっては問題になりませんが、新しいコネクションを繋ぐ際の接続遅延はできるかぎり避けたい所です。

前提知識

Lambdaの実体はコンテナです。特定の言語ランタイムとFunctionのソースコードが組み込まれたコンテナが各実行毎に起動され、その中で対象の言語ランタイムのプロセスが稼働するのが基本です。ただし、毎回新規にコンテナを起動すると起動時のオーバヘッドがあるので、Functionに更新が無い場合はある程度の期間一度起動したコンテナ(とプロセス)が保持されて再利用されるようになっています(5分〜10分程度、ただし期間の保証は無し)

ここでは、その再利用されたコンテナの上でFunctionが動作することをWarm Start、起動時に新規にコンテナが立ち上げられてその上で動作することをCold Startと呼んでいます(海外のServerless界隈でもわりとそのように呼ばれているようです)

このように、Lambdaコンテナはリクエスト数に比例して増えていき、コンテナ間でRDBへのコネクションを使いまわすことができない上、すぐに破棄されることから、コネクション数の増大と接続遅延の問題が出てきます。ただ、PHPなどの言語ではConnection Poolは使用できず基本的に都度接続ですし、それでも大規模なトラフィックは捌いているケースはあるので一概にConnection Poolが使えないとダメかというとそうではないということは念頭に置いておく必要があるかと思います。

対策

今回はこのLambdaコンテナが再利用された(Warm Start)時だけでもコネクションプールを使うことで少しでもコネクション数を削減し、接続遅延を軽減する方法をご紹介します。対象は言語はPythonです。

事前準備

今回もRDSへの接続はIAM認証で行います。手順はコチラをご確認ください

一点だけ付け加えると、今回はConnection Poolingを行うためにSQLAlchemyというライブラリを使用しますので、デプロイ時にコチラを含める必要があります。Poolingする接続用のライブラリは同じく mysql-connector-python になります。

ソースコード

今回の内容はコードの書き方が全てです。まずはこちらのコードを御覧ください。

import os
import boto3
import mysql.connector
from sqlalchemy import create_engine

RDS_HOST = 'pool.xxxxxxxxxxx.ap-northeast-1.rds.amazonaws.com'
RDS_USER = 'takagisan'
RDS_REGION = 'ap-northeast-1'

rds = boto3.client('rds')

def get_connection():
    password = rds.generate_db_auth_token(
        DBHostname=RDS_HOST,
        Port=3306,
        DBUsername=RDS_USER
    )

    return mysql.connector.connect(
        user=RDS_USER,
        password=password,
        host=RDS_HOST,
        database='mysql',
        charset='utf8',
        ssl_verify_cert=True,
        ssl_ca='{}/rds-combined-ca-bundle.pem'.format(os.environ['LAMBDA_TASK_ROOT']),
        buffered=True
    )


engine = create_engine('mysql+mysqlconnector://', creator=get_connection, pool_recycle=60)


def lambda_handler(event, context):
    conn = engine.connect()
    result = conn.execute('SELECT CONNECTION_ID()')
    for row in result:
        print(row)
    conn.close()

ポイント

今回の一番重要な点はコチラになります。

engine = create_engine('mysql+mysqlconnector://', creator=get_connection, pool_recycle=60)

create_engine はSQLAlchemyのエントリーポイントとなるものです。ここから取得した Engine オブジェクトから各ライブラリ毎のAPI差異を吸収したコネクションを取得し、トランザクション制御やデータベースへのクエリを実行することができます。詳しくはこちらをご確認ください

ここでポイントとなるのは creator=get_connection でコネクションを取得するメソッドを指定することです。mysql+mysqlconnector:// の接続文字列部分にHost,ID,Password等を入れることで接続することも可能ですが、今回はIAM認証を使っているため、動的に生成した署名をPasswordとして利用することになるので都度生成が必要となります。これをやらないと再接続時や初回接続にタイムラグがあった場合にエラーとなります。

また、本件と直接的な関係はありませんが、 pool_recycle=60 も重要です。MySQLでは一定時間何の操作もないコネクションを自動で切断する設定が存在します。 wait_timeout がその設定で、デフォルトは28800なので、大抵はコンテナの破棄が先になるでしょうが、wait_timeout を短く変更する場合は必ず設定してください。それ以外でも、定期的に使われ続ければ8時間(28800秒)以上生き続けることもありえなくはないので、wait_timeout 未満の値を常に設定しておいたほうが安全です。

実行と解説

それでは、実行してみましょう。

(.venv) marcy@Marcy-MBA:~/github/iam-db-auth-py$ lamvery invoke {}
START RequestId: fad9e898-2bb1-11e7-880f-81f7b5602c03 Version: $LATEST
Connection ID: 355
END RequestId: fad9e898-2bb1-11e7-880f-81f7b5602c03
REPORT RequestId: fad9e898-2bb1-11e7-880f-81f7b5602c03  Duration: 332.39 ms Billed Duration: 400 ms     Memory Size: 128 MB Max Memory Used: 39 MB  

(.venv) marcy@Marcy-MBA:~/github/iam-db-auth-py$ lamvery invoke {}
START RequestId: fd222b0f-2bb1-11e7-946d-ddea02646d65 Version: $LATEST
Connection ID: 355
END RequestId: fd222b0f-2bb1-11e7-946d-ddea02646d65
REPORT RequestId: fd222b0f-2bb1-11e7-946d-ddea02646d65  Duration: 11.84 ms  Billed Duration: 100 ms     Memory Size: 128 MB Max Memory Used: 39 MB  

(.venv) marcy@Marcy-MBA:~/github/iam-db-auth-py$ lamvery invoke {}
START RequestId: fea82f3f-2bb1-11e7-bddb-d71de33abb29 Version: $LATEST
Connection ID: 355
END RequestId: fea82f3f-2bb1-11e7-bddb-d71de33abb29
REPORT RequestId: fea82f3f-2bb1-11e7-bddb-d71de33abb29  Duration: 12.06 ms  Billed Duration: 100 ms     Memory Size: 128 MB Max Memory Used: 39 MB  

(.venv) marcy@Marcy-MBA:~/github/iam-db-auth-py$ lamvery invoke {}
START RequestId: ffdd2cb9-2bb1-11e7-a79f-f1fd5393405a Version: $LATEST
Connection ID: 355
END RequestId: ffdd2cb9-2bb1-11e7-a79f-f1fd5393405a
REPORT RequestId: ffdd2cb9-2bb1-11e7-a79f-f1fd5393405a  Duration: 11.94 ms  Billed Duration: 100 ms     Memory Size: 128 MB Max Memory Used: 39 MB  

(.venv) marcy@Marcy-MBA:~/github/iam-db-auth-py$ lamvery invoke {}
START RequestId: 00c62cf2-2bb2-11e7-a419-b3f31a16eb6b Version: $LATEST
Connection ID: 355
END RequestId: 00c62cf2-2bb2-11e7-a419-b3f31a16eb6b
REPORT RequestId: 00c62cf2-2bb2-11e7-a419-b3f31a16eb6b  Duration: 14.43 ms  Billed Duration: 100 ms     Memory Size: 128 MB Max Memory Used: 39 MB  

(.venv) marcy@Marcy-MBA:~/github/iam-db-auth-py$ lamvery invoke {}
START RequestId: 01d0bf3e-2bb2-11e7-8009-b9f1ce52ac5c Version: $LATEST
Connection ID: 356
END RequestId: 01d0bf3e-2bb2-11e7-8009-b9f1ce52ac5c
REPORT RequestId: 01d0bf3e-2bb2-11e7-8009-b9f1ce52ac5c  Duration: 225.83 ms Billed Duration: 300 ms     Memory Size: 128 MB Max Memory Used: 39 MB

かなり見づらいかと思いますが、 Connection ID: の部分に着目すると、最初の4つは同じ 355 で、最後の5つ目で 356 に変わっていることが分かるかと思います。これは、 最後の実行は60秒経過後に行ったため pool_recycle=60 の記述によって新しいコネクションが利用されており、それまでは同じコネクションが使いまわされているということです。

また、 Duration: の部分に着目しましょう。初回はコンテナ起動とコネクションオープンの両方の処理が行われているため一番時間がかかっています(332ms)。2〜4回目は10ms台で安定して高速です。コンテナは再利用されているものの、コネクションは再接続となっている最後の実行は225msで2〜4回目と比べると200msほど遅延が発生しています。今回の検証は一番小さい128MBメモリで行っているため顕著に出ている面はあるかと思いますが、コネクション生成のオーバヘッドは中々バカになりませんね。

最後に

IAM認証によって、LambdaからRDSへの接続が現実的なユースケースとして見えてきましたので、今回はより実戦で使いやすくしていくためのアプローチを実証してみました。

Lambdaであっても、Warm Start時だけならConnection Poolingは利用できます。トランザクションをできるだけ細かくし、まめにコネクションを開放することで同時接続数を減らすアプローチもありますが、コネクションはクライアントで開放されてからサーバ側で削除されるまでタイムラグがありますし、接続遅延の問題もあります。

Lambdaであっても、リソースはできるだけ再利用して節約するということを考えてみるのも、Serverlessと上手く付き合うために必要なことなのかもしれませんね。