記事のサマリー(TL;DR)
- SmartHR 最大の Rails アプリが月1回以上の PostgreSQL 接続枯渇障害に見舞われ、緊急対応チームを組成
- コネクションプーラーに PgBouncer ではなく PgCat を採用し、Compute Engine + MIG + 内部ロードバランサーの構成でセルフホスト
- 本番導入後、DBコネクション数が導入前の1/4以下に安定し、メモリ使用量は13%改善。デプロイ時の障害も収束
Cloud Run × PostgreSQL 構成を採用する国内 SaaS が直面しやすいコネクション枯渇問題
SmartHR の事例は Cloud Run と PostgreSQL(Cloud SQL)を組み合わせる構成特有のスケーリング課題を正面から取り上げています。Cloud Run はリクエスト数に応じてコンテナをオートスケールしますが、各コンテナが独立してDBコネクションを確立するため、コンテナ数の増加がそのままコネクション数の増加につながります。PostgreSQL は最大接続数に達すると新規接続をすべて拒否する仕様のため、一時的なトラフィックスパイクだけで本番障害が発生しうる構造的リスクです。
同様の構成は kintone の外部連携バックエンドや Salesforce 連携 Rails アプリ、あるいは Shopify Plus のカスタムバックエンドで Cloud Run を採用している事業者にも該当します。コネクションプーラーの導入を後回しにしていて「最大接続数エラーが月に何度か出ている」という状況であれば、本記事の選定プロセスと運用設計はそのまま参照できます。
特にトランザクションモードの制約(advisory lock・カーソル・プリペアドステートメント・セッション変数が利用不可)と接続先の使い分けは、Rails の db:migrate や statement_timeout を使うほぼすべての Rails アプリで設計上の検討が必要になるポイントです。
詳細
背景:月1回以上のコネクション枯渇障害
SmartHR の「基本機能」は同社最大の Rails アプリケーションで、Google Cloud の Cloud Run 上で稼働しています。リクエスト数の増加に応じてコンテナがオートスケールし、それに比例してデータベース(PostgreSQL)への接続数が増加する構造を持っています。
サービス成長に伴いトラフィックが増えると、スケールアウトしたコンテナがそれぞれDBコネクションを確保するため、最大接続数に達してエラーになる事象が月1回程度発生していました。その後、複数の処理で同時多発的にエラーが発生するようになり、サービスにアクセスしづらくなる障害が頻発。デプロイ時のトラフィック切り替えのタイミングでもコンテナ数の増加によってコネクション数が急増し、アラートやエラーが出て安心してデプロイできない状態になっていました。
障害の頻度が急増したため、緊急対応チームを組成して対応に当たりました。
原因分析:大半は idle コネクション
メトリクスを確認したところ、DBコネクションの大半はクエリを処理していない idle コネクションであることが判明しました。アプリケーションがコネクションプールを保持したまま接続を張り続けているため、実際にはほとんど使われていないコネクションが最大接続数を圧迫していたのです。
そこで、コネクションプーラーにDBコネクションを集約させる方針を決定しました。アプリケーションはコネクションプーラーに接続し、プーラー側がDBへの少数のコネクションを使い回すことで、DB側のコネクション数を抑制します。また、常時接続するコネクションが減ることでDBのメモリ使用量削減も期待されました。
コネクションプーラーの選定:PgBouncer ではなく PgCat を採用した理由
導入当時、Google Cloud のマネージドコネクションプーラーはまだリリースされていなかったため、セルフホストを前提に選定を行いました。
定番の PgBouncer ではなく、PgCat を選んだ主な理由は以下のとおりです。
- PgBouncer と高い互換性を持ちつつ、Instacart 社の本番事例でピーク時に約 105,000 QPS(クエリ/秒) を PgBouncer と遜色ないレイテンシで処理したと報告されている
- レプリカのフェイルオーバー、シャーディング、クエリのミラーリングをサポートしている
- 特に クエリミラーリングは、当時検討していた AlloyDB(Google Cloud 提供の PostgreSQL 互換マネージドDB)への移行時に新旧DBへ同じクエリを流して結果を比較検証できる点で魅力的だった
注意事項: PgCat は現在開発が停止している模様です。新規導入を検討する場合は状況を確認してください。また、SmartHR の「基本機能」はすでに PgCat から Google Cloud のマネージドコネクションプーラーへ移行しています(詳細は後日公開予定)。
アーキテクチャ設計
PgCat は Compute Engine 上のマネージドインスタンスグループ(MIG) として稼働させ、前段に内部ネットワークロードバランサーを配置しました。アプリケーション(Rails と Sidekiq)はロードバランサー経由で PgCat に接続します。
「基本機能」ではリードレプリカへの接続を明示的に使い分ける実装になっていたため、Primary と Replica それぞれにロードバランサーを立てる構成を採用しています。PgCat の「Load balancing of read queries」機能を使えば一つのエンドポイントで Primary と Replica を扱えますが、検証の余裕がなかったため、既存実装に手を入れずに済むシンプルな構成を優先しました。
なお、マイグレーションやデータパッチなど一部の処理は後述のトランザクションモード制約のため、PgCat を経由せず Cloud SQL に直接接続しています。
実行基盤に Compute Engine を選んだ理由
PgCat は PostgreSQL の TCP 接続を待ち受ける必要があります。App Engine・Cloud Run・Cloud Functions は HTTP ベースの PaaS であり、独立した TCP サーバーを公開する用途には向きません(Cloud Run のサイドカーとして動かすワークアラウンドもありますが、プールがコンテナ毎に分かれてしまい目的に合いません)。
残る選択肢は GKE と Compute Engine でした。「基本機能」は Cloud Run / App Engine ベースの環境で稼働しているため、GKE を選ぶとクラスタ構築・ネットワーク設計・権限整理など追加作業が多く、短期完了という目標に間に合わないと判断し、インフラ構築が比較的短期間で済む Compute Engine を選択しました。
OS には Container-Optimized OS を採用。PgCat はコンテナイメージで配布されているため必要十分な環境でしたが、Container-Optimized OS は Ops Agent のインストールに対応しておらず、Prometheus 形式のメトリクスを Cloud Monitoring へ直接送信できない問題が発生。社内の OpenTelemetry 有識者のサポートにより、OpenTelemetry Collector ベースのエージェントを別途用意することで解決しました。
Blue / Green デプロイによる安全な更新
PgCat 自体の更新には Blue / Green Deployment を採用しました。PgCat が中継するクエリにはバッチ処理の長時間トランザクションも含まれるため、更新のたびにコネクションが強制切断されるとアプリケーションへの影響が大きいためです。
Compute Engine の標準機能(シャットダウンスクリプト・ロードバランサーのコネクションドレイニング・MIG のカナリアアップデート)を検討しましたが、それぞれ以下の理由で要件を満たしませんでした。
| 機能 | 問題点 |
|---|---|
| シャットダウンスクリプト | 実行時間の上限が約90秒で長時間トランザクションの完了を待てない |
| コネクションドレイニング | アプリケーション側がコネクションをプールして張り続けるため接続が自然に減らず、最終的にタイムアウトで強制切断される |
| MIG カナリアアップデート | ランダムなインスタンスを入れ替える仕組みのため、切断されないことを保証できない |
そこで、active と standby の2つのインスタンスグループをあらかじめ用意し、ロードバランサーの向き先を切り替える方式を採用。切り替え後も古い系統をコネクションが自然に完了するまで残しておけるため、切断なしで更新を適用できます。
トランザクションプーリングの制約と接続先の使い分け
PgCat には2つの動作モードがあります。
- セッションモード:アプリケーションから直接繋ぐのとほぼ同じ挙動。切断するまで同じコネクションを占有する
- トランザクションモード:トランザクション完了のたびにコネクションをプールに返して使い回す
コネクションを効率的に使い回すためトランザクションモードを採用しましたが、セッションに依存する機能が使えなくなるという大きなトレードオフがあります。具体的には以下の制約が生じます。
- advisory lock:Rails の
db:migrateが並列実行防止のためセッションレベルの advisory lock を使うが、獲得と解放のセッションが別になりエラーになる可能性がある - カーソル機能:クエリ結果をDB側に保持して少しずつ取り出すカーソルがセッションをまたぐと参照できない
- プリペアドステートメント:セッション内でサーバーサイドオブジェクトを使い回す仕組みのため利用不可
- SET したセッション変数:セッション内で SET した値が次のクエリで引き継がれる保証がない。
statement_timeoutなどが該当
この制約を踏まえ、アプリケーションにはPgCat 経由の接続とCloud SQL への直接接続の2つを用意。制御できないユーザートラフィックは必ず PgCat を経由させ、マイグレーションやデータパッチなどセッション依存の処理だけ Cloud SQL に直接接続する設計にしています。また、statement_timeout などセッション変数に依存していた設定値はロールレベルの設定に移行し、同等の効果を維持しました。
負荷試験による事前検証
本番導入前に staging 環境で負荷試験を実施しました。目的は性能測定ではなく、「DBコネクション数がボトルネックになる問題が解消されるか」の確認です。
実際のリクエストログ分析に基づいた以下の2シナリオを検証しました。
- 漸進的な負荷:RPS(リクエスト/秒)を増やし続け、導入前に接続数の上限でエラーになっていた RPS を超えてもリクエストを処理できるか
- 瞬間的な負荷:重いリクエストの多発で Cloud Run のコンテナが一斉にスケールアウトしても、コネクションを確保しすぎてエラーにならないか
期待どおりの挙動を確認した後、本番への導入を実施しました。
導入後のメトリクス変化
本番環境へは様子を見ながら段階的にトラフィックを切り替える形でリリースしました。結果として以下の改善が確認されました。
- DBコネクション数:導入前の1/4以下で安定するようになった
- DBメモリ使用量:13% 改善(idle コネクション削減による効果)
- デプロイ時の安定性:コンテナが一斉に切り替わっても、DB側のコネクション数は安定を維持
- 数日間のモニタリングを経て、コネクション数起因の障害は収束と判断
まとめと今後の教訓
今回の反省として担当エンジニアは「DBが単一障害点であると把握していながら、障害頻度がいつ上がるか見当がつかないまま対応を先延ばしにしていた」点を挙げています。問題が顕在化してから対応するのはプレッシャーが大きく、焦りからミスも増えやすくなります。
この障害をきっかけに SLO(Service Level Objective)の導入が進み、サービス信頼性への考え方を大きく見直すきっかけになったとのことです。