リアルタイム通信 — WebSocket・gRPC ストリーミング・WebRTC
コネクションプールを作って枯渇させてみる
目標
上限・待機時間・破棄のルール・アイドル上限を備えたクライアントのコネクションプールを自分で作り、リクエストタイムアウトのない遅い呼び出しがプールを枯らす場面と、生き返る場面を、同じ測定ツールで測ります。
なぜ重要なのか
HTTPクライアント、DBドライバー、gRPCチャネルは、どれも内部にプールを持っています。プールが枯れると、サーバーは空いているのにクライアントのすべてのリクエストが列に並び、サーバーの指標だけを見る人には、何も見えません。原因は、たいていプールの設定ではなく、プールの外側のルールです。リクエストタイムアウトがない、タイムアウトした接続を戻す、相手がすでに閉じた接続を再び使う、などです。このラボは、その3つを1つずつ再現して防ぎます。標準ライブラリだけを使います。
ステップ
- 上限のあるプール: /root/rt/pool/pool.pyにPoolTimeout例外とPoolクラスを作ってください。Pool(factory, max_size, acquire_timeout, idle_timeout=None)は、引数なしで呼ぶと接続を1つ作ってくれるfactoryを受け取ります。acquire()は、アイドル中の接続があればそれを、なくて、これまでに作った接続がmax_sizeより少なければ、factoryで新しく作って返します。release(conn)は、接続をアイドル中のリストに戻します。max_sizeが1以上のintでない、またはacquire_timeoutが正でなければValueErrorで、boolは数として受け付けません。
- 空きがなければ決まった時間だけ待つ: acquire()を直して、接続がすべて使われていて、これ以上作れないときは、acquire_timeout秒まで待つようにしてください。そのあいだに誰かがreleaseしたら、待っていた側がすぐにその接続を受け取り、時間が切れたらPoolTimeoutを出します。待つあいだ、CPUを使って回り続けないでください。
- 状態がわからない接続は戻さない: Poolにrelease(conn, broken=False)のbrokenを追加し、connection()コンテキストマネージャーを追加してください。broken=Trueなら、接続のclose()を呼び出してプールから外し、次のacquireが新しい接続を作れるようにします。with pool.connection() as conn:ブロックが正常に終わったら接続を戻し、例外で終わったらbrokenとして処理したあと、その例外をそのまま再び発生させます。
- プールの状態を数字で出す: Poolにstats()を追加してください。size(いま生きている接続数)、idle(アイドル中の接続数)、in_use(貸し出し中の接続数)、waiting(いまacquireで待っている呼び出し数)、timeouts(これまでにPoolTimeoutを出した回数)の5つのキーのdictを返します。
- タイムアウトした接続には過去の応答が残っている: /root/rt/pool/pool.pyにget(pool, path, timeout)を追加してください。poolから接続(ソケット)を借りてsettimeout(timeout)をかけ、/opt/fixtures/rt/rtnet.pyのh1_get(sock, path)でHTTP/1.1のGETを送り、(ステータス, 本文)を返します。タイムアウトを含めて、どんな例外が出ても、その接続はbrokenとして破棄し、例外をもう一度出します。採点ツールは、max_sizeが1のプールで遅いリクエストをタイムアウトさせたあと、すぐに次のリクエストを送ります。
- 遅い呼び出し4つがプール全体を握る: /opt/rt-lab/bin/python /opt/fixtures/rt/pool/starve.pyを実行してください。サイズ4の自分のプールで、5秒かかるリクエスト4つを先に送ってから、速いリクエスト20個を送ります。リクエストタイムアウトがないときと0.5秒のときで、速いリクエストがいくつ成功したかが出ます。出力のstarved_okとbounded_okの2行を、/root/rt/pool/report.txtに書いてください。採点ツールがもう一度測って照合します。
- サーバーが先に閉じた接続を再び使わない: Poolのidle_timeoutを実装してください。releaseで戻ってきた時刻を記録しておき、acquireがアイドル中の接続を取り出すとき、休んだ時間がidle_timeout秒を超えたものはclose()して捨ててから、次のものを見ます。Noneなら捨てません。採点ツールは、1秒のあいだ静かな接続を切る中継器の後ろにサーバーを置き、idle_timeoutが0.5のプールで、リクエスト→1.5秒の休止→リクエストを送ります。
参考
- 作業フォルダは/root/rt/poolです。mkdir -p /root/rt/poolで、先に作ってください。
- 測定ツールは/opt/rt-lab/bin/python /opt/fixtures/rt/pool/starve.pyで、自分のPoolとgetを呼び出して使います。サーバーは、測定ツールが自分で起動します。
- 採点ツールは、複数のスレッドでacquireを同時に呼び出します。すべての状態変更は、ロックの中で行ってください。
- よくあるミスが2つあります。タイムアウトしたソケットをプールに戻して、次のリクエストが過去の応答を読むことと、waitから起きたあとで条件をもう一度見ずに、他人の接続を横取りすることです。
- Pythonは、必ず/opt/rt-lab/bin/pythonで実行します。このラボのライブラリは、その仮想環境にだけ入っていて、普通のpython3で実行すると、ModuleNotFoundErrorが出ます。alias rpy=/opt/rt-lab/bin/pythonのように短くしておくと便利です。
- ラボのPodは、外へ出る接続が塞がれています。すべての通信は、同じPodの中の127.0.0.1で行われ、インストールやダウンロードは必要ありません。
- 採点ツールは、コードを別プロセスで読み込んで、実際に接続を張ってみます。例のファイルは関数の枠にすぎないので、そのままでは合格しません。前のステップで完成させた関数は、消さないでください。
- ラボのセッションが終わると、/rootのファイルは残りません。必要なコードは、終える前に別に保管してください。
上限のあるプール
/root/rt/pool/pool.pyにPoolTimeout例外とPoolクラスを作ってください。Pool(factory, max_size, acquire_timeout, idle_timeout=None)は、引数なしで呼ぶと接続を1つ作ってくれるfactoryを受け取ります。acquire()は、アイドル中の接続があればそれを、なくて、これまでに作った接続がmax_sizeより少なければ、factoryで新しく作って返します。release(conn)は、接続をアイドル中のリストに戻します。max_sizeが1以上のintでない、またはacquire_timeoutが正でなければValueErrorで、boolは数として受け付けません。
アイドル中の接続を先に使うことが、プールの存在理由です。接続を作るコスト(TCPハンドシェイク、TLS)を、リクエストのたびに払わないためです。factoryが何回呼ばれたかを、採点ツールが数えます。
空きがなければ決まった時間だけ待つ
acquire()を直して、接続がすべて使われていて、これ以上作れないときは、acquire_timeout秒まで待つようにしてください。そのあいだに誰かがreleaseしたら、待っていた側がすぐにその接続を受け取り、時間が切れたらPoolTimeoutを出します。待つあいだ、CPUを使って回り続けないでください。
threading.Conditionのwait(timeout)は、起きた理由を教えてくれません。起きるたびに条件を見直し、残り時間を計算し直す必要があります。締め切りの時刻をtime.monotonic()で一度決めておくと、計算が単純になります。
状態がわからない接続は戻さない
Poolにrelease(conn, broken=False)のbrokenを追加し、connection()コンテキストマネージャーを追加してください。broken=Trueなら、接続のclose()を呼び出してプールから外し、次のacquireが新しい接続を作れるようにします。with pool.connection() as conn:ブロックが正常に終わったら接続を戻し、例外で終わったらbrokenとして処理したあと、その例外をそのまま再び発生させます。
例外が出た瞬間、その接続に何が残っているのかはわかりません。応答の後ろの部分が、まだソケットに残っているかもしれません。わからないものは捨てるのが、プールの基本ルールです。contextlib.contextmanagerを使うと、短く書けます。
プールの状態を数字で出す
Poolにstats()を追加してください。size(いま生きている接続数)、idle(アイドル中の接続数)、in_use(貸し出し中の接続数)、waiting(いまacquireで待っている呼び出し数)、timeouts(これまでにPoolTimeoutを出した回数)の5つのキーのdictを返します。
プールの枯渇は、サーバーの指標に見えません。サーバーは空いていて、クライアントだけが列に並んでいるからです。waitingとtimeoutsが、その列を見せてくれる唯一の数字です。待ち始めるときに増やし、どんな理由でも待ちが終わるときに減らしてください。
タイムアウトした接続には過去の応答が残っている
/root/rt/pool/pool.pyにget(pool, path, timeout)を追加してください。poolから接続(ソケット)を借りてsettimeout(timeout)をかけ、/opt/fixtures/rt/rtnet.pyのh1_get(sock, path)でHTTP/1.1のGETを送り、(ステータス, 本文)を返します。タイムアウトを含めて、どんな例外が出ても、その接続はbrokenとして破棄し、例外をもう一度出します。採点ツールは、max_sizeが1のプールで遅いリクエストをタイムアウトさせたあと、すぐに次のリクエストを送ります。
タイムアウトは、待つのをやめたという意味であり、サーバーが応答を送らないという意味ではありません。その応答は、遅れても同じソケットに届きます。そのソケットをプールに戻すと、次のリクエストが他人の応答を読みます。sys.pathに/opt/fixtures/rtを入れると、rtnetをインポートして使えます。
遅い呼び出し4つがプール全体を握る
/opt/rt-lab/bin/python /opt/fixtures/rt/pool/starve.pyを実行してください。サイズ4の自分のプールで、5秒かかるリクエスト4つを先に送ってから、速いリクエスト20個を送ります。リクエストタイムアウトがないときと0.5秒のときで、速いリクエストがいくつ成功したかが出ます。出力のstarved_okとbounded_okの2行を、/root/rt/pool/report.txtに書いてください。採点ツールがもう一度測って照合します。
タイムアウトのない呼び出しは、プールの席を無限に占有します。acquire_timeoutは待つ側を救うだけで、席を占めている側を終わらせることはできません。2つの値がなぜそうなるのかを、説明できなければなりません。
サーバーが先に閉じた接続を再び使わない
Poolのidle_timeoutを実装してください。releaseで戻ってきた時刻を記録しておき、acquireがアイドル中の接続を取り出すとき、休んだ時間がidle_timeout秒を超えたものはclose()して捨ててから、次のものを見ます。Noneなら捨てません。採点ツールは、1秒のあいだ静かな接続を切る中継器の後ろにサーバーを置き、idle_timeoutが0.5のプールで、リクエスト→1.5秒の休止→リクエストを送ります。
ロードバランサーとサーバーは、静かな接続を先に切ります。クライアントのプールは、その事実を、次にその接続で送るときになってはじめて知ります。プールのアイドル上限を、相手のアイドル上限より短くしておけば、その競争を避けられます。