WebSocketの扱うサービスでは、長時間のコネクション、再接続処理、プロキシ、ロードバランサなど、インフラの面で多くの問題を抱えがちです。弊社のサービス「pixiv」の9周年企画でも、この問題に直面しました。
実際にそこで構築したインフラの事例をもとに、本運用に使えるWebSocketサーバの構成について、pixivインフラ部の南川からご紹介します。
* 9周年企画 “黒歴史”をロケットで宇宙に飛ばす pixiv黒歴史
そもそも WebSocket とは?
WebSocketはTCP上で動く双方向通信のための通信規格です。
Webページの読み込みで行われているような、クライアントがサーバにデータを要求し、サーバはクライアントにレスポンスを返すというHTTPの通信ルールとは違います。サーバと長時間コネクションを確立し、Socketのようにデータのやり取りを行います。そして、コネクションを確立した後は、サーバ・クライアントのどちらからも通信を始めることができます。
HTTPと比較すると、コネクションを張り続けるため通信のオーバーヘッドが少ないという利点があります。一方で、コネクションが切断された場合の再接続処理などの面がやや手間です。コネクションを張り続けることでの注意点もあり、HTTPとは違った知識が要求されます。
TCP/IPでのソケット通信の知識があれば理解は進みやすいでしょう。
WebSocketを有効に使えるケース
WebSocketが最も活躍するのは、クライアントとサーバ間で多くの双方向通信を行うケースです。
テキストチャットのように配信量が少ない場合は、Ajaxを使ったポーリングでも賄える場合が多いです。WebSocketの用途は、双方向で大量のデータをやり取りする必要がある場合に限られるでしょう。
また、移動通信中はコネクションの切断について意識しなければいけません。モバイル端末で使われるような場合には注意が必要でしょう。
WebSocketを使う上で注意する点
Webエンジニアとして気を付けておきたいWebSocketの注意点の一つがコネクションを張り続ける点でしょう。
HTTPはクライアントがサーバにデータを要求し、サーバはクライアントにレスポンスを返すまでが一連の処理です。レスポンスを返し終われば、コネクションを切断できます。
一方でコネクションを張り続けているWebSocketの場合、切断時には適切な終了処理を記述する必要があります。
サーバ側プログラムの更新のタイミングではコネクションを一度リセットする必要があるため、クライアント側でもコネクションの切断時に呼び出されるWebSocket.onclose属性にハンドラーを設定し、適切な復帰処理を記述する必要があります。
ws = WebSocket(...); // e: CloseEvent ws.onclose = function (e) { // 適切な復帰処理 if (e.code == ...) { } }
もう一点、HTTP通信に付きまとう問題としてプロキシサーバの存在があります。
WebSocketの通信は80番ポートを使用するため、WebSocketの動作に問題のあるHTTPキャッシュサーバと通るケースがあります。
これはサーバ側でコントロールできないこともあり厄介な問題となりえます。
例えばコネクションが生存しているか確認するために行う PING/PONG フレームがキャッシュサーバによって破壊されてしまうと、クライアントがコネクションが確立されていると錯覚し、奇妙な動作を引き起こすことがあります。
このような問題を避けるため、WebSocketを使う場合、必ずTLSを通さなくてはいけません。
HTTPS接続を行う場合と同じように証明書の設定をし、wss://
で接続するようにすればこの問題を回避できます。
コネクション数制限についてもサーバ、クライアントで注意する必要があります。
WebSocketはブラウザのタブごとに違うコネクションを張ることができるので、クライアントあたりのコネクション数が大量になる恐れがあります。
サーバは多くのコネクションを張らなければならない場合がありますが、例えばNginxなどのHTTPサーバをTLSの終端としておく場合、プロキシとして使えるTCP/IPでのポート数には65535の制限があるため注意する必要があります。
データベースへの接続も同様に問題となるためコネクションプールを用意する方法などを使いポートの枯渇を避ける必要があります。
これは例えばタブがアクティブではない場合にはコネクションを切断し、数分に1度のポーリングに切り替えることで軽減できますがクライアントの実装は複雑になります。
コネクションフル通信
コネクションを確立する通信のため、サーバ・クライアントで多くのデータをやり取りする場合に新規接続を張る際のオーバーヘッドを減らすことができます。
一方で移動通信環境では度々切断が発生するため、再接続時にデータの再取得を行うような設計になっているとデータのやり取りは想定しているよりも多くなってしまうこともあります。
より有効に利用するには、PCやWifi環境化でのほぼ常時コネクションを維持できる環境を対象にするのがよいでしょう。
アクティブでないタブでもコネクションを張り続けてしまうことが問題になる場合がある
WebSocketでコネクションを確立するとアクティブではないタブでもWebSocketのコネクションは有効のままになります。
これはサーバからのプッシュで便利な面もありますが、多くの非アクティブなユーザのブラウザでもコネクションが張り続けられてしまい、サーバのコネクションを占有し続けてしまうという問題もあります。
サーバが受け付けられるコネクションの数には制限があるため、接続と切断を繰り返すHTTPと比較すると1サーバで処理できるユーザ数は少なってしまいます。
アクティブでないユーザのためにマシンリソースを割り当てられないよう、クライアント側にも工夫が必要です。
再接続処理
WebSocketの再接続処理についてはクライアントが考慮しておく必要があるでしょう。
サーバ再起動時など、大量のクライアントが同時に切断される場合、再接続処理も同時に大量発生するため、サーバ側が一時的に高負荷になってしまう問題があります。
デプロイ時など同時に複数のサーバが再起動される場合にこの負荷が無視できない大きさになることが予想できます。
9周年企画サーバの事例では、再接続時に画面描画に必要なデータを全てまとめて要求するという非常に高負荷な処理を行う設計になっていました。このため、サーバの再起動時に発生する切断でクライアントからのアクセスが集中し、データベースサーバが高負荷になるという問題が発生し、デプロイが容易にできなくなりました。
サーバの数を増やし、緩やかに再起動することである程度緩和できますが、クライアント側でも切断を検知した後、すぐに再接続するのではなく、ランダムな時間待ってから接続したり、再接続処理でサーバに大きなデータを要求することを避けることである程度サーバ側の負荷を分散することができます。
ロードバランサー
HTTPサーバの構成ではよくあるL7ロードバランサーはWebSocketではあまり意味がありません。
HTTPロードバランサーは接続のたびに異なるバックエンドサーバに接続することで負荷分散を行うことを目的としますが、WebSocketを使う場合、接続が長期間維持されるため異なるサーバに接続されることはありません。
また、ロードバランサーとしてつかうサーバのコネクション数がボトルネックとなるため、配置しないほうがよいでしょう。
NginxやHAProxy、AWSのELBなどはTCPロードバランサーとしての機能もあり、簡単な設定で使うこともできますが、上記の問題により接続が偏る問題は付きまといます。
HTTPとの通信と異なり、接続元情報をアクセスログに記録するには proxy_protocol を使うなどの工夫も必要になります。
いずれにせよ、サーバ側でのロードバランシングは実装の難しさに比較してメリットが小さいので、負荷分散を目的とした接続先のバランシングはクライアント側で行うようにできればよいでしょう。
DNSラウンドロビンを使う方法は簡単にある程度効果が見込めますが、定期的な切断処理がない場合、一度確立したコネクションが切断されるには時間を要するためより効果的に分散するためにはサーバ、クライアント双方で専用の仕組みを用意する必要があります。
構成例
以上のことを踏まえた現実的なWebSocketサーバの構築例は以下の図のようになります。
LUNCHER
WebSocketサーバがインターネットに面したIPアドレスを持ち、DNSラウンドロビンで接続先を分散します。
サーバクライアント間での細かな制御ができると理想ではありますが、クライアント側でのコード変更が不要なため、DNSラウンドロビンを使っています。
ただし、DNSラウンドロビンによる接続先の分散は偏りを生みやすいため少ないサーバ台数での運用ではうまく分散するのは難しいので注意が必要です。
サーバ側の構築はHTTPサーバと比べシンプルになります。
終わりに
WebSocketを活かしたサービスを本運用に乗せるには、気をつけるべき点がたくさんあります。
HTTPと違う点も多く慣れていないと扱いづらいと思うこともありますが、使いこなせば強力なツールとなりえます。
実際に動かすプログラムのコードは省略しているので、各フレームワークのドキュメントを参照してみてください。