車輪の再発明がしたくなってWebRTC SFUライブラリを作った

会社で仕事をしていると,どうしても生産性がどうのとか保守性がどうのとかいう思考に囚われてしまう. 仕事をする限り,できるだけ息の長い技術を選択したいし,メンテナンス性が高いものを使いたいと思ってしまう. その結果だいたいどこでも「車輪の再発明をするな」とか言われてしまうんだけど,別に仕事はそれでいい.好きに効率を追い求めて利益を追求したらいい.

でもそれ以外の時間は好きなことをしているので,別に効率とか生産性とかどうでもいいんだよ. むしろ趣味というのは非効率である方が面白いんだよ.利益とか追い求めてないしどうでもいいんだよ.

こういう気持ちになったときはOSSにコミットするというのも一つ手なのだが,いや,ちょっと生産的すぎる.世界に貢献しすぎている. もっと俺の欲望に忠実に無駄なものを作りたくなったので,車輪を再発明してみることにした.


WebRTCというのがある.こいつは元来P2Pでデータをやり取りして,主に映像とか音声をブラウザ間でやり取りする技術だ. そこにSFUというのがある.これは,本来P2Pであったデータのやり取りをサーバ経由で行うものだ. なんでこいうことが必要になるのかはとりあえずここではどうでもいいとして,SFUだ.SFUサーバが作りたい.

RustにはWebRTCのスタックを実装したものがあって

github.com

これで一通りのWebRTCはできる.そして一応このリポジトリにもSFUのexampleがある.だが,examplesを動かしてみても,実用的なWebRTC SFUサーバをこいつから作るには結構な量のコードを書く必要がある.さらに,WebRTCではシグナリングも必要になる.メジャーなのはWebSocketだと思うが,別にここはgRPCでもMQTTでもなんでもいいのだが,とりあえずシグナリングサーバも一緒に作る必要がある. ここの自由度を上げたくなったのでSFUサーバを作る部分をライブラリに切り出して,シグナリングサーバから独立させたパッケージを作りたくなった.

すでにmediasoupがある?今そんな話はしてないんだよ

WebRTC SFUライブラリ

で,作った.Rustで.

github.com

SFUはSelective Forwarding Unitの略称なのだが,全く名前の通りでSFUサーバ自体もWebRTCの一つのピアに過ぎない.ただ単に,一つのピアが中心に居座ってすべてを中継しているだけで,基本的な通信の仕組みはP2PのWebRTCと何ら変わらない.だから別に特別なことをしているわけではないんだが,いかんせん正しく要求されたクライアントに中継してやるロジックが結構多い.これはRustという言語の特性もあるのかもしれんが.

このライブラリは,SFUサーバをRustで作るためのライブラリだ.ただしWebRTCはだいたいブラウザから映像を送信して,ブラウザで見ると思うのでブラウザ側のクライアントライブラリも提供している.これで,シグナリングサーバをRustで作って,クライアントアプリケーションをJavascriptで作れるようになっている.

  • 一般的な音声,映像の送受信
  • DataChannel
  • Simulcast

あたりは普通にサポートできた.

SVCというのも最近はあるのだが,

Scalable Video Coding (SVC) Extension for WebRTC

これについては特許の問題等が不安なので,サポートはしていない.ただしtemporal layerのフィルタリングは実装しているので,「spatial layerの指定はできないけど,temporal layerの指定だけはできる」状態になっている.

ちなみにwebrtc-rsは,VP8やVP9についてはライブラリ側でRTPパケットからtemporal_idのパースをしてくれる.しかしAV1に関してはそういった関数を提供していなかったので,ヘッダーのDependencyDescriptorのパースを自分でやる必要があった.spatial layerの指定を実装しないのに,ここまでやる必要あったか?とも思うのだが,DependencyDescriptorのドキュメントは割と丁寧で,実装するのは結構楽しかったのでヨシ.

relayというかpipeというかfowardingというか

mediasoupみたいなライブラリだとpipeと読んでいる.どう呼ぶのか固定の名前がない気がするんだけど…….SFUサーバはそのままでは基本的にクラスタリングや負荷分散ができない.先に書いた通り,単に一つのピアであるので,こいつが複数に分裂するというのは想定されていない.だから,パフォーマンスはサーバのスペックである程度限界が決まってしまうし,耐障害性のために複数台用意しておくのも難しくなる.

こういう問題を回避するために,pipeとかrelayとかforwardingとか,そういう名前で表現される機能が必要になる.つまり,サーバAがクライアントaから受け取ったRTPパケットは,本来サーバAに接続したクライアント達しか視聴できない.しかしこれを,サーバBに接続したクライアントbが受信できれば,ある意味負荷分散になるのではないだろうか.クライアントが,サーバAに接続しようがサーバBに接続しようが,同じようにクライアントaが送信している映像が見えているのであれば,サーバとしては負荷分散ができている.この思想に基づいてRTPパケットを他サーバに転送できる仕組みも実装してある.

ここではrelayと名前をつけたけど,これはまだそこまで完成度が高くない. とりあえず思いついたものを作ってみたので,UDPを使ってサーバAのRTPパケットをサーバBに転送している.ただしここの通信はWebRTCスタックを利用していない.単にRTPパケットをUDPで送りつけているだけだ.もちろん,送信元や送信先を識別できるようなロジックにしてはいるのだが,DTLSになっていないし受信側もUDPサーバを一つ用意しているだけだ.つまり様々な宛先のRTPパケットが飛んでくる.この辺,パフォーマンスは未知数だ.

