パフォーマンス問題との戦い(2) 〜DBコネクション問題〜
はじめに
こんにちは!株式会社ブロードエッジ・ウェアリンク CTOの高丸です。
今回は、Qiita Advent Calendar 2024の17日目の記事です。
15日目の記事では、リプレイスプロジェクトの第2弾として、診断・カルテ機能のリリースについてお伝えしました。
今回は、リリース時に直面したバックエンドAPIでのDBコネクション問題について、その原因の特定から解決策の実装まで、具体的にお話ししていきたいと思います。
バックエンドAPIで起きたパフォーマンス問題 その2
16日目の記事では、主にN+1問題によるパフォーマンスの低下について説明しましたが、実は裏でもう一つ重要な問題が発生していました。それは、DBのコネクション数がAuroraの上限を超えてしまうという事象でした。
この問題は複数の要因が組み合わさって発生していました。
一つ目は、FastAPIのORMであるSQL Alchemyの仕様に起因するものです。
SQL Alchemyはデフォルトでコネクションプールを生成し、一定数のコネクションを確保します。これ自体は、コネクションの確立にかかるコストを削減するための正しい仕組みなのですが、
弊社の実装において、コネクションを適切にプールに返却する処理が不十分だったことで、未使用のコネクションがプールに戻されない状況が発生していました。
二つ目は、App Runnerのオートスケール機能に関連していました。
App Runnerがスケールアウトすると、新しいFastAPIプロセスが起動します。この時、SQL Alchemyは新たにコネクションプールを作成するのですが、スケールアウトが進むにつれて、これらのプールが蓄積されていき、最終的にAuroraの接続上限に到達してしまうという事態を引き起こしていたのです。
このような状況を改善するため、我々は以下の対応を行いました:
- アプリケーションコード内での未使用コネクションの適切な解放処理の実装
- SQL Alchemyのコネクションプール設定の最適化
- App Runner インスタンスあたりの最大コネクション数の調整
これらの修正により、コネクション数の急激な増加を防ぎ、より安定したシステム運用が可能になりました。
ちなみに、他にもコネクションが増える原因としては、実際のロジックが重かったり、特にSQLの発行のしすぎ(例: N+1問題)で足りなくなるケースがありますが、16日目の記事の対応でこちらは解決されていました。
反省点
この問題を振り返ると、最も大きな反省点は、リリーススケジュールが押していたことを理由に、本番相当の負荷をかけるテストを省略してしまったことでした。
実装されているロジックは、一般的なリクエスト数であれば問題ないと考えていたのですが、普段あまり意識することのないコネクション周りに不具合を仕込んでしまっていたのです。
この経験を踏まえ、即座に対策を講じました。
まずは原始的ではありますが、チーム全員が同時にシステムをチェックするという方法を実践することにしました。
この取り組みにより、STG環境で度々パフォーマンスの低下が確認できるようになり、リリース判定の重要な指標として活用できるようになりました。
また、将来的に本番相当のデータが入った環境を用意し、負荷試験ツールを使用して一定のリクエストパターンを送信するなど、より本格的なパフォーマンステストの導入を検討しています。
保険的対応
コネクション数の問題に対して、Aurora(RDS)のインスタンスタイプをスケールアップするという選択肢もありました。
Auroraの最大コネクション数はインスタンスタイプ(Memory)に依存しているため、より大きなインスタンスタイプを選択することで、一時的な解決は可能です。
しかし、これは開発者としてコネクション管理への意識が欠如したまま問題を先送りにするだけであり、望ましい解決策とは言えないと判断しました。
そこで我々は、App Runnerの柔軟なオートスケール特性を活かしつつ、コネクション管理を適切に行うため、RDS Proxyの導入を決定しました。
RDS Proxyは、データベースコネクションをプーリングし、効率的に管理する仕組みを提供しており、特にオートスケールできるサーバレスアーキテクチャ環境での使用が推奨されています。
プロキシによってコネクションが作られない事象は解消されますが、アプリケーション側で余計なコネクションを作成したり、プールへの返却処理が適切に行われていない場合には、同様のエラーが発生してしまう可能性があるため、RDS Proxyはあくまでも保険的な対応ではあります。
さらに、負荷分散の観点から、AuroraのReaderインスタンスとWriterインスタンスへのアクセスを分離する対応も実施しました。
SQL Alchemyのcreate_engine
で複数のDBインスタンスに対して適切にコネクションを振り分けることが可能です。
この実装により、特定のインスタンスへの負荷集中を防ぐことができました。
# ReaderとWriterで分けた例
writer_engine = create_engine(
WRITER_DATABASE_URL,
pool_size=5,
max_overflow=10
)
reader_engine = create_engine(
READER_DATABASE_URL,
pool_size=5,
max_overflow=10
)
さいごに
今回のパフォーマンス問題への対応を通じて、改めてインフラストラクチャとアプリケーションの両面からの対策の重要性を実感しました。
また、モダンなアーキテクチャを採用する際には、その特性を十分に理解し、適切な対策を講じることが重要です。
Discussion