読者です 読者をやめる 読者になる 読者になる

チャットメッセージの即時反映を支える技術

lobi Node.js Perl

Lobiチームの長田です。 今回はLobiの根幹であるチャットサービスの、Streaming APIについて紹介します。

多くのチャットサービスがそうであるように、 Lobiでも新しいチャットメッセージが画面リロードの必要なく表示されるようになっています。 チャットメッセージをデータストリームとしてクライアントに送信するためのAPIがStreaming APIです。 LobiのチャットサービスはiOS・Android・Webブラウザで利用することができ、 これら全てでStreaming APIを使ったチャット画面の自動更新を実現しています。

即時反映の実装方法としては

  • Polling
  • Long Polling
  • Web Socket

など複数の方法が挙げられますが、LobiではHTTPリクエストのLong Pollingをベースにした 独自のフォーマットを使用しています。

仕組み

sequence.png

  • app - メインのアプリケーション。メッセージ送信依頼を出す
  • publisher - job queue。appから来た送信依頼をnockに渡す
  • nock - メッセージ送信アプリケーション。クライアントの接続を受け付け送信依頼を元にメッセージを流す

少々複雑に見えますが、「認証」「メッセージ送信」「終了」の3つに分けて考えるとシンプルです。 一度認証すると一定時間(Lobiの場合は600秒)クライアントとの接続を維持し続け、 その間に投稿されたチャットメッセージを対応するクライアントに送信します。 クライアントが切断するかタイムアウトすると接続を終了します。

使用言語についてですが、 appはLobiの主要言語であるPerl、publisherとnockはNode.jsで書かれています。 nockをPerlではなくNode.jsを採用した理由としては、 大量のクライアントからの接続を少ないプロセスで維持したかった、ということが挙げられます。 同様の実装をPerlで行なった場合、接続ごとにforkしてプロセスが大量に作られるか、 少しクセのあるAnyEventを使って実装を行うことになるでしょう。 (余談ですが、いま再実装するならGoを選ぶはずです) publisherがNode.jsなのは、こちらも言語の特徴を活かすため・・・というわけではなくnockとセットで作られたからです:p

クライアントの管理

nockはクライアントとの接続を維持するためのアプリケーションです。

クライアントから新規接続が発生した場合、nockからユーザー情報を持っているappに認証リクエストが行われます。 この際にappでは、「対象のチャットグループをキーとした接続先nockのホスト名」をKVSに保存します。 KVSはLobiサービス内のどこからでも参照でき、異なるホスト上のappからでも同じ値が参照できます。

認証が完了すると、nockは「対象のチャットグループをキーとしたクライアントの情報」をメモリ上に保持します。 メモリ上に保存するため、この値はクライアントとの接続を維持しているnockしか知ることはできません。 この情報はクライアントとの接続が終了した際に破棄されます。

このように、アプリケーションごとに必要な情報を絞ることによってアプリケーションごとの結びつき疎にしています。

メッセージの送信

新しいチャットメッセージが投稿されると、appがpublisherにメッセージ送信リクエストを行います。 送信リクエストには、

  • チャットメッセージの内容
  • 対象nockのホスト名

が含まれています。

publisherはjob-queueの仕組みを持っており、送信リクエストをキューに追加すると即座にappへレスポンスを返します。 appから直接nockに送信リクエストを行なった場合、nockがbusyだった場合に待ち時間が発生してしまいます。 この待ち時間を最小にするための緩衝材としてpublisherが用意されています。

publisherは受け取ったリクエストを順に処理し、そこに含まれるnockのホスト名を元にnockへのメッセージ送信リクエストを行います。 どのnockホストにリクエストを送ればいいのかがわかっているので、必要充分なリクエストのみが発生します。

クライアントに送信する内容

HTTPを使ったLong Pollingを行っているわけですが、その間nockからclientに送られるデータは例えば以下のようになっています。


