こんにちは。ABEMAでバックエンドエンジニアのユシンです。本ポストでは、よりTVに近い使用性をご提供することを目的として、従来のAPI通信方式にとどまらず、リアルタイム通信プロトコルであるWebSocket、SSE、Short Polling などをサポートするリアルタイムシステムを導入した事例についてご紹介します。

 

リアルタイム技術とは?

一般的にリアルタイム技術とは、サーバー上で発生した状態変化やイベントを遅延なくユーザーへ伝達する技術を指します。ユーザーによる明示的なリクエストがなくても、サーバー側の変更内容が即座にクライアント画面へ反映される点が主な特徴です。一方、私たちが一般的に利用している従来のHTTPベースのAPI通信は、基本的にRequest-Responseモデルに従っています。つまり、クライアントがリクエストを送信して初めてサーバーがレスポンスを返すことができ、サーバー側から先行してクライアントへメッセージを送信することはできません。このような特性から、画面の更新が必要な場合であっても、クライアントが定期的にサーバーへリクエストを送信する Polling 方式に依存せざるを得ない状況でした。

 

しかし、ABEMAのようなメディアサービスにおいては、このような方式にはユーザー体験の観点から明確な限界があります。例えば、放送開始、番組切り替え、編成変更、ライブ状態の変化など、サーバーの状態変化を即座にユーザーへ反映する必要があるシナリオが数多く存在します。従来のTVでは、放送局側で送出する映像を切り替えるだけで、リアルタイムにユーザーへ画面の変化を伝達することが可能でした。一方、ABEMAでは、配信中のライブ映像の切り替えや一部ユースケースにおけるサーバー状態の伝達は実現できているものの、従来のHTTP通信環境では、サービス全体に対してサーバーが能動的にクライアントへ変更内容を通知することができません。そのため、TVのように即時性の高いユーザー体験を提供することは、構造的に困難な状況でした。これらの課題を解決するため、ABEMAではリアルタイム通信プロトコルをサポートする方針を決定しました。

 

Request-Response Model Realtime Model
クライアントとサーバー間の基本的な通信フローを示すシーケンス図。クライアントからサーバーへリクエストが送信され、サーバーはその処理結果をレスポンスとしてクライアントに返却する、一往復のやり取りを表している。 クライアントとサーバー間でコネクションを維持した通信フローを示すシーケンス図。クライアントがサーバーに接続(Connect)した後、サーバーから複数回にわたってレスポンスがクライアントへ送信される。その後、クライアントからリクエストが送信され、サーバーがレスポンスを返却する流れを表している。ストリーミング通信や双方向通信を想定したモデルを示唆している。

 

プロトコル比較

リアルタイム通信を実装する方式には複数の選択肢があり、各プロトコルは通信方向、接続維持方式、ならびにシステム制約条件に応じて、それぞれ異なる特性を持っています。WebSocket および Server-Sent Events(SSE)は、クライアントとサーバー間で持続的な接続を維持することで、サーバー側で発生したイベントを遅延なく伝播できるため、高いリアルタイム性が求められる機能に適しています。ABEMAでは、これらの特性を活かし、リアルタイムイベント配信のために WebSocket と SSE の両方をサポートしています。一方で、一部のゲームデバイスのように同時接続数に制限がある、もしくは持続的な接続の維持が困難な環境も存在します。このようなデバイス環境を考慮し、ABEMAでは持続的な接続を必要としない Short Polling方式も併せてサポートすることで、さまざまなクライアント条件においても一貫したユーザー体験を提供できるよう設計しています。

 

プロトコル 通信方式 接続維持 メリット デメリット
WebSocket 双方向(Full-Duplex) 持続接続 低レイテンシで、サーバー・クライアント間の即時的な相互作用が可能 接続管理コストが高く、スケーリングおよび障害対応が複雑
SSE (Server-Sent Events) 単方向(Server → Client) 持続接続 実装が比較的シンプルで、HTTPベースのためプロキシ・ファイアウォールとの親和性が高い Client → Server方向のリアルタイム通信が不可
Polling 単方向(Request-Responseの繰り返し) 非持続 既存のHTTPインフラをそのまま活用でき、実装が容易 リクエスト周期に起因する遅延が発生し、不要なトラフィックが増加

 

