リアルタイム通信 — WebSocket・gRPC ストリーミング・WebRTC
コネクションプールが枯れると速いリクエストも並ぶ
一言でいうと
コネクションプールが枯れると、サーバーは空いているのにクライアントのすべてのリクエストが列に並び、原因はたいてい、プールのサイズではなく、タイムアウトと破棄のルールです。
なぜ必要なのか
HTTPクライアント、DBドライバー、gRPCチャネルは、どれも内部にプールを持っています。接続を作るコスト、つまりTCPハンドシェイク、TLSハンドシェイク、認証を、リクエストのたびに払わないためです。プールは、普段は静かにうまく回っていて、ある日いっぺんに崩れます。そして、崩れる様子が原因から遠く離れています。サーバーのCPUは空いていて、サーバー側のレイテンシ指標も正常なのに、クライアントのリクエストだけが数秒かかったり、「プールから接続を取得できなかった」というエラーが大量に出たりします。
リトルの法則が、この現象を1行で説明します。同時に必要な接続数は、1秒あたりのリクエスト数に、リクエスト1つのレイテンシを掛けた値です。1秒あたり200個を50msで処理するなら、接続は10個で足ります。後ろのサービス1つが遅くなって、レイテンシが5秒になると、同じ負荷に接続1,000個が必要になります。プールの上限が10なら、残りは列に並びます。プールが枯渇したのは結果であり、原因はレイテンシです。
どう動くのか
プールを構成する数字は5つで、それぞれ防ぐ事故が違います。
| 設定 | 防ぐもの | ないと |
|---|---|---|
| 最大サイズ | サーバーに接続を際限なく開くこと | 障害のとき、クライアントがサーバーを叩きまくります |
| 取得待機時間 | 空きを終わりなく待つこと | 呼び出しスレッドがすべてプールの前で止まります |
| リクエストタイムアウト | 遅い呼び出しが席を無限に占有すること | 遅い呼び出し数個が、プール全体を握り込みます |
| アイドル上限 | 相手がすでに閉じた接続を再び使うこと | しばらく静かだったあと、最初のリクエストがリセットで失敗します |
| 最大寿命 | 古い接続が1台のサーバーに偏ること | サーバーを増やしても、負荷が移りません |
取得待機時間とリクエストタイムアウトは、よく混同されます。取得待機時間は、待つ側を救うだけで、席を占めている側を終わらせることはできません。5秒かかる呼び出し4つがサイズ4のプールを埋めると、取得待機時間が1.5秒の速い呼び出しは、すべて失敗します。遅い呼び出しにリクエストタイムアウトがかかっていてはじめて、席が戻ってきます。
さらに静かな落とし穴は、タイムアウトした接続をプールに戻してしまうことです。タイムアウトは、待つのをやめたという意味であり、サーバーが応答を送らないという意味ではありません。遅れた応答は、同じソケットに届き、そのソケットを次に借りたリクエストは、他人の応答を自分のものとして読みます。HTTP/1.1のように、リクエストと応答が順序だけで対応づけられるプロトコルでは、これがデータの取り違え事故になります。規則は1つです。状態がわからない接続は、破棄します。
アイドル上限は、相手のアイドル上限より短くなければなりません。サーバーとロードバランサーは、静かな接続を先に切ります。Node.jsのHTTPサーバーは、keepAliveTimeoutの既定値が5秒なので、アイドル上限がそれより長いクライアントのプールは、6秒休んだ接続を取り出して使い、接続リセットを受けます。AWSのApplication Load Balancerは、アイドル上限の既定値が60秒です。
最大寿命は、負荷分散と関係があります。プールは、一度作った接続を長く使おうとするので、サーバーを増やしても、既存の接続は元のサーバーにとどまります。接続に寿命を設けて、使い終わったら閉じて新しく作らせれば、新しい接続が新しいサーバーに向かうことで、負荷が徐々に均等に広がります。すべての接続の寿命が同じ瞬間に終わらないように、寿命にも少しランダム性を混ぜます。
現場での姿
最も一般的な場面は、外部API 1つが遅くなったら、まったく関係のないAPIまで遅くなることです。2つが同じHTTPクライアント、同じプールを使っていて、遅いほうがプールを占有しました。宛先ごとにプールを分けるか(バルクヘッド)、少なくともリクエストタイムアウトを宛先ごとに別に置くことが、処方箋です。
HTTP/2とgRPCクライアントでは、プールの形が少し違います。接続1つが複数のストリームを載せるので、「借りる」のは接続ではなくストリームで、上限は、相手が通知した同時ストリーム数(SETTINGS_MAX_CONCURRENT_STREAMS)が決めます。その上限に達すると、新しい呼び出しはクライアントの中で列に並び、これもサーバーの指標には見えません。接続1つで耐えようとして上限に阻まれるサービスは、接続をいくつか追加で開くことで解決することもあります。結局、同じ問い、つまり、何が何個まで同時に可能で、あふれたらどこで待つのかを、層ごとに改めて問うことになります。
観測もプールで行う必要があります。サーバー側の指標には列が見えないので、プールの使用中・アイドル中・待機中の接続数と、取得待機時間、取得失敗の回数を、クライアントが自分で出力する必要があります。この数字がないと、障害のときに「サーバーは正常だ」という言葉だけが飛び交います。特に、取得待機時間は、リクエストのレイテンシの中に混ざって報告されやすいので、別に切り出して測ってはじめて、遅くなったのがサーバーなのか、プールの前の列なのかを、切り分けられます。
次のラボですること
上限・待機時間・破棄のルール・メトリクス・アイドル上限を持つプールを、標準ライブラリで作ります。タイムアウトしたソケットを戻すと、次のリクエストが過去の応答を読む場面を再現し、5秒かかる呼び出し4つがプールを枯らす場面と、0.5秒のタイムアウト1つで生き返る場面を、同じ測定ツールで測ります。