shiichan

リレー + トランシーバで音声を速く! OpenAI の WebRTC 再設計を覗いてみた

やっほー、しぃちゃんだよ! 今日はね、音声 AI を「会話のスピード」で動かすための、めちゃくちゃ渋いインフラの話を見つけちゃったの。わくわくするよ!

OpenAI News openai.com

なにが発表されたの?

OpenAI の News から「How OpenAI delivers low-latency voice AI at scale」っていう記事が出たよ。ChatGPT の音声や Realtime API を支えている WebRTC のスタックを、大規模でも低遅延を保てるように作り直した、っていうエンジニアリング解説なの。

音声って、ネットワークがちょっともたつくだけで、変な間があいたり、割り込みがうまくいかなかったりして、すぐに「不自然」って気づかれちゃうんだよね。だから OpenAI は、週 9 億人以上のユーザーに届く広さ、すぐ話しはじめられる接続の速さ、そして安定した往復遅延(ジッターやパケットロスが少ないこと)、この 3 つを同時に満たす必要があったの。

今までどうだったの?

最初の実装は、Pion をベースにした 1 個の Go サービスで、シグナリングとメディア終端の両方をやっていたの。これが ChatGPT voice や Realtime API の WebRTC エンドポイント、いくつかの研究プロジェクトを動かしてた。

でもね、これを Kubernetes で普通のサービスみたいに動かそうとすると、WebRTC の「1 セッション 1 ポート」モデルが相性最悪だったの。同時接続が増えると、何万個もの UDP ポートを外に公開しないといけなくて、

  • クラウドのロードバランサや Kubernetes は、そんな大量の UDP ポートを想定してない
  • 公開範囲が広がってセキュリティの監査が難しくなる
  • Pod が増えたり減ったり移動したりするオートスケールと、固定ポート予約が噛み合わない

さらに ICE と DTLS はステートフルだから、セッションを作ったプロセスがそのセッションのパケットを受け取り続けないと、接続確認や DTLS ハンドシェイクが壊れちゃう。「少ない固定の UDP 入り口だけ公開して、でも各パケットは必ず正しい所有者に届ける」っていう、ちょっと無理難題なゴールがあったんだ。

これで何が変わるの?

そこで OpenAI が出した答えが、リレー + トランシーバっていう 2 層構成だよ。

  • リレー: パケットを転送するだけの軽い UDP レイヤー。メディアを復号もしないし、ICE の状態機械も動かさない。ちょっとだけメタデータを読んで、宛先のトランシーバに投げるだけ。公開する UDP の面積がすごく小さいの。
  • トランシーバ: WebRTC セッションの状態(ICE 接続確認、DTLS ハンドシェイク、SRTP の鍵、セッションのライフサイクル)を全部持つ、ステートフルな終端サービス。

すごいのは、クライアントから見ると普通の WebRTC のまま、っていうところ。ブラウザやモバイルの互換性はそのままで、中のパケットの流し方だけを変えたんだよ。ほとんどのセッションは 1 対 1(ユーザーと 1 つのモデル)だから、グループ通話向けの SFU じゃなくて、この軽いトランシーバ型が正解だったんだって。

深く潜ってみよう

一番の肝は「最初のパケットをどう振り分けるか」なの。外部の検索サービスに問い合わせて待つんじゃなくて、パケット自身に手がかりを埋め込んじゃうの。

WebRTC には ICE username fragment、通称 ufrag っていう短い識別子がもともとあるんだ。OpenAI はサーバ側の ufrag に、宛先クラスタと所有トランシーバを推測できるだけのルーティング情報を仕込んでおくの。

シグナリングのとき、トランシーバは共有のリレー VIP(リレー群の前に立つ仮想 IP)と UDP ポートを SDP アンサーで返す。クライアントには例えば

203.0.113.10:3478

みたいに 1 個の安定した宛先だけが見える。裏にはたくさんのリレーがいるのにね。クライアントの最初のメディアパケットはたいてい STUN のバインディング要求で、リレーはそこから ufrag だけを読んでルーティングヒントを解読して、所有トランシーバに転送する。あとの DTLS・RTP・RTCP は、もう ufrag を読み直さずにセッションのまま流れていくよ。

リレーの持つ状態はわざと最小限。もしリレーが再起動して状態を失っても、次の STUN パケットが ufrag から作り直してくれる。さらに Redis キャッシュに「クライアントの IP とポート」から「トランシーバの IP とポート」への対応を持たせて、もっと早く復旧できるようにしてるんだって。

そしてこれを世界中に広げたのが Global Relay。地理的に分散したリレーの入り口を置いて、ユーザーの近くでネットワークに入れるようにしたの。シグナリングは Cloudflare の geo / proximity steering で近いトランシーバクラスタに届けて、最初のホップの遅延をぐっと減らす仕組みだよ。

実装は Go で、あえて狭く作ってあるのもポイント。カーネルバイパスみたいな仕組みは使わず、ユーザー空間の普通の Go プロセスでさばいてる。効率化のためにこんな工夫をしてるよ。

  • SO_REUSEPORT で複数のワーカーが同じ UDP ポートを bind して、カーネルにパケットを分散させる
  • runtime.LockOSThread で UDP を読む goroutine を特定の OS スレッドに固定して、同じフローを同じ CPU コアに寄せてキャッシュ効率を上げる
  • バッファを事前確保してコピーを最小化し、Go の GC を減らす

原文はこの設計思想をこう締めくくってるの。

The broader lesson is that the best place to add complexity is in a thin routing layer, not in every backend service, and not in custom client behavior.

複雑さは薄いルーティング層に押し込む、っていう考え方だね。

まとめ

  • ChatGPT voice や Realtime API を支える WebRTC を、リレー + トランシーバの 2 層に作り直したよ
  • 「1 セッション 1 ポート」をやめて、公開する UDP を小さく固定。Kubernetes と相性がよくなった
  • ルーティングは ICE の ufrag に埋め込んで、最初のパケットを外部検索なしで振り分け
  • Global Relay と Cloudflare の geo-steering で、ユーザーの近くから入って遅延を削減
  • リレーは Go 製で、SO_REUSEPORT やスレッド固定など地道な最適化で十分だった

リアルタイム音声や WebRTC を大規模で運用する人、Kubernetes で UDP を扱う難しさに悩んだことがある人には、めちゃくちゃ刺さる設計解説だと思う!