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 });
});
});
シグナリングの注意点
- メッセージ順序が重要:ICE候補がSDP answerより先に到着すると、ブラウザはそれを無視する。バッファリングを実装すること。
- 再接続:Socket.IOはトランスポート再接続を処理するが、アプリケーションレベルのシグナリング状態復旧が必要。
- 古い状態:参加者がクリーンに退出せずに切断すると、エントリが残存する。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で動くデモを決して信じるな。
オンラインツール
- JSONフォーマッター — シグナリングメッセージペイロードとSDPセッション記述のデバッグ
- Base64エンコード/デコード — base64エンコードされたICE候補と証明書の処理
- ハッシュ計算機 — 長期認証用のTURN認証情報ハッシュを生成
ブラウザローカルツールを無料で試す →
#WebRTC#实时通信#STUN#TURN#SFU#音视频#2026