システムアーキテクチャ

ABEMAでは、リアルタイムプロトコルを各マイクロサービスで個別に実装するのではなく、WebSocket、SSE、Polling をサポートするリアルタイムゲートウェイを別途配置する方式を採用しています。これにより、各マイクロサービスはリアルタイム通信方式や接続状態を直接管理する必要がなく、特定のタイミングでユーザーへイベントを通知したい場合には、リアルタイムサービスに対して標準化された RPC リクエストを送信するだけで、リアルタイムメッセージを配信することが可能です。このような構成により、リアルタイムプロトコルに関する実装および運用の責務をリアルタイムゲートウェイに集約し、マイクロサービス間の結合度を低減するとともに、各サービスが本来のドメインロジックに集中できるようにしています。

クライアントと AbemaTV のリアルタイム配信基盤の構成図。クライアントは WebSocket または SSE を通じて Proxy に接続し、メッセージを受信する。Proxy は AbemaTV 内の realtime-gateway にリクエストを送信し、realtime-gateway と realtime-server は RPC で双方向通信を行う。microservice からのリクエストは realtime-server に送られ、realtime-server がメッセージを Publish し、Proxy 経由でクライアントへ配信されるリアルタイム通信の流れを示している。

 

Fastly Fanout

リアルタイムシステムの実装にあたり、ABEMAでは WebSocket、SSE、Polling を大規模トラフィック環境においても安定的に運用できるインフラが必要でした。その解決策として、Fastly Fanout を採用しています。Fastly Fanout は、CDN エッジレベルで WebSocket および SSE の接続を効率的に管理し、多数のクライアントに対してイベントを fan-out 形式で配信する機能を提供します。これにより、アプリケーションサーバーが大量の持続接続を直接処理する必要がなくなります。その結果、ABEMA ではリアルタイム接続数の増加に伴うサーバー負荷や運用の複雑性を大幅に軽減しつつ、ユーザーの皆様に遅延のないリアルタイム体験を提供できるリアルタイムゲートウェイ構成を実現しました。Fastly Fanout を適用した最終的なシステムアーキテクチャは、以下の構成となっています。

 

Fastly と AbemaTV を組み合わせたリアルタイム配信アーキテクチャの構成図。クライアントは Fastly の Compute に接続を試み、ハンドオフによって Fanout Proxy に接続が引き継がれる。Fanout Proxy は AbemaTV 側の realtime-gateway と Grip Protocol で通信し、realtime-gateway と realtime-server は RPC で双方向通信を行う。microservice からのリクエストは realtime-server に送信され、メッセージが Publish されて API を経由し、Fanout Proxy からクライアントへリアルタイムに配信される流れを示している。

 

 

FastlyとABEMAバックエンドシステムとの関係

(1) 接続プロセス

最初のステップは、クライアントがリアルタイム通信のための接続を確立するプロセスです。クライアントは WebSocket または SSE の接続をリクエストし、このリクエストは最初に Fastly の Compute に到達します。Compute はエッジレベルでリクエストを受信し、認証トークンの検証などの Pre Hand-Off Authentication を実施します。なお、URL パラメータで受け渡す認証情報は、接続確立用途に限定した短期トークンであり、漏えい時の影響を最小化する設計としています。この段階で無効なリクエストはバックエンドまで転送されることなく遮断されるため、ABEMA バックエンドシステムの負荷軽減に寄与します。

認証が完了すると、Compute は該当する接続を Fanout Proxy へ hand-off します。その後、クライアントとの持続的な接続管理は Fanout Proxy が担い、この時点でクライアントと Fastly エッジ間のストリーミング接続が確立されます。ABEMA のバックエンドシステムはこの接続を直接認識または維持することはなく、あくまでイベント配信のための Origin としての役割のみを担っています。

 

Fastly を用いた接続確立フローの詳細を示す構成図。クライアントからのリクエストは Fastly の Compute に送信され、事前ハンドオフ認証(Pre Hand-Off Authentication)が行われた後、Origin(ABEMA backend)へのハンドオフが実施される。Origin は Grip Protocol による OPEN イベントを Fanout Proxy に送信し、レスポンスが返却される。最終的に Fanout Proxy を通じてクライアントとのコネクションが確立される一連の手順(1〜6)を表している。

 

