[Fastly] Server-Sent Events (SSE) を Fastly で最強にスケールさせる

# はじめに

この記事の内容の多くは、Andrew Betts が書いた Fastly のブログ [Server-sent events with Fastly](https://www.fastly.com/blog/server-sent-events-fastly) に基づいています。

# Server-Sent Events とは？

- [[MDN] Server-Sent Events の利用
](https://developer.mozilla.org/ja/docs/Web/API/Server-sent_events/Using_server-sent_events)

Server-Sent Events (以降 SSE と呼ぶ) は、いわゆる "リアルタイム" イベントを _サーバ -> クライアント_ に向けてプッシュできる技術です。
リアルタイムイベントというと、[Websockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) をまず頭に浮かべる人が多いかと思います。確かに Websockets は SSE に比べてより柔軟で強力な機能を持っていますが、実装の複雑さは SSE とは対象的に非常に高コストになりがちです。また大きな違いとして、Websockets では _サーバ <-> クライアント_ 間での双方向のリアルタイム通信が可能ですが、SSE はあくまでも _サーバ -> クライアント_ (一方向) のデータのプッシュのみをサポートしており、Websockets とは想定される利用シーンが少々異なってきます。

例えば下記のようなウェブページには SSE が非常に向いていると言えます。

- フライト情報をリアルタイムに更新
- 株価情報
- ニュースサイトのアラート
- スコアボード (選挙速報や競技イベント)

上記はいずれもサーバからクライアントに対して一方的にデータをプッシュできればいいため、わざわざ大掛かりな Websockets を導入せずとも SSE で低コストに実現することができます。しかし、SSE はあまり普及していないようです。なぜでしょう。

## SSE の基本的な仕組み

SSE はシンプルに既存の HTTP プロトコルの上で通常のリクエストとして動作します。特殊なプロトコルは何も使用していません。
サーバはクライアントからリクエストが来ると、テキストベースのシンプルなストリームデータをクライアントに送信します。この時サーバは、MIME タイプ `text/event-stream` としてデータを送信する必要があります。
クライアントはデータを受信するとコネクションを閉じず保持し続け、サーバからのデータを待ちます。サーバはこの open 状態にあるコネクションの上で新しい追加のデータをストリームにプッシュし続ける事ができます。

クライアントが SSE のイベントを受信するには、`EventSource` インターフェイスを利用します。

```js
var evtSource = new EventSource("stream");
```

インスタンスの作成後、メッセージの受け取りをトリガーに任意の処理を実行できます。

```js
evtSource.onmessage = function(e) {
  var newElement = document.createElement("li");
  var eventList = document.getElementById('list');

  newElement.innerHTML = "message: " + e.data;
  eventList.appendChild(newElement);
}
```

とりあえず SSE が動いてるか手早く確かめたい場合は、`EventSource` でインスタンスの作成だけ行い、例えば Chrome の developer tools で下記のように `EventStream` タブからデータ受信の様子を確認することができます (または当該の URL に直接アクセスすると、データがそのままブラウザに表示され、更新されていく様子が見ることができます)。

<img width="1437" alt="Screen Shot 2020-03-20 at 21.15.11.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19942/1f6b39ad-040f-41f9-972b-9ad689a7082d.png">

非常に簡単であることが分かると思います。
あとは送られてくるデータをクライアント側でどう処理するかを考えればよいだけです。

## SSE を使う上での課題

### ブラウザのサポート状況

- https://caniuse.com/#feat=eventsource

これはあまり問題にはならないでしょう。IE を除く主要なブラウザでサポートされています。

### サーバ側のスケーラビリティ

前述したように、クライアントとサーバは SSE のコネクションを一度開くとコネクションを張りっぱなしにします。
これはつまり、大量のクライアントからのアクセスが発生する場合にはサーバが大量のアイドル状態の TCP 接続を開いたままにしておかなければならない事を意味し、サーバまたはネットワーク構成上において何らかの最適化が必要になる可能性があります。

### アプリケーションのスケーラビリティ

接続中の複数のクライアントに対して同じデータを同時に提供する必要があるため、接続がリクエスト毎に isolation されるような場合には少々チャレンジングになることがあるかもしれません。

# Fastly で全て解決する

前述したスケーラビリティの問題は、Fastly を使うことで劇的に改善することができます。
これを実現するためには、Fastly の下記のような機能が鍵になってきます。

### [リクエスト共有 (Request collapsing)](https://docs.fastly.com/ja/guides/request-collapsing)

リクエスト共有 (Request Collapsing) は、単一の Fastly データセンター内でキャッシュミスが同時に発生する際に、オリジンサーバへのリクエストを 1 つに束ねることを意味します。つまり、キャッシュされていない URL コンテンツに対して複数のクライアントから同時にアクセスが発生したとしても、1番最初に到達したリクエストだけがオリジンへリクエストを送信し、残りのリクエストはこの最初のリクエストのレスポンスがオリジンから返されるまでの間は Fastly 上で待機状態になります。オリジンからレスポンスが返されると、一斉に待機状態になっている接続に対してコンテンツを返します。
これは通常 [cache stampede](https://en.wikipedia.org/wiki/Cache_stampede) 問題を回避するための重要な機能ですが、SSE の場合にはつまり Fastly <-> オリジン 間の単一のストリームコネクションを複数のユーザーに "fan out" させることができます。なので、オリジンのサーバは非常に低い load で稼働を続けることができ、単に単一ストリームにデータをプッシュすることで、Fastly 上で待機状態になっている複数の接続に対して一斉にデータをプッシュすることができます。

### [Streaming Miss](https://docs.fastly.com/en/guides/streaming-miss)

通常クライアントが Fastly にリクエストを送り、そのコンテンツがキャッシュされていない場合、Fastly はオリジンサーバにリクエストを送ります。この時、クライアントは Fastly がコンテンツ全体をオリジンから取得し終わるまで待機状態となり、レスポンスを受け取ることができません。Streaming Miss を有効にすることでこの挙動を変更できます。有効にすると、オリジンからの最初のレスポンス (チャンク) が届いた時点ですぐにそれをクライアントにも配信することができます。SSE では当然ストリーム上で任意の間隔でデータのチャンクが送信されるため、オリジンサーバから最初のチャンクが届いた時点ですぐにそれをブラウザに届けることが重要です。

### [オリジンシールド (Shielding)](https://docs.fastly.com/ja/guides/shielding)

> Fastly のオリジンシールドを使うと、オリジンに対するシールドとして POP (Point of Presence) をいずれか 1 つ指定できます。シールド POP の指定後は、当該オリジンへのリクエストはすべてシールド POP 経由となるため、キャッシュのヒット率が高くなります。シールドに指定されていない POP がキャッシュを持っていない場合、その POP はオリジンではなくシールド POP へリクエストを行います (ただしシールド POP がダウンしていないことが条件です)。

つまり、デフォルトでは、例えばオリジンサーバが日本にあるとして、アメリカからアクセスするクライアントはアメリカの POP からオリジンに向けてコネクションが、ヨーロッパからアクセスするクライアントはヨーロッパの POP からオリジンに向けてコネクションが張られます。キャッシュオブジェクトは POP 単位で管理されており、全ての POP で共有されている訳ではありません。

そこで、例えば東京の POP をオリジンシールドとして設定するとします。すると、アメリカからのクライアントはアメリカ POP -> 東京の POP に対して接続を、ヨーロッパからのクライアントはヨーロッパ POP -> 東京の POP へ必ずアクセスが中継されることになり、東京 POP にさえキャッシュオブジェクトが存在していれば HIT となり、オリジンへの接続を減らすことができます。

これはまたリクエスト共有とも関連していて、オリジンシールドが有効でない場合は各 POP 単位でリクエスト共有が行われ、例えばアメリカ POP -> オリジン、ヨーロッパ POP -> オリジン、へそれぞれコネクションが1本発生します。オリジンシールドが有効になっていると、更に間に 東京 POP が仲介しているので、同じ URL に対しての世界中からのリクエストに対してリクエスト共有が働き、東京 POP -> オリジンへの1本のリクエストだけの発生に抑えることができます。

<img width="1012" alt="Screen Shot 2020-03-20 at 22.39.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19942/453df778-dbb8-eece-de1a-7b569a249ad0.png">


### HTTP/2

HTTP/2 では1つの TCP コネクション上で複数の HTTP リクエストを多重並列化することができます。これは HTTP/1.1 ではできなかったことです。SSE の場合を考えると、ストリームのコネクションで常時1つのコネクションが張りっぱなしになるため、例えば多くのブラウザで制限されている、1つのドメインに対しての最大同時リクエスト数制限(主に6本であることが多い)、このうち1つを常に消費してしまうことになります。Fastly 上で HTTP/2 を利用することでこの問題を回避できます。

## Fastly で SSE を実装する上での注意点

上記で説明した機能を使うに辺り、注意しなければならない点がいくつかあります。

### サーバは定期的にストリームコネクションを切断すべし (close interval)

例えば、サーバはストリームのコネクションを open してから XX秒後 (例: 30秒後) にコネクションを強制的に閉じる、といった処理をするべきです。なぜなら、コネクションを明示的に切断しない限り Fastly <-> オリジン 間のコネクションはずっと張りっぱなしになってしまいます。コネクションが切断されないとキャッシュオブジェクトへの書き込みが終わらず、新規に SSE 接続してきたクライアントが初回に受け取るデータ量が増え続けてしまいます。また、Fastly <-> オリジンの最大同時接続数制限に達する可能性があります。

### サーバは cacheable なストリーム応答を返すべし

先に説明したように、リクエスト共有の機能は非常に強力で、SSE をスケールさせる上で最も重要なポイントです。
ただしこのリクエスト共有を正しく使うためには、オリジンサーバは必ずストリームのレスポンスを cacheable (キャッシュ可能) なオブジェクトとして返す必要があります。つまり、`Cache-Control` ヘッダーに任意の TTL を正しく設定する必要があります。

### max-age == ストリームの定期切断の interval とせよ

ちょっと分かりにくいのですが、前述の2つの注意点はそれぞれ密接に関係していて、例えばオリジンサーバがストリームコネクションを30秒毎に切断するように構成した場合、`Cache-Control` レスポンスヘッダーに設定するべき TTL `max-age` も同じ interval に設定する必要があります。なので、この場合には `Cache-control: public, max-age=30` とするのが正しいです。または、時刻同期のズレの可能性を考慮して、`Cache-control: public, max-age=29` (TTL を1秒だけ短く) としてもよいでしょう。

こうすることで、オリジンサーバは30秒毎にストリームを切断し、TTL も30秒に設定されているため同時に expire (期限切れ) となり、SSE ストリームの切断を検知したブラウザは自動的にコネクションの再接続を試みるため、新たなコネクションが張られます。

例えば Fastly <-> オリジンサーバ間でストリームを開始して10秒後に新たに接続してきたクライアントは、それまで Fastly がキャッシュしていた10秒間のストリームデータを接続時に一気に取得し、残りの20秒間はリアルタイムにプッシュを受け取ります。


### Streaming Miss を有効にする

```c:vcl_fetch
# サーバからのレスポンスが `Content-Type: text/event-stream` (SSE) の場合には Streaming Miss を有効にする
if (beresp.http.Content-Type ~ "^text/event-stream") {
  set beresp.do_stream = true;
}
```

### ブラウザキャッシュを無効にする

```c:vcl_deliver
if (fastly.ff.visits_this_service == 0) {
  set resp.http.Cache-Control = "private, no-store";
}
```

### オリジンサーバは常に ping (空データ) メッセージを一定間隔でストリームに送信するべし

これはちょっとした落とし穴ですが、データのプッシュがどのタイミングで、どのくらいの間隔で発生するのかは完全にオリジンサーバ側のアプリケーションの実装次第です。例えばデータの更新が激しい場合には毎秒データのプッシュが発生するかもしれませんし、更新がない場合には数十秒間プッシュが行われないかもしれません。Fastly を使う上ではここが問題となります。Fastly ではオリジン毎に各種 timeout のしきい値を[設定できます](https://docs.fastly.com/en/guides/changing-connection-timeouts-to-your-origin)。このうち、`between byte timeout` の値が鍵となります。

これはデフォルトでは `10000 milliseconds` (10秒) となっています。つまり、オリジンとストリームのコネクションを張った後、10秒以上データが送信されてこない場合、タイムアウトとしてエラーになってしまい、正しくキャッシュオブジェクトが作成されません。

このため、必ずオリジンサーバは一定間隔で (between byte timeout の設定値よりも短い間隔で) ping メッセージをストリーム上で送信するように構成してください。keep-alive 目的のなようなものです。

参考実装として、Andrew Betts が公開している `sse-pubsub` npm パッケージを紹介しておきます。

- https://www.npmjs.com/package/sse-pubsub

# まとめ

説明してきたポイントを正しく設定することで、Fastly を使って SSE を最強にスケールさせることができます。是非チャレンジしてみてはいかがでしょうか。