Meet at Idobata

21世紀の開発者のためのグループチャット Idobata の開発ブログです。

idobata-eventd と Server-Sent Events

書いた人: ursm

これまで Idobata ではイベント配信の仕組みとして Pusher を使ってきましたが、先日のリリースから idobata-eventd という独自のバックエンドを試験的に導入しました。idobata-eventd は送信したイベントを一定期間保持し、クライアントからの要求に応じて再送する機能を備えています。今まではネットワークが切り替わったときなど、イベントを取りこぼした可能性がある場合はすべてのデータを再取得する (= 画面全体をリロードする) する必要がありましたが、idobata-eventd では最後に取得したイベントからの増分のみを受け取って反映できるようになりました。

仕組み

idobata-eventd は Go で書かれた SSE (Server-Sent Events) サーバです。SSE は XHR による HTTP ストリーミングを使いやすくラップした仕様で、用途から見れば WebSocket の単方向版と言って差し支えありません。技術的には chuked transfer encoding そのものなので、単純で扱いやすいのがメリットです。

API サーバ (Rails) から eventd にイベントを投げるのは PostgreSQL の NOTIFY/LISTEN を介して行います。API サーバがイベントを表すテーブル events にレコードを挿入すると、テーブルに設定しておいたルールで events_insert チャネルに通知が送られます。

                                     Table "public.events"
   Column   |            Type             |                      Modifiers
------------+-----------------------------+-----------------------------------------------------
 id         | integer                     | not null default nextval('events_id_seq'::regclass)
 event      | text                        | not null
 data       | jsonb                       | not null
 user_ids   | integer[]                   | 
 client_id  | text                        | 
 created_at | timestamp without time zone | 
 updated_at | timestamp without time zone | 
Indexes:
    "events_pkey" PRIMARY KEY, btree (id)
Rules:
    events_insert AS
    ON INSERT TO events DO
 NOTIFY events_insert

eventd はこの通知を受け取り、events テーブルからレコードを読み出して対象ユーザに HTTP コネクションを通してイベントを送信します。

ハマりどころ

主に SSE の微妙な挙動に悩まされました。

nginx 越しだと eventd が落ちたときに再接続しない

SSE は明示的に切断しない限り接続が切れても自動的に何度でも再接続してくれるありがたい仕掛けを備えているのですが、本番に近い環境で試しに eventd を kill してみるとあっさり再接続を諦めてしまいました。調べてみるとどうやら再接続されるのは「接続が切れた」場合のみで、サーバがエラーレスポンスを返したときは再接続されないようです。eventd を nginx の背後に置くと、eventd が落ちたとき nginx が代わりに 502 Bad Gateway を返してしまうので駄目なのですね。

nginx の設定で 502 のときはレスポンスを返さずコネクションを切断するようにしてみたところ、期待通りに再接続されるようになりました。

location /api/stream {
  proxy_buffering off;
  proxy_cache off;
  proxy_http_version 1.1;
  proxy_pass ;
  proxy_set_header Connection '';

  error_page 502 = @no_content;
}

location @no_content {
  return 444;
}

444 は nginx 独自のステータスコードで、これを返すとコネクションが即座に切断されます。

Chrome 50 だとコネクションの切断が検知できない

Chrome 51 だと OK、他のブラウザでも OK、でも Chrome 50 だけコネクションを切断してもレスポンスが続いているように見えます (しかし実際には切れているので、データは流れてこない)。これは SPDY が原因でした。1つの物理的なコネクションに論理的なコネクションを重畳させる仕組み上、切断を検知するのが難しいのかもしれません。Chrome 51 から SPDY のサポートが削除されたため、50 でだけ問題が発生していたようです。

HTTP/2 の登場で SPDY は役目を終えつつあるということで、ひとまず SPDY をオフにすることで対処しました。サーバ環境の都合上 HTTP/2 で同じ問題が発生するかはまだ試せていません。準備が整ったら試してみようと思います。

Avast Antivirus を導入している環境だと接続に失敗する

いくつかのアンチウイルスソフトはブラウザの通信に介入し、安全かどうかをチェックしてからブラウザに渡すという機能を持っています。普通の HTTP リクエストならよいのですが、ストリーミング接続でこれをやられると待てど暮らせどレスポンスが返ってこない事態になってしまいます。

アンチウイルスソフトにストリーミング接続だということをわかってもらえれば良きに計らってもらえるだろうと推測して実験してみたところ、Transfer-Encoding: chunked ヘッダを付けるとうまくいくことがわかったのでそのようにしておきました。

スリープから復帰したときに再接続されず、エラーにもならない

SSE が自動的に再接続することは先に書きましたが、本当にどうしようもないときは error イベントが発火します。Idobata ではエラーハンドラで画面をリロードするようにして「通常は再接続してイベントの再送を受け取る、リカバリできないときはリロード」という挙動にしたつもりでした。しかしながら、スリープから復帰したときに再接続されず、エラーハンドラも呼ばれないという現象が発生しました。これではユーザが手動でリロードするまで新しいイベントを受け取ることができません。

この問題への対処として接続状態を定期的に監視して切断されたままになっていたらリロードするという泥臭い方法を取ったのですが、コネクションはオープンに見えるのに通信だけ途絶えているという予想外のケースに遭遇しました。仕方がないので keepalive のために送信していた空のイベントが一定時間来なかったらリロードするようにしました。

おわりに

Pusher はすごいと思いました。