WebRTC 本番運用ガイド:シグナリング、NATトラバーサル、SFUアーキテクチャと10の落とし穴

网络协议

40%のユーザーを切断した通話障害

「ビデオ通話が全滅です。」ダッシュボードは40%の接続失敗を示していた。モバイルネットワークのユーザー、企業ファイアウォールの背後にいるユーザー、CGNAT地域のユーザー——全員が接続できなかった。単一のTURNサーバーが限界に達していた:10,000の同時リレー接続、各2 Mbps、20 Gbpsのトラフィック。カーネルは受信バッファが満杯のためUDPパケットを黙って破棄していた。

WebRTCのデモはlocalhostで動作するが、本番環境では全く異なるレベルの理解が必要だ。これは、遠隔医療プラットフォーム(5,000同時相談)からライブストリーミングサービス(50,000視聴者)まで、4つの本番WebRTCシステムの運用経験に基づく実践ガイドである。


コアコンセプトリファレンス

概念 役割 主要メカニズム 本番への影響
シグナリング メディア前にセッションメタデータを交換 WebSocket/SSE/HTTPロングポーリング 再接続、メッセージ順序、ルーム状態
ICE 対話的接続確立 STUN + TURN候補 85%直接P2P、15%がリレー必要
STUN NAT背後で公開IP/ポートを発見 公開STUNサーバーに問い合わせ 対称NATでは失敗
TURN 直接接続失敗時のメディアリレー 専用リレーサーバー 帯域幅消費大、1ストリームあたり約500 kbps
SFU 選択的転送ユニット デコードせずにストリームをルーティング グループ通話で帯域効率的

シグナリングアーキテクチャ:見えない基盤

シグナリングはWebRTCで最も過小評価されている部分だ。仕様は意図的にこれを未定義のままにしており、各チームが独自に構築する——そしてほとんどが間違える。

本番シグナリングサーバー(Node.js + Socket.IO)

// server/signaling.ts
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { Redis } from "ioredis";

const io = new Server(3000, {
  pingInterval: 10000,
  pingTimeout: 5000,
  connectTimeout: 10000,
});

// 水平スケーリングのためのRedisアダプター
const pubClient = new Redis({ host: "redis" });
const subClient = pubClient.duplicate();
io.adapter(createAdapter(pubClient, subClient));

io.on("connection", (socket) => {
  socket.on("join-room", async ({ roomId, userId }) => {
    const count = await redis.scard(`room:${roomId}:participants`);
    if (count >= 12) {
      socket.emit("error", { message: "ルーム満員" });
      return;
    }
    socket.join(roomId);
    await redis.sadd(`room:${roomId}:participants`, userId);
    socket.to(roomId).emit("participant-joined", { userId });
  });

  socket.on("offer", ({ to, sdp }) => {
    io.to(to).emit("offer", { from: socket.id, sdp });
  });
  socket.on("answer", ({ to, sdp }) => {
    io.to(to).emit("answer", { from: socket.id, sdp });
  });
  socket.on("ice-candidate", ({ to, candidate }) => {
    io.to(to).emit("ice-candidate", { from: socket.id, candidate });
  });
});

シグナリングの注意点

  1. メッセージ順序が重要:ICE候補がSDP answerより先に到着すると、ブラウザはそれを無視する。バッファリングを実装すること。
  2. 再接続:Socket.IOはトランスポート再接続を処理するが、アプリケーションレベルのシグナリング状態復旧が必要。
  3. 古い状態:参加者がクリーンに退出せずに切断すると、エントリが残存する。TTL期限付きRedisキーを使用する。

NATトラバーサル:厳しい現実

NATがP2Pを阻害する理由

NATなし:
  ホストA (1.2.3.4:50000) ←──────────→ ホストB (5.6.7.8:60000)

NATあり:
  ホストA (192.168.1.5:50000) ──NAT──▶ 公開 (1.2.3.4:12345)
  ホストB (10.0.0.3:60000)    ──NAT──▶ 公開 (5.6.7.8:54321)
  
  Aが5.6.7.8:54321にパケット送信 → NAT Bが破棄(マッピングなし)

NATタイプとその影響

NATタイプ P2P可能? TURN必要? 普及率
フルコーン はい いいえ
制限コーン はい いいえ 一般的
ポート制限コーン はい いいえ 非常に一般的
対称NAT いいえ はい 企業、モバイル、CGNAT

