リアルタイム通信 — WebSocket・gRPC ストリーミング・WebRTC
シグナリングからデータチャネルまで WebRTC の 2 つのピアをつなぐ
目標
WebSocketシグナリングサーバーとaiortcのピア2つを作って、オファー・アンサーをやり取りし、信頼性・順序を選んだデータチャネルでメッセージをやり取りしたあとで、ICE候補がどこで生じ、STUNが塞がれると何が遅くなるかを測ります。
なぜ重要なのか
WebRTCは、ブラウザーと携帯電話の間で、音声を最も短いレイテンシで運ぶ標準です。ところが、接続を結ぶ方法は仕様が決めていないので、シグナリング・ICEサーバーの設定・候補の収集を、サービスが自分で責任を負います。「会社のネットワークでだけ接続できない」「接続まで5秒かかる」という障害の大半が、この部分で起こります。同じPodの中なのでNATはありませんが、そのおかげで、候補とSTUNの役割を、余計なものなしに見られます。
ステップ
- シグナリングサーバーを作る: /root/rt/webrtc/signaling.pyにrun(host, port)コルーチンを作ってください。websocketsサーバーで/room/<名前>パスを受け付け、同じ部屋につながった2つの接続の間で、テキストメッセージをそのまま渡します。相手がまだいないときに来たメッセージは、ためておき、相手がつながった瞬間に順番に渡します。3つ目につながる接続は、クローズコード4001、理由"room full"で閉じ、片方が抜けたら、残った側に{"type": "bye"}を送ります。
- オファーSDPを作る: /root/rt/webrtc/peer.pyにmake_offer()コルーチンを作ってください。ICEサーバーのリストを空にしたRTCConfiguration(iceServers=[])でRTCPeerConnectionを作り、"chat"データチャネルを作ったあと、createOfferとsetLocalDescriptionを経て、localDescription.sdp文字列を返します。返す前に、接続はcloseします。
- SDPを読む: /root/rt/webrtc/sdp.pyにsummarize_sdp(sdp)を作ってください。SDP文字列を読んで、dictを返します。キーは、ufrag(最初のa=ice-ufragの値)、fingerprint(最初のa=fingerprintのハッシュ名を小文字で、例: sha-256)、setup(最初のa=setupの値)、media(m=行のメディアの種類を、出てきた順に入れたlist)、candidates({"host": n, "srflx": n, "relay": n}という形で、a=candidate行のtyp別の個数、ない種類も0)の5つです。行末は、\r\nや\nのどちらも来ます。
- アンサーするピア: /root/rt/webrtc/peer.pyにanswer_peer(signal_url, room, out_path)コルーチンを追加してください。signal_url/room/につながって{"type": "offer", "sdp": ...}を待ち、受け取ったら、setRemoteDescription → createAnswer → setLocalDescriptionのあとで{"type": "answer", "sdp": ...}を送ります。相手が開いたデータチャネルのメッセージには、前に"echo:"を付けて返し、チャネルのlabel・ordered・maxRetransmitsをJSONでout_pathに書きます。{"type": "bye"}を受け取ったら、接続を閉じて終わります。
- オファーするピア: /root/rt/webrtc/peer.pyにoffer_peer(signal_url, room, count)コルーチンを追加してください。"chat"データチャネルを作ってオファーを送り、answerを受け取ってsetRemoteDescriptionしたあと、チャネルが開いたら、"m0"からcount個を1つずつ送り、echoを受け取るたびに往復時間を測ります。{"sent": count, "echoed": 受け取った数, "rtt_ms": 往復ミリ秒のlist}を返します。
- 遅れたデータは捨てるチャネル: offer_peerのデータチャネルを、ordered=False、maxRetransmits=0で作ってください。採点ツールの基準のアンサー側が、受け取ったチャネルの属性を記録して確認します。
- 候補がどこから来るかを見る: /root/rt/webrtc/peer.pyにgather(stun=None)コルーチンを追加してください。stunが与えられたらRTCIceServer(stun)を1つ、そうでなければ空のリストをICEサーバーとして使い、データチャネルを1つ作って、createOfferとsetLocalDescriptionにかかったミリ秒を測ります。{"elapsed_ms": ミリ秒, "candidates": [(typ, アドレス, ポート), ...]}を返します。採点ツールは、STUNなし、同じPodの中のSTUNサーバー、届かないSTUNアドレスの3通りで呼び出します。
参考
- 作業フォルダは/root/rt/webrtcです。mkdir -p /root/rt/webrtcで、先に作ってください。
- シグナリングサーバーは、cd /root/rt/webrtc && /opt/rt-lab/bin/python -c "import asyncio, signaling; asyncio.run(signaling.run('127.0.0.1', 8765))"で起動します。ファイル名をsignal.pyにしないでください。標準ライブラリのsignalを隠してしまい、asyncioから壊れます。
- 基準ピアは/opt/fixtures/rt/webrtc/refpeer.py、Pod内のSTUNサーバーは/opt/fixtures/rt/webrtc/stunserver.pyです。
- よくあるミスが2つあります。チャネルが開く前にsendすることと、ICEサーバーを指定せず、塞がれた公開STUNを待って、接続の準備が数秒遅れることです。
- Pythonは、必ず/opt/rt-lab/bin/pythonで実行します。このラボのライブラリは、その仮想環境にだけ入っていて、普通のpython3で実行すると、ModuleNotFoundErrorが出ます。alias rpy=/opt/rt-lab/bin/pythonのように短くしておくと便利です。
- ラボのPodは、外へ出る接続が塞がれています。すべての通信は、同じPodの中の127.0.0.1で行われ、インストールやダウンロードは必要ありません。
- 採点ツールは、コードを別プロセスで読み込んで、実際に接続を張ってみます。例のファイルは関数の枠にすぎないので、そのままでは合格しません。前のステップで完成させた関数は、消さないでください。
- ラボのセッションが終わると、/rootのファイルは残りません。必要なコードは、終える前に別に保管してください。
シグナリングサーバーを作る
/root/rt/webrtc/signaling.pyにrun(host, port)コルーチンを作ってください。websocketsサーバーで/room/<名前>パスを受け付け、同じ部屋につながった2つの接続の間で、テキストメッセージをそのまま渡します。相手がまだいないときに来たメッセージは、ためておき、相手がつながった瞬間に順番に渡します。3つ目につながる接続は、クローズコード4001、理由"room full"で閉じ、片方が抜けたら、残った側に{"type": "bye"}を送ります。
WebRTCの仕様は、SDPをどう渡すかを決めていません(JSEP、RFC 9429も、その部分をアプリケーションに任せています)。そのため、ほとんどすべてのサービスが、このような小さな中継サーバーを別に置きます。4000–4999は、RFC 6455がアプリケーションの分として残しておいたクローズコードです。
オファーSDPを作る
/root/rt/webrtc/peer.pyにmake_offer()コルーチンを作ってください。ICEサーバーのリストを空にしたRTCConfiguration(iceServers=[])でRTCPeerConnectionを作り、"chat"データチャネルを作ったあと、createOfferとsetLocalDescriptionを経て、localDescription.sdp文字列を返します。返す前に、接続はcloseします。
SDPにm=行ができるには、オファーの前に、何を送るか(データチャネルかメディアトラック)を決めておく必要があります。aiortcは、ICEサーバーを指定しないと公開STUNサーバーを使いますが、外へのUDPが塞がれた場所では、その応答を待つために、候補の収集が数秒止まります。
SDPを読む
/root/rt/webrtc/sdp.pyにsummarize_sdp(sdp)を作ってください。SDP文字列を読んで、dictを返します。キーは、ufrag(最初のa=ice-ufragの値)、fingerprint(最初のa=fingerprintのハッシュ名を小文字で、例: sha-256)、setup(最初のa=setupの値)、media(m=行のメディアの種類を、出てきた順に入れたlist)、candidates({"host": n, "srflx": n, "relay": n}という形で、a=candidate行のtyp別の個数、ない種類も0)の5つです。行末は、\r\nや\nのどちらも来ます。
hostは自分のインターフェースのアドレス、srflx(server reflexive)はSTUNサーバーが見た外側のアドレス、relayはTURNサーバーが貸してくれたリレーアドレスです。fingerprintはDTLS証明書のハッシュで、シグナリングで渡ってきたこの値と、ハンドシェイクのときに受け取った証明書が合ってはじめて、接続できます。
アンサーするピア
/root/rt/webrtc/peer.pyにanswer_peer(signal_url, room, out_path)コルーチンを追加してください。signal_url/room/につながって{"type": "offer", "sdp": ...}を待ち、受け取ったら、setRemoteDescription → createAnswer → setLocalDescriptionのあとで{"type": "answer", "sdp": ...}を送ります。相手が開いたデータチャネルのメッセージには、前に"echo:"を付けて返し、チャネルのlabel・ordered・maxRetransmitsをJSONでout_pathに書きます。{"type": "bye"}を受け取ったら、接続を閉じて終わります。
データチャネルはオファーした側が作り、アンサーする側はdatachannelイベントで受け取ります。アンサーするピアでも、ICEサーバーのリストは空にしておいてください。採点ツールの基準のオファー側が、自分のシグナリングサーバーを経由してつながります。
オファーするピア
/root/rt/webrtc/peer.pyにoffer_peer(signal_url, room, count)コルーチンを追加してください。"chat"データチャネルを作ってオファーを送り、answerを受け取ってsetRemoteDescriptionしたあと、チャネルが開いたら、"m0"からcount個を1つずつ送り、echoを受け取るたびに往復時間を測ります。{"sent": count, "echoed": 受け取った数, "rtt_ms": 往復ミリ秒のlist}を返します。
チャネルのopenイベントの前に送ると、メッセージが消えます。ICE確認→DTLSハンドシェイク→SCTP接続がすべて終わってはじめてopenです。ここまでにかかった時間が、WebRTCの接続準備のコストです。
遅れたデータは捨てるチャネル
offer_peerのデータチャネルを、ordered=False、maxRetransmits=0で作ってください。採点ツールの基準のアンサー側が、受け取ったチャネルの属性を記録して確認します。
音声フレームやカーソル位置のように、20ms後には役に立たないデータは、失った1つの断片を再送するために後ろのものまで引き留めるより、捨てるほうがよいです。SCTPは、チャネルごとに順序と再送を別々に決められ、これが、TCPの上のWebSocketでは真似できない点です。
候補がどこから来るかを見る
/root/rt/webrtc/peer.pyにgather(stun=None)コルーチンを追加してください。stunが与えられたらRTCIceServer(stun)を1つ、そうでなければ空のリストをICEサーバーとして使い、データチャネルを1つ作って、createOfferとsetLocalDescriptionにかかったミリ秒を測ります。{"elapsed_ms": ミリ秒, "candidates": [(typ, アドレス, ポート), ...]}を返します。採点ツールは、STUNなし、同じPodの中のSTUNサーバー、届かないSTUNアドレスの3通りで呼び出します。
a=candidate行は、空白で分けると、5番目がアドレス、6番目がポート、typの次が種類です。同じPodの中にはNATがないので、srflxのアドレスが何と同じになるかを、先に予想してみてください。