RDS Proxyの負荷検証を行った件について

タム

2023.03.21

6

こんにちは、タムです。

今回は、(AWS初心者の僕が、)RDS Proxyの負荷検証を行ったので、その結果を報告させていただきます。

RDS Proxyとは何か?については多数の記事が存在するため割愛しますが、

現在自分が関わっている案件で、LambdaからRDSに接続する必要性が出てきたため、RDS Proxyを導入することを検討することになりました。

導入を検討するにあたり、RDS Proxyの挙動、特にどれくらいのアクセス数に耐えられるかが不明だったため検証を行いました。

AWS公式ドキュメントには

RDS Proxy コンピューティングリソースはサーバーレスであり、データベースのワークロードに基づいて自動的にスケーリングされます。

と記載がありますが、この一行だけを根拠に導入しても問題ないと判断するには心許なかったため、今回検証を行いました。


まずは結論から

スケールするので基本的に上限はない

時間がない人のために先に結論だけ申し上げると、結局ドキュメントに記載のとおりだった。

ただ、もう少し付け加えると、無限に多くのリクエストを受けられるわけではなく、

RDS Proxyが利用できる最大接続数が多いほど、多くのリクエストを捌くことができた。

リクエスト数が一定以上多くなるとレイテンシーが増加し、それ以上は増えないという結果になった。

また後述するが使用する言語・ライブラリ・実装によっても挙動が異なる場合があるため注意が必要で、

上記の結論はPython / sqlalchemy(NullPool)+pymysqlを使用した場合。

具体的な検証内容

検証方法

  • 上記のインフラ構成で、APIをマルチスレッドで5分間リクエストを投げ続けることで検証を行った
  • リクエストを投げ続ける処理についてはwrkというツールを使った
    • Docker化したのでローカルを汚さずにサクッと環境構築したい人はこちらどうぞ
  • Lambda関数の内容としては、DB接続を取得し、簡単なクエリを発行してから接続を閉じる、というもの
    • 言語はPython、DB接続部分のライブラリはsqlalchemyを用いた
      • Lambda環境は基本的に実行が終わったら破棄されるためLambda環境内にコネクションをプーリングするメリットはあまりないため、poolclassにはNullPoolを使用した
  • Lambda関数のソースコード:
import os
import pymysql
import mysql.connector
import time
from sqlalchemy import create_engine, text
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy.pool import NullPool

host = os.environ['RDS_HOST_NAME']
user = os.environ['USER']
password = os.environ['PASS']
db = os.environ['DB']
connect_count = os.environ['CONNECT_COUNT']
connect_timeout = os.environ['CONNECT_TIMEOUT']


def lambda_handler(event, context):
    _connect()
    return {
        'isBase64Encoded': False,
        'statusCode': 200,
        'headers': {},
        'body': '{"message": "Hello from AWS Lambda"}'
    }


def _connect():
    engine = create_engine(
        'mysql+pymysql://{user}:{password}@{host}/{dbname}'.format(user=user, password=password, host=host, dbname=db),
        echo=True,
        poolclass=NullPool,
    )

    session = scoped_session(
        sessionmaker(
            autocommit=False,
            autoflush=True,
            expire_on_commit=False,
            bind=engine
        )
    )

    res = session.execute(text('select now()'))
    for v in res:
        print(v)

    session.close()
  • 処理件数の上限は何に依存して決まるのかを確認するため、RDSのスペックを下げた場合と、最大接続数を下げた場合についても比較検証を行った

検証結果

  • RDSのインスタンスタイプ: db.t3.medium(プロキシで利用できる最大接続数: 100%, 305)
    • 1分間に最大8万リクエスト程度が限界だった
      • なお、上記はRDS(Proxy)と同じVPC上のEC2環境で実行した場合
      • ローカル環境で実行した場合はその半数の4万リクエスト程度が限界だった
  • RDSのインスタンスタイプ: db.t3.micro(プロキシで利用できる最大接続数: 100%, 61)
    • 1分間に最大6万5千リクエスト程度
  • RDSのインスタンスタイプ: db.t3.medium(プロキシで利用できる最大接続数: 20%, 61)
    • 1分間に最大6万7千リクエスト程度


EC2から実行した場合のほうがリクエスト数が多くなったのは、レイテンシーが小さかったためで、

おそらく、同じVPCにあるので通信のパケットが長い距離を移動しなくて済むためと考えられる。

なお、2つのマシンから同時に実行した場合もリクエスト数はほぼ変わらなかったため、

クライアントマシンのスペックはあまり関係なく、送れるリクエスト数の上限はネットワーク帯域的な限界で決まるのではと思った。

また、RDSのスペックもあると思うが、RDS Proxyの最大接続数によって最大リクエスト数がほぼ決まることがわかった。

その他わかったこと

  • sqlalchemyのpoolclassにNullPoolを使用せず通常のQueuePoolを使用した場合、パフォーマンスが大幅に悪化した。
    • Lambda環境は破棄されるがコネクションは紐付いた状態のままになるため、RDS Proxy側で再利用できないのが原因と考えられる

残る謎

使用するライブラリによって挙動が全く異なる

sqlalchemy(NullPool)+pymysqlを使用した場合はsession.close()してもコネクション自体は切れずにプロキシでプールされ別の接続リクエストに再利用されるが、

pymysqlを単体で使用した場合はコネクションがプールされずに毎回切れる、という全く異なる挙動を示した。

これによりpymysqlを単体で使用した場合はパフォーマンスが悪化し、リクエスト数が多くなるとレイテンシーの増加だけでなく接続失敗も発生するようになった。

問題はコネクションの部分なのでクエリのログには原因を示すようなログは特に見当たらず、

解決のためにはTCP接続の部分やソースコードなどを掘り下げていく必要があり時間がかかりそうだったため、

今回はそこまで深追いすることはやめた。

苦労した点

API Gatewayの時間制約に翻弄される

Lambdaの課金は実行回数によって決まるため、

最初なるべく実行回数を少なくしようとして1関数の中でループして500個のDB接続を取得するようにしていた。

この関数を単体で実行したときは正常に動作したが、

DBリクエスト数が2万件くらいになったあたりからレスポンスが正常に返ってこなくなった。

LambdaのエラーログにはLost ConnectionとなっていたのでRDSの設定などを見直していたが、

最終的にAPI Gatewayの時間制限(30秒)に引っかかっていることがわかった。

知っていれば回避できたと思うが、この問題の解決にかなりの時間を費やしてしまった。

RDSのサービスを使う際には予め制約を把握しておくことが重要ということが身にしみてわかった。

この記事をシェアする