おそらくだが,このサーバ間のrelayもWebRTCにしてしまったほうが安定するような気がしている.ただ,いくつか懸念点がある. まずはサーバ間のためのシグナリングが必要になるという点だ.これはクライアントとのシグナリングとは別なので,シグナリングをライブラリ内に内包するのか?という疑問に最後まで答えが出なかった.使うならgRPCかなぁとは思っているのだが.

次にポートだ.WebRTCはUDP通信するときにポートを一つ消費する.そして基本的にはこのポートは使い回されない.つまりサーバ側がクライアントからRTPパケットを受け取るとき,サーバはポートを一つ消費している.このサーバに別のクライアントが接続してきたとき,またポートを一つ消費する.Linuxのポートは65535までしか存在しない上に,若い番号のポートは別の用途に使われるのでUDP通信では使えない.そのため,ポート的にも収容できるユーザに上限がある.さらにサーバ間のRTPパケットのやり取りでポートを消費していくと,これは結構な数のポートが消費されることになる.仮にサーバが受け取ったひとつの映像を10台にrelayするとしたら,そのサーバは受信するために1ポート,relayで送信するために10ポート使うことになる.そこにさらにクライアントが受信するためのポートも追加で消費する.これは結構大きい.

とはいえRTCPどうするか問題が残る

RFC 3550 - RTP: A Transport Protocol for Real-Time Applications

RTCPは受信側が送信側に対してフィードバックを送信するプロトコルで,だいたいのWebRTCにおいて受信側から送信側に送られてくる.ハンドリングするかどうかは好きにしたらいいが,とりあえず送られてくる.ただ,上記のようにrelayサーバ間はRTPパケットを送りつけているだけなので,逆方向の通信となるRTCPはハンドリングできていない.サーバAについては,クライアントaがサーバAにつながっているのでそこに送りつけてやればいいのだが,サーバBに関しては,サーバB -> サーバAという方向の通信を今のアーキテクチャで想定していないのでRTCPを送る方法がない. これを送ろうと思うと,結局双方向通信になってしまうので,UDPで双方向通信するくらいならWebRTC通信にしてしまったほうが楽なのだと思っている.

というわけでここは将来的に変えるかもしれない.

で,使い方とか

どうしてもシグナリングを含まないライブラリなので,シグナリングでwebsocketとかを絡めると使い方が複雑になる.ましてやクライアント側とのやりとりもあるのでね. なのでexampleを用意してあるので,だいたいこのフローでWebSocketサーバを作ると動くようになる.たぶんgRPCとか他のシグナリング方法でも普通に動く.

https://github.com/h3poteto/rheomesh/blob/master/sfu/examples/media_server.rs

対応するクライアント側のexampleがこれ.

https://github.com/h3poteto/rheomesh/blob/master/client/example/multiple/src/pages/room.tsx

まぁ一応対応するフローも全部READMEに書いてあるけど,結構むずいよね.

パフォーマンスとか

一切計測していないので未知数.ローカルで動かしている限りはパフォーマンス問題に出くわさないので,絶望的に悪くて使い物にならんという程ではない.ただ,どこまで行けるのかを確認するにも結構な数のクライアントを用意する必要があり,まだそこまでテストしていないだけ.これがk6だけでいけるかはまだちょっとやってないのでわからん.

また,先程書いたがrelayに関してはUDPのサーバがどの程度のパフォーマンスを出せるかわからない.こちらもサーバの台数を増やして,relayの数を増やしたときにどうなるのかをテストする必要がある.

ただ改善していきたいとは思うので,なんか見つけるたびに改善するとは思う.

そもそもなんでRustで書こうと思ったのか?

特に深い理由はないけど,書いたら面白そうなのがRustだったのと,こういう大量のパケット送りつけてそれを中継して誰かに送りつける,みたいな処理はRustで書いて十分な速さが出るだろうというパフォーマンスに対する信頼もあった. Goでも良かったかもしれないが,Goは結構書き飽きていた. Elixirという選択肢もあったが,これはもしかしたらそのうちやるかもしれん.ただ,同時並列処理に対する信頼感はあるものの,1プロセスですら大量のパケット処理が必要になるものに対してElixir(というかErlang)がどこまで速く処理できるかはちょっと疑問だ.若干この辺のパフォーマンスに対する信頼がない(落ちないという信頼はあるが).

あとがき

よくよく考えたら自作したけどOSSライセンスにしてしまったし公開しているし,これもまたOSSになってしまった…….

webrtc-rsはWebRTCのスタックを一通り実装しているが,元ネタはPionというGoのWebRTCスタックだ.

github.com

これをRustで再実装したものがwebrtc-rsになっている. 今回,SFUの部分だけを実装しているが,「いや,本当の再発明はWebRTCのスタック全部作るべきでは?」というのも思っていたので,もしかしたら俺は最終的にrheomeshのWebRTCスタック部分も再発明するのかもしれない…….本当にメリットなんて一切ないだろうけど,それはそれで面白いのだから良い.