Ayame互換の WebRTC Signaling Server "ayu" を作った

AyameというWebRTC Signaling Serverの実装がある。 Web側のSDKとサーバー側の実装が両方公開されており、プロトコルの仕様も文章化されている。

そして今回、Ayame互換のプロトコルを持つWebRTC Signaling Server "ayu" を開発した。

github.com

動機

Ayameはサーバー側も含めてオープンな実装があるのになぜ新しく互換サーバーを作ったのか。 大きく分けて2つの理由がある。

  • ステートレス
  • カスタマイズ性

ステートレス

Ayameはシグナリングを行うために Room という概念を使う。 名前通り部屋に2人が集まってお互いにシグナリングの情報を交換するやり方だ。 Ayameのメモリ内で現在参加しているクライアント情報を管理する。

f:id:castaneai:20210719130837p:plain

このやり方はシンプルで素晴らしいが、Cloud Runのようなサーバレス環境になると問題がある。 Cloud Runのようなサービスは内部でコンテナが動的に増減するため、リクエストごとに異なるInstanceにアクセスが分散する可能性がある。 そうなると同じRoom IDに接続したはずなのにお互いが相手を認識できずシグナリングが開始できない。

WebSocket の使用  |  Cloud Run のドキュメント  |  Google Cloud

Cloud Runの各インスタンスは自身が状態を持たない、ステートレスである必要がある。

Cloud Run コンテナ インスタンスのステートレスな性質と自動スケーリングにより、Cloud Run サービスに接続するクライアントは、WebSocket 接続から同じデータを受信できない場合があります。

よってCloud Runでサーバー内に状態を保持したい場合は次のように、状態を外部ストレージに保存するとよいらしい。

f:id:castaneai:20210718223024p:plain

https://cloud.google.com/run/docs/triggering/websockets?hl=ja#chatroom-example より引用

ayu ではこの方法に従い、RedisにRoom内の参加クライアント情報を保存するようにした。 これでサーバーの各Instanceはステートレスになり、Instanceが分かれても同じRoom IDを指定すると必ずひとつの状態にたどり着ける。

f:id:castaneai:20210719132036p:plain

カスタマイズ性

Ayameは設定ファイルを設置すると直接実行できるバイナリ形式で配布されている。 通常のLinuxマシンで実行するにはお手軽だが、パブリッククラウド環境で運用するとやりづらい点があった。

まずは先ほども出てきたCloud Runでの実行。 Cloud RunでWebSocketサーバーとして実行するには環境変数 PORT のポート番号でサーバーを立ち上げる必要があり、設定ファイルを動的に変えなければならない。

次にログの扱い。Ayameは標準で特定のファイルとstdoutにログを書き込む仕組みがあるが たとえばGCPのCloud Loggingなどにそのままの形式で流すとログレベルの違いが認識されないし、制御文字が残っていて読みづらい。

f:id:castaneai:20210719134250p:plain

以上の2つの問題を解決するために設定ファイルでは触れないサーバー実装を柔軟にコードでカスタマイズしたくなる。 そうなるとバイナリとして配布するよりGoパッケージとして提供したほうが良いのではないかと考え、ayuは net/http.Handler を実装したサーバーを提供するパッケージとして開発した。

次のように簡単な main.go をひとつ書けば ayu を組み込んだシグナリングサーバーが起動できる。

package main

import (
    "log"
    "net/http"

    "github.com/castaneai/ayu"
    "github.com/go-redis/redis/v8"
)

func main() {
    redisClient := redis.NewClient(&redis.Options{Addr: "x.x.x.x:6379"})
    sv := ayu.NewServer(redisClient)
    defer sv.Shutdown()
    http.Handle("/signaling", sv)

    addr := ":8080"
    log.Printf("listening on %s...", addr)
    log.Fatal(http.ListenAndServe(addr, nil))
}

パッケージとして提供しているので、どのポート番号でサーバーを立てるかは自由に設定できるし、Redis Clientのリトライ設定なども自由に決められる。 認証やログの仕組みをカスタマイズして差し込むこともできる。

作ってみた感想

インメモリに載せていた状態をRedisに変える、と一言で書くと簡単にできそうに見えるが意外とむずかしかった。

2人のクライアントが1つの状態(Roomの状態)を共有するため、排他制御が必要になる。 Ayameの実装ではGoのchannelを利用してRoomの状態を直接触るgoroutineをひとつにすることで排他制御なしでこれを実現している。

しかし、ayuはRoomの状態が外部(Redis)にありayuサーバー自体が複数に分散している可能性があるため同じようにはいかない。 たとえばRoomへ新しいクライアントが参加する場合は

  • 現状のRoomの人数を確認
  • 人数が埋まっていなければクライアントを登録
  • 埋まっていたらエラーを返す

といった順序で処理を行うが複数のサーバーから並列に実行される可能性を考えると上記の処理をAtomicに行う必要が出てくる。 よってRoomの人数を保持するデータはロックをかけて処理する。RedisのSETNXでこれを実現した。

もうひとつむずかしい箇所がある。参加・解散のリアルタイム通知だ。 サーバーが分散している以上Goのchannelは使えないのでRedisのPubSubを使っている。 当然ながらTCP越しの通知になるため、相手側に到達するにはある程度の時間がかかる。 通知を飛ばしたが届く頃には相手はRoomからいなくなっている可能性もある。 また、RedisのPubSubにはBufferingの機能がない。メッセージが飛んできた瞬間に購読しているクライアントのみに届く。その一瞬を逃すとメッセージを受け取れない。 しかし、WebRTCのシグナリング (Trickle ICE) ではoffer/answerを作る前からICE candidateの収集が非同期で始まるため 相手がRoomに参加していない段階でサーバーに届いた候補はBufferingしなければならない。 メッセージを飛ばす度に相手が参加済みかを判断するために外部の共有状態を確認する、といった具合でコードが当初思ったよりも複雑化していった。

とにかく共有状態とそれを扱う分散システムという構図になるため、通信遅延やサーバー障害をあちこちで考慮しなければならない。 片方のクライアントがRoomから抜けて、もう一方のクライアントに通知が届くまでのわずかな隙間に 最初に切断したクライアントが再接続すると…?なんて考え始めると頭が痛くなる。 PubSubによる通知に対してACK的なメッセージを定義して届くまで共有状態を大きくロックするという手も考えたが、ACKが届かずにタイムアウトしたらどうなるか…等考えてさらに頭が痛くなってきた。現状ayuではPubSubの通知はロックしていない。わずかな確率でしか起きない不整合のためにデカいロックを取るよりは、 リトライしたら結果的になんとかなったという方向に期待しようという逃げ(?)でもある。 Ayameのプロトコルは1:1のみという制約もあり高頻度で書き込みが発生するOLTP的なものではない。 軽く実環境で動かしてみて問題なく動作したのでまぁよしとしている。

www.oreilly.co.jp