(2) メッセージのPublish(Fanout)プロセス

Publish とは、サーバーで発生したイベントをクライアントへリアルタイムに配信するプロセスを指します。例えば、放送状態の変更、編成の更新、ライブ状態の変化といったイベントが発生した場合、ABEMA のバックエンドシステムは、それらを Publish メッセージ の形式で Fanout Proxy へ送信します。Fanout Proxy は、当該メッセージを購読中のすべてのクライアント接続に対して、fan-out 方式で配信します。

 

この過程において、クライアントとの持続的な接続管理、再送制御、プロトコル差異の吸収といった処理はすべて Fastly Fanout が担います。ABEMA のバックエンドは、単に「どのイベントを、どのクライアントに送信するか」を判断するだけでよく、その結果として、大規模な同時接続環境においても安定的にリアルタイムイベントを配信できる構成を実現しました。

 

Fastly を利用したリアルタイムメッセージ配信フローの概要図。クライアントは Fastly 経由で Fanout Proxy とコネクションを確立し、Origin(ABEMA backend)から Publish されたメッセージを Fanout Proxy が受信する。Fanout Proxy は受信したメッセージをクライアントへ配信し、クライアントは継続的にリアルタイムメッセージを受信する構成を示している。

 

(3) クライアントリクエスト処理プロセス(WebSocket)

WebSocket の場合、接続が確立された後、クライアントは WebSocket 接続上で追加のリクエストを送信することが可能です。例えば、チャットシステムにおけるメッセージ送信などが該当します。これらのリクエストは、Fanout Proxy を経由して WebSocket-over-HTTP 形式で ABEMA のバックエンド(Origin)へ転送されます。

 

バックエンドシステムは、当該リクエストを通常の HTTP リクエストと同様に処理した後、GRIP Protocol を用いてレスポンスを返します。このレスポンスは Fanout Proxy によって、接続中のクライアントへ配信されます。また、接続経由でレスポンスを返すだけでなく、Publish を通じて特定のチャンネルに接続しているすべてのユーザーへメッセージを配信することも可能です。

 

Fastly を介した WebSocket 通信のリクエスト/レスポンスフローを示す構成図。クライアントは接続確立後、コネクション上でリクエストを送信する。Fanout Proxy はそのリクエストを WebSocket-over-HTTP として Origin(ABEMA backend)に転送し、Origin からのレスポンス(GRIP)を受信する。Fanout Proxy はレスポンスまたは Publish されたメッセージをクライアントへ返却し、双方向かつリアルタイムな通信が継続される流れ(1〜4)を表している。

 

(4) コスト

Fastly Compute は、接続確立のタイミングでのみ使用される点が特徴です。Compute は初回の接続リクエストを受信し、認証などの事前処理を実施した後、接続を Fanout Proxy へ hand-off すると、それ以降の通信処理には関与しません。そのため、接続が維持されている間に Compute に対する追加の実行や課金は発生しません。実際にクライアントとの持続的な接続を維持し、メッセージ配信を担うのは Fastly Fanout です。この構成により、課金も Fanout を基準として行われ、主にクライアント接続の維持時間および Publish されたメッセージ数が課金要素となります。

 

リアルタイムシステムの実装

Fastly Compute Fanoutの構築

ABEMA では、Fastly Compute および Fastly Fanout のリソースをすべて Terraform により IaC 形式で管理しています。Terraform 上で Fastly Provider を利用することで、比較的容易に構築することが可能です。

以下の設定において、backend ブロックで定義されている対象が、Fastly Compute から hand-off される実際のバックエンド(Origin)となります。Compute は接続リクエストに対する事前処理や認証を実施した後、それ以降のリクエストおよび接続処理をこの backend へデリゲーションします。

 

