TT Lab
はじめる
学ぶ 学習パス コース

リアルタイム通信 — WebSocket・gRPC ストリーミング・WebRTC

長く繋がる WebSocket を運用する

TT Labで続きを見る

一言でいうと

長く接続されたままのWebSocketは、遅い購読者・アイドルタイムアウト・黙って消えた相手・一斉再接続に耐えられるように、上限とハートビートと再開のルールを備えていなければなりません。

なぜ必要なのか

プロトコルを正しく実装しても、WebSocketサービスは時間が経つにつれて崩れます。接続が何時間も生きているあいだに、4つのことが必ず起こるからです。誰かは遅くなり、中間機器は静かな接続を切り、携帯電話はトンネルに入って黙って消え、デプロイはすべての接続を一度に切ります。リクエストと応答が短く終わるHTTPでは、この4つはほとんど見えませんでした。

どう動くのか

遅い購読者: 発行ループが購読者ごとにawait sendを順番に呼ぶと、1人の送信バッファーがいっぱいになったとき、その後ろの全員が待ちます。購読者ごとにキューと、そのキューを空にするループを別に持たせれば、待ちが人ごとに切り離されます。ところが、キューに上限がないと、遅い1人の分がサーバーのメモリに際限なくたまります。上限に達したときにできることは3つあります。古いものを捨てるか、最新の値1つにまとめるか(相場やカーソル位置)、接続を切るかです。順序と欠落が重要なストリームなら、穴が空いたまま送り続けるより、切って再接続させるほうが正直で、そのとき、クローズコード1013(あとでもう一度)で理由を残します。ブラウザーのWebSocket APIにはバックプレッシャーがなく、bufferedAmountで溜まった量が見えるだけなので、送る側がこの数字を自分で見る必要があります。

アイドルタイムアウト: ロードバランサーとプロキシは、静かな接続を決まった時間のあとで切ります。AWS Application Load Balancerの既定値は60秒で、nginxのproxy_read_timeoutの既定値も60秒です。LinuxのTCP keepaliveは、既定では2時間後にようやく最初のプローブを送るので、この目的には役に立ちません。そのため、アプリケーションが、最も短いアイドル上限より頻繁にpingを送ります。pingは、接続を生かしておくと同時に、答え(pong)が来るかどうかで、相手が生きているかを確認します。

黙って消えた相手: 相手がcloseなしで消えると、TCPはしばらく何もわかりません。pingを送って、決められた時間内にpongがなければ切るのが、唯一の確認方法です。ここでよくある欠陥が1つあります。ライブラリが接続を閉じても、購読者キューを待っていたコルーチンは、新しいメッセージが来るまで起きません。リストには死んだ接続が残って、接続数の指標を膨らませ、メッセージを受け取ってためます。キューと接続の終了を、一緒に待つ必要があります。

一斉再接続: サーバー1台が再起動すると、そのサーバーの接続数万が同時に切れ、同じ規則でリトライするクライアントたちが、同じ瞬間にまた押し寄せます。指数バックオフにランダム性を混ぜること(AWSアーキテクチャブログがfull jitterと呼んだ方式で、0からmin(上限, 基準 × 2^試行)までの間から一様に選ぶ方法)が、その波を広く均してくれます。クローズコードも判断に使います。1001・1006・1011・1012・1013のように、相手の事情が変わりうる場合だけ再接続し、1008(ポリシー違反)や1002(プロトコルエラー)で切れた接続は、再接続しても、同じ理由でまた切れます。

再開: SSEには、Last-Event-IDで切れた位置を知らせる規則が仕様にありますが、WebSocketにはありません。メッセージに連番を付け、サーバーは、最近のメッセージをリングバッファーに置いておいて、クライアントが最後に処理した連番を知らせてきたら、その後ろを再送します。バッファーからすでに押し出された位置なら、黙って穴を空けずに、「最初から受け取り直してください」と知らせます。クライアントが知らせるべきなのは、受け取った連番ではなく、処理して書き残した連番です。

接続数の限界: 接続1つは、ファイル記述子1つとカーネルバッファー、アプリケーションのキューを占めます。Podの記述子の上限(ulimit -n)とメモリが、同時接続数の天井を決めるので、負荷テストで接続数を増やしながら、接続あたりのメモリを測っておく必要があります。

スケーリング: 接続がサーバー1台に結びつくことも、WebSocketの性質です。発行者と購読者が別々のサーバーにつながると、サーバー同士でメッセージを共有する場所(Redisのpub/sub、メッセージブローカー)が必要で、再開用のリングバッファーも、サーバー1台のメモリではなく、その共有ストレージに置いてはじめて、別のサーバーに再接続しても、続きを受け取れます。連番を、サーバーではなくチャネル単位で振るのも、そのためです。

現場での姿

デプロイのたびに、数分のあいだ、サーバーのCPUが跳ね上がって、接続エラーが大量に出るサービスが、よくあります。調べてみると、再接続にジッターがなく、再接続の直後に、全体の状態をまたダウンロードしています。ジッターと連番に基づく再開の2つで、その数分がなくなります。サーバーを落とすときは、1001や1012で閉じて、クライアントが「再接続してよい」とわかるようにするのも、役に立ちます。

次のラボですること

websocketsで、発行・購読ハブと再接続クライアントを作ります。読まない購読者を1人つないだまま、64MBを発行して、メモリと1013を確認し、2.5秒のアイドル上限の中継器の後ろで6秒耐え、pongを返さない購読者を6秒以内に片づけ、発行の途中で接続を2回切っても、連番1から最後まで、抜けなく1回ずつ書き込めるかを見ます。