Node.js Cluster+Socket.IO+Redisによるリアルタイム通知システム | サイバーエージェント 公式エンジニアブログ
アメーバピグの開発エンジニアをしている古谷です。

アメーバピグはもうじき5周年を迎えます。
ユーザ数はリリース当初よりはるかに多くなり、それに伴いシステム規模も大きくなってきているピグですが、
Java, MySQL, Node.js, MongoDB, Redis, ランキングシステム, 外部連携API、認証... リアルタイムなサービスからスマホ向けアプリまで...
と、開発に携わってみるとWeb関連の様々な事が学べます。

最近、アメーバピグ関連サービス(アメーバピグ、ピグライフ ...etc)にて、サービス間でユーザの行動をリアルタイム通知するためのシステムを開発する機会がありました。
今回は、そのシステムの一部である、Node.js Cluster + Socket.IO + Redisを利用したフロントサーバについてかきます。

現在、アメーバピグ関連サービス全体だと約200,000人のユーザが同時にプレイしてくれています。
これらのユーザに対してリアルタイム通知を送るため、全ユーザとのコネクションを維持し、通知データをユーザに届けるというのがフロントサーバの役割です。
ちなみに、アメーバピグ関連サービスはそれぞれクライアント⇔サーバ間の通信プロトコルや、クライアントのデバイスが異なるため、各サービスのシステムを利用して相互にリアルタイム通知を送るのは困難でした。
そこで、みなさんもご存知のSocket.IOを利用したリアルタイム通知専用のシステムを作ることになったわけです。



サーバについて
フロントサーバは、Node.jsで実装し、Node.jsのCluster機能を利用しました。
サーバ上にはマスタープロセスとワーカープロセスが起動し、ワーカープロセスではSocket.IOが稼動します。
マルチプロセス上でSocket.IOを利用するために、Socket.IOのRedisStoreという機能を利用しています。

現在、この通知システムはピグの裏側で稼動していますが、リリースに至るまでにはいくつかの壁がありました。その中から、参考になりそうなものをピックアップして紹介したいと思います。

Node.js Cluster
 【クライアント接続が各プロセスに均等に分散されない。】
開発で利用したバージョン(0.10.21)のNode.jsの場合、Cluster構成はリスニングソケット共有型であるため、各ワーカプロセスへの分散はカーネルによって行われます。そのため、プロセスごとに接続が偏る傾向があります。
コネクションを維持するようなシステムの場合、特定のプロセスに接続が偏るため負荷によるプロセスダウンの可能性があります。


回避策として、Cluster構成を接続済みソケット共有型し、ラウンドロビン方式で分散するよう修正して利用しています。
修正自体は、次期バージョンにて組み込まれる予定の内容をcherry-pickしています。

Socket.IO
開発で利用したバージョン(0.9.16)のSocket.IOでRedisStoreの機能を利用する場合に、以下の問題点があります。※MemoryStore利用時は問題ないです。
  
【ハンドシェイクデータのpub/subが遅延した場合、接続率が低下する。】
RedisStore利用時は、クライアントの接続情報はRedisのpub/subによって他プロセスに共有されます。
Socket.IOの場合、クライアントはハンドシェイク → 接続という2段階の接続方式をとっており、以下のようなフローで接続確立されます。
  1.ハンドシェイク
  2.pub/subにより、ハンドシェイクデータが各プロセスに共有される
  3.接続
  4.接続確立


ただし、高負荷によりRedisのpub/subが遅延している場合は以下のような状態になり、未ハンドシェイクと判断されクライアントが切断されるケースがあります。
このように、pub/subの遅延が接続率の低下に繋がるわけです。
  1.ハンドシェイク
  2.接続
  3.未ハンドシェイクとして切断(クライアントに「client not handshaken error」エラーが返されます。)
  4.pub/subにより、ハンドシェイクデータが各プロセスに共有される


また、仮にエラー時にクライアントを再接続するようにしていた場合、クライアントの再接続によりハンドシェイクの数が増えるためpub/subの遅延を悪化させます。
さらに、ハンドシェイクデータのpub/subは、Socket.IOを起動している全プロセスへ通信が発生するため、接続率の低下だけではなく各プロセスの負荷高騰の原因にもなる可能性があります。

回避策として、接続時にハンドシェイクデータが共有されていない場合、一定期間ハンドシェイクの共有を待つよう修正して利用しています。

【クライアントの接続/切断が大量に行われるとメモリリークする。】
リリース後、数日間で各プロセスのメモリ使用量が上昇していく現象がありました。
原因は、Socket.IOモジュール内で、クライアント切断時に行われるべきunsubscribe処理の漏れでした。
修正は、本家のSocket.IOにコミットしてあります。 この修正前のバージョンをご利用の方は、以下のコミットが反映されているバージョンのご利用をお勧めします。
まとめ
今回Node.js、Socket.IO、Redisを利用したシステムは、通知データをユーザへ届けるためのハブのようなシステムであったためIOバウンドな処理が多く、組み合わせとしては相性が良かったように思います。
ただし、利用するためには今回書かせていただいた内容以外にもいくつか工夫が必要でした。今後、機会があればブログに書きたいと思います。