resource "fastly_service_compute" "abema" {
  name          = "abema"
  activate      = false
  force_destroy = false

  product_enablement {
    fanout = true
  }

  domain {
    name = "abema.domain.com"
  }

  backend {
    name              = "origin"
    address           = "backend.domain.com"
    port              = 443
    use_ssl           = true
    override_host     = "backend.domain.com"
    ssl_sni_hostname  = "backend.domain.com"
    ssl_cert_hostname = "backend.domain.com"
  }

  resource_link {
    name        = "config"
    resource_id = fastly_configstore.abema.id
  }

  resource_link {
    name        = "secret"
    resource_id = fastly_secretstore.abema.id
  }
}

resource "fastly_configstore" "abema" {
  name = "abema"
}

resource "fastly_secretstore" "abema" {
  name = "abema"
}

 

接続&認証の実装

WebSocket および SSE リクエストには、ヘッダー利用などに関する制約があるため、認証トークンを URL パラメータとして受け渡す必要がありました。そのため、ABEMA では接続および認証のための専用フローを設計・実装しています。なお、URL パラメータで受け渡す認証情報は、接続確立用途に限定した短期トークンであり、漏えい時の影響を最小化しまあす。クライアントはまず、Origin を通じてリアルタイム接続用の短期認証情報を発行し、その情報を含めて Fastly への接続を試みます。Fastly は Edge にて認証情報を検証した後、Origin との通信を通じてユーザーメタデータおよび初期購読チャンネルを取得し、それらを接続にバインドします。これにより、接続確立時点に必要なすべてのコンテキストを安全に設定しています。認証に関するより詳細な内容については、「ABEMA リアルタイム基盤サーバー認証システムの設計および実装 大規模トラフィック環境を経験して成長した話」にてご確認いただけます。

 

リアルタイム接続における認証および接続確立のシーケンス図。まずクライアントがリアルタイム接続用の認証情報を要求し、Origin から短時間有効な署名付きクレデンシャルが返却される。次に接続認証フェーズとして、クライアントはそのクレデンシャルを用いて Fastly 経由でリアルタイム接続を開始する。Fastly は公開鍵を用いてクレデンシャルを検証し、接続に必要なユーザーコンテキストを Origin に要求する。Origin からユーザーメタデータと購読チャンネル情報が返され、それらを接続に紐づけた上で接続が受理され、リアルタイム接続が確立される流れを示している。

 

上記ダイアグラムにおいて、Origin である ABEMA のバックエンドサービスが返却する内容は以下の通りです。Fastly から転送された接続リクエストに対して、ユーザー識別のためのメタデータとともに、当該ユーザーが接続すべき購読チャンネル情報を返却します。これにより Fastly は、接続確立時点に必要なコンテキストを接続へバインドすることが可能となります。レスポンスの形式は GRIP Protocol に準拠しています。

 

(1) Websocketの場合
HTTP/1.1 200 OK
Content-Type: application/websocket-events

c:{"type":"subscribe","channel":"abema"}
c:{"type":"set-meta","name":"user","value":"abema-user"}
(2) SSEの場合
HTTP/1.1 200 OK
Content-Type: text/event-stream
Grip-Hold: stream
Grip-Channel: abema
Grip-Set-Meta: user=abema-user

 

メッセージ送信の実装

ABEMAのリアルタイムシステムでは、WebSocketやSSEといった個別プロトコルの差異を各マイクロサービスが直接意識する必要がないよう、「メッセージ送信」という概念を一つの抽象化されたインターフェースとして定義しています。これにより、各ドメインサービスはリアルタイムプロトコルの実装詳細を把握することなく、ユーザーへ届けたいメッセージの定義および送信にのみ集中することが可能となります。

インターフェースにはgRPCベースの設計を採用し、oneofを活用して送信可能なメッセージタイプをProto上で定義する構成としています。新たなリアルタイムイベントを追加する場合においても、Protoにメッセージタイプを定義するだけで即座に利用できるため、リアルタイム技術をマイクロサービス全体へ容易に拡張できるアーキテクチャを実現しています。

 

message MessageBody {
  oneof body {
    BodyTypeA body_a = 1;
    BodyTypeB body_b = 2;
  }
}

message SendMessageRequest {
  MessageBody body = 1;
}

service RealtimeService {
  rpc SendMessage(SendMessageRequest) returns (SendMessageResponse);
}

 