$ curl -v 'https://stream.lobi.co/1/group/gggggggg?token=tttttttt'
*   Trying 52.68.212.118...
* Connected to stream.lobi.co (52.68.212.118) port 443 (#0)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
* Server certificate: *.lobi.co
* Server certificate: Cybertrust Japan Public CA G3
* Server certificate: Baltimore CyberTrust Root
> GET /1/group/gggggggg?token=tttttttt HTTP/1.1
> Host: stream.lobi.co
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: openresty
< Date: Wed, 21 Sep 2016 06:50:49 GMT
< Content-Type: multipart/mixed; boundary="xHchms"
< Transfer-Encoding: chunked
< Connection: keep-alive
< X-Powered-By: Express
< X-Content-Type-Options: nosniff
<--xHchms
Content-Type: application/json

{"now":"1474440595.98001","chat":{"likes_count":0,"bookmarks_count":0,"boos_count":0,"created_date":1474440595,"urls":[],"message":"\u308f\u3044\u308f\u3044","image":null,"assets":[],"max_edit_limit_date":1474444195,"reply_to":null,"user":{"icon":"https://assets.nakamap.com/img/user/iiiiiiii_72.png","cover":"https://assets.nakamap.com/img/user/cccccccc_640.png","uid":"uuuuuuuu","premium":0,"name":"handlename","default":1,"description":"\ue408"},"type":"normal","id":"00000000","assets_expired":0,"edited_date":null},"event":"chat"}
--xHchms
Content-Type: application/json

{"now":"1474440629.42829","chat":{"boos_count":0,"image_type":"stamp","urls":[],"stamp_id":"661","image_height":240,"user":{"icon":"https://assets.nakamap.com/img/user/iiiiiiii_72.png","cover":"https://assets.nakamap.com/img/user/cccccccc_640.png","uid":"uuuuuuuu","premium":0,"name":"handlename","default":1,"description":"\ue408"},"id":"00000000","assets_expired":0,"likes_count":0,"bookmarks_count":0,"image_width":240,"created_date":1474440629,"message":null,"image":"https://assets.nakamap.com/static/stamps/lobi_aisatsu_2_achieve_08/01_w240/stamp_08.png?hash=20dc3d1f3ed78767a9e767854e6ad79d42240434","assets":[],"max_edit_limit_date":null,"reply_to":null,"type":"normal","edited_date":null},"event":"chat"}
--xHchms

ヘッダーを診てもらうと分かる通り、multipart/mixedを使って1回の接続に複数のメッセージを含めています。 新しいチャットメッセージが届くたびにboundaryで区切られたメッセージデータが送られてきます。 クライアントではこのメッセージデータを元に画面の更新を行っています。

Amazon Web Services固有の話

LobiはAmazon Web Services(AWS)上にサービスを構築しています。 Streaming APIの仕組みを提供する上で固有のトピックがあったので紹介します。

Auto Scaling時の再接続ラッシュ

Lobiで使用しているElastic Computing Cloud(EC2)のインスタンスのうち、 負荷増加の可能性があるものはAuto Scaling Groupによって自動的に増減するようになっています。

Streaming APIを提供するホストについても同様にAuto scaleしているのですが、 「長期間接続を維持する」という性質上、decreseする際に接続中のクライアントを同時に切断してしまうと、 その数だけ再接続が発生しnockの負荷が増加することになります。

2014年3月に行われたELBのアップデートでコネクションが切断されるまで接続を維持することができるように成りました。

Amazon Web Services ブログ: 【AWS発表】ELBのConnection Draining - インスタンスをサービスから注意深く取り除く

Streaming APIの最大接続時間が600秒なので、Connection DrainingのTimeoutにはこれよりも少し長い720秒を設定しています。 これにより再接続処理が集中することが避けることができるように成りました。

Elastic Load Balancerのコネクションタイムアウト

ロードバランサーに使用しているElastic Load Balancer(ELB)ですが、 LobiをAWSに移行した当初はコネクションのタイムアウトが60秒に設定されていました。 LobiのStreaming APIは最大600秒間接続を維持する仕様だったため、 当時のELB配下にはこのAPIを提供するホストを入れることができず、 DNSラウンドロビン方式で負荷分散を行なっていました。

2014年7月に行われたELBのアップデートでコネクションタイムアウトまでの時間が最大3600秒まで拡大されました。

Amazon Web Services ブログ: 【AWS発表】Elastic Load Balancingのコネクションタイムアウト管理

これにより他のホストと同じくELBによる負荷分散が可能になりました。

HTTP modeで切断が検知できない問題

ELBのコネクションタイムアウトが延長されたので、いざELB配下に! ということで接続テストを行なったところ、nginxがクライアントの切断を検知できないという問題があることがわかりました。 ELBがクライアントとのコネクションを終了しても、バックエンドのnginxには通知されず、 ELBとnginxのコネクションがタイムアウトまで残り続けてしまう、というものです。

これを回避するために、

  • ELBのListenerをHTTP modeではなくTCP modeに設定
  • これだけではクライアントのIPアドレスを記録できないため、Proxy Protocolを有効に

という対応を行いました。

おまけ

いまは使われていない昔話です。

Androidのプッシュ通知

Android端末へのプッシュ通知を実現するために、Googleが提供するC2DMというサービスを利用していました。 (下記のページを見てもらえば分かる通り、現在はDeprecatedです)

Deprecated | Cloud to Device Messaging (Deprecated) | Google Developers

しかし、C2DMには接続するクライアントごとにクオータが設定されており、それを超える通知を送ることができないという仕様の壁がありました。 チャットサービスで通知を送ることができないというのは致命的です。

この問題を解決するために、Android端末ではStreaming APIを使ってプッシュ通知を実現していました。 バックグラウンドで起動しているLobiアプリが常にStreaming APIへの接続を維持し続け、 サーバーサイドのアプリケーションはプッシュ通知が必要な場合にはStreamにメッセージを流すというわけです。

チャットグループのStreaming APIはアプリがフォアグラウンドにあるときだけ接続を維持すればいいのですが、 プッシュ通知を送るために使用する場合はアプリが起動していようがいまいが常に接続を維持しなければなりません。 アクティブなユーザーならまだしも、休眠ユーザーの接続まで維持しなければならず、 かといって休眠ユーザーの接続を破棄してしまうとプッシュ通知に寄る呼び戻し効果を捨てることになるというジレンマを抱えていました。

現在はGCMという新しいプッシュ通知送信の仕組みが提供されているため、 Streaming APIによる通知の仕組みは廃止しそちらを利用しています。

Cloud Messaging | Google Developers

おかげでStreaming API用のホストはGCM採用以前の半分ほどに減らすことができました。

おわり

次回はid発番ツールkatsubushiについて紹介します。

カヤックではリアルタイムメッセージングに興味のあるエンジニアも募集しています!