実運用では、10-15%のP2P接続でTURNリレーが必要。モバイルネットワークユーザーでは25-30%に跳ね上がる。

TURNサーバー設定(coturn)

# /etc/coturn/turnserver.conf
listening-port=3478
tls-listening-port=5349
relay-ip=10.0.1.5
external-ip=203.0.113.5/10.0.1.5

lt-cred-mech
user=myapp:myapp_turn_secret
realm=myapp.com

max-bps=2000000
no-multicast-peers
no-loopback-peers

cert=/etc/letsencrypt/live/turn.myapp.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.myapp.com/privkey.pem

prometheus

SFUアーキテクチャ:P2Pを超えて

3人以上の通話では、P2Pメッシュは壊滅的に失敗する:

P2P Mesh(4人): 6接続、各人3倍のアップロード帯域
P2P Mesh(8人): 28接続、各人7倍のアップロード帯域
モバイル端末は3-4ストリームのアップロードで限界

SFU: 8人 × 1接続 = 8接続
各人1回アップロード、SFUが他7人に転送
アップロード:1倍。ダウンロード:7倍(下り帯域は豊富)

mediasoup SFU実装

// server/sfu.ts
import * as mediasoup from "mediasoup";

const worker = await mediasoup.createWorker({
  logLevel: "warn",
  rtcMinPort: 40000,
  rtcMaxPort: 49999,
});

const router = await worker.createRouter({
  mediaCodecs: [
    {
      kind: "video", mimeType: "video/VP8", clockRate: 90000,
      parameters: { "x-google-start-bitrate": 1000 },
    },
    {
      kind: "video", mimeType: "video/H264", clockRate: 90000,
      parameters: { "packetization-mode": 1, "profile-level-id": "42e01f" },
    },
    { kind: "audio", mimeType: "audio/opus", clockRate: 48000, channels: 2 },
  ],
});

async function handleNewParticipant(transport, roomId) {
  const producer = await transport.produce({
    kind: "video",
    rtpParameters: clientRtpParameters,
    encodings: [
      { rid: "h", maxBitrate: 1500000, scalabilityMode: "L1T3" },
      { rid: "m", maxBitrate: 500000, scalabilityMode: "L1T3" },
      { rid: "l", maxBitrate: 150000, scalabilityMode: "L1T3" },
    ],
  });
}

Simulcast:1ストリーム3品質

const pc = new RTCPeerConnection();
const stream = await navigator.mediaDevices.getUserMedia({
  video: { width: 1280, height: 720 },
});

const transceiver = pc.addTransceiver(stream.getVideoTracks()[0], {
  sendEncodings: [
    { rid: "h", maxBitrate: 1500_000, scaleResolutionDownBy: 1 },
    { rid: "m", maxBitrate: 500_000, scaleResolutionDownBy: 2 },
    { rid: "l", maxBitrate: 150_000, scaleResolutionDownBy: 4 },
  ],
});

10の本番環境の落とし穴

1:TURNサーバーなし — 10-30%が接続失敗

2:単一TURN — 単一障害点。最低2台。

3:ICE候補トリクリングの無視 — 逐次送信すること

4:getUserMediaに制約なし — 解像度・フレームレートを指定

5:getUserMediaエラー未処理 — 権限拒否など適切に対応

6:通話中のネットワーク切替 — ICE再起動を実装

7:ブラウザ互換性 — Chrome/Firefox/Safariで異なる挙動

8:WebRTC統計未監視 — getStats()で監視必須

9:過剰ビットレート — maxBitrateで制限

10:クリーンアップ忘れ — PeerConnectionを正しくclose()


監視ダッシュボード指標

指標 健全範囲 アラート閾値
ICE接続時間 1-3秒 > 10秒
パケットロス 0-2% > 5%
ジッター 0-30ms > 50ms
RTT 20-80ms > 300ms
フレームレート 25-30 < 15
TURN使用率 10-20% > 30%

まとめ:WebRTC本番デプロイは20%がメディア、80%がインフラだ。最も致命的な3つの問題:TURN未デプロイ(10-30%の接続失敗)、ネットワーク切替未処理(ICE再起動が必須)、全コンポーネント単一サーバー(TURN・シグナリング・SFUそれぞれに独立したスケーリング戦略が必要)。初日から監視を構築し、実際のモバイルネットワークでテストし、localhostで動くデモを決して信じるな。


オンラインツール

ブラウザローカルツールを無料で試す →

#WebRTC#实时通信#STUN#TURN#SFU#音视频#2026