メッセージは内部的に単一の抽象化された形式として生成され、これを基にWebSocketおよびSSEの双方へ同時に配信されます。WebSocketの場合は、メッセージ本文をJSON形式にSerializeして送信し、SSEの場合は、同一のメッセージをSSEの仕様に準拠し、id、event、dataフィールドを含む形式へ変換して配信いたします。これにより、内部のメッセージモデルは一元的に維持しつつ、各クライアントは利用しているプロトコルに応じた形式で、同一のイベントを受信できるよう実装しています。

 

WebSocket SSE
{
  "id": "message-id",
  "message": "hello, world"
}
id: message-id
event: message
data: {"id":"message-id","message":"hello, world"}

 

メッセージ状態管理

このような状態メッセージは、一過性のイベントとは異なり、システム内で常に最新の状態を維持する必要があります。そのため、メッセージが発行された時点で接続していたクライアントだけでなく、その後に新規接続または再接続したクライアントに対しても、同一の状態を配信する必要があります。ABEMA のリアルタイムシステムでは、これらの要件を満たすために状態メッセージを別途管理し、クライアントが接続したタイミングで現在有効な状態を即座に配信できるよう設計しています。

これにより、クライアントはメッセージの発行タイミングを逃した場合であっても、接続時点の最新状態を基に画面を構成することが可能となります。その結果、放送状態や番組切り替えのように現在の状態が重要となるシナリオにおいても、一貫したユーザー体験を提供できます。このように、リアルタイムイベント処理と状態同期を明確に分離することで、複雑なクライアントロジックを必要とせず、安定したリアルタイム機能を実現できる構成としています。

WebSocket 接続を Fastly 経由で確立する際の通信シーケンス図。クライアントが WebSocket 接続を開始すると、Fastly は WebSocket-over-HTTP として Origin に対して HTTP リクエスト(OPEN)を送信する。Origin からの HTTP レスポンスを受けた後、制御メッセージ(subscribe)やテキストメッセージ(data1、data2)が Fastly を通じてクライアントに配信される。最終的に WebSocket 接続が確立され、データが継続的にクライアントへ送信される流れを示している。

Fastly Fanoutでは、クライアントが新規に接続、または再接続する際に、Originが返却するレスポンスを通じて、これらの状態メッセージを配信することが可能です。つまり、メッセージをPublishした時点で接続していなかったクライアントであっても、接続時にOriginから現在有効な状態を含むレスポンスを受け取ることで、同一のメッセージを受信できます。 以下は、接続時のコントロールメッセージに加え、通常のテキストメッセージも併せて送信するWebSocket接続レスポンスの一例です。

HTTP/1.1 200 OK
Content-Type: application/websocket-events

OPEN
TEXT 40
c:{"type":"subscribe","channel":"abema"}
TEXT 56
c:{"type":"set-meta","name":"user","value":"abema-user"}
TEXT 46
{"id": "message-id","message": "hello, world"}

 

結論

本ポストでは、ABEMA においてリアルタイムなユーザー体験を提供するために導入したリアルタイムシステムの構成と、Fastly Fanout を活用した WebSocket・SSE ベースのメッセージ配信方式についてご紹介しました。リアルタイムプロトコルの差異をゲートウェイ側で抽象化することで、各マイクロサービスが容易にリアルタイム機能を活用できる構成を実現しています。実運用環境では、本記事で取り上げた内容に加え、TCP Idle Timeout 防止のための Keep-alives や、メッセージの重複配信を防ぐための Message De-duping など、Fastly Fanout および Pushpin が提供するさまざまな機能も併せて活用しています。このような構成および運用経験を基に、ABEMA のリアルタイムシステムは現在も継続的に改善を重ねており、今後もより良いユーザー体験を提供できるよう進化していく予定です。最後までお読みいただき、ありがとうございました。

 

REFERENCE

  • https://www.fastly.com/documentation/guides/concepts/real-time-messaging/fanout/
  • https://pushpin.org/docs/about/
  • https://protobuf.dev/programming-guides/proto3/#oneof
  • https://datatracker.ietf.org/doc/html/rfc6455
  • https://developer.mozilla.org/ja/docs/Web/API/Server-sent_events/Using_server-sent_events