レイテンシから光速へ:WebRTCとNode.jsでスケーラブルなP2Pビデオ通話を構築する

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

ビデオのリレーに伴う高コスト

ローカルホストでビデオチャットアプリを構築するのは一見簡単そうに見えますが、現実の世界はレイテンシ(遅延)と帯域幅のコストに満ちた混沌とした場所です。多くの開発者は、サーバーがすべてのパケットをリレーする従来のクライアント・サーバー・アーキテクチャから始めます。これはテキストなら機能しますが、ビデオとなると話は別です。720p HDの30fpsストリームは、1ユーザーあたり約2 Mbpsを消費します。通話に50人のユーザーがいる場合、サーバーは単に「リクエストを処理」しているのではなく、毎秒100 Mbpsもの生のメディアデータを送り出すのに苦労することになります。

私はこの教訓を身をもって学びました。かつて標準的なリレー方式を使ってプラットフォームを構築した際、テスターが2人なら問題ありませんでしたが、3人目のユーザーが参加した途端にオーディオが4秒もずれ始めました。ユーザーが10人に達する頃には、ラグのせいで会話が不可能になり、AWSのデータ転送料金は、ある日の午後だけで15ドルから400ドル以上に跳ね上がりました。問題はコードのバグではなく、大帯域のメディアを中央の1点にルーティングするというアーキテクチャ上のボトルネックにありました。

莫大なクラウド予算をかけずにプロフェッショナルなツールを構築するには、ピアツーピア(P2P)通信をマスターするしかありません。重い処理をサーバーから切り離し、ユーザーのハードウェア上で直接行うように移行する必要があります。

NAT問題:なぜブラウザ同士が直接通信できないのか

2つのブラウザを直接接続したい場合、IPアドレスを交換するだけではだめなのでしょうか?現代のネットワーキングがそれを難しくしています。ほとんどのデバイスはNAT(ネットワーク・アドレス変換)や厳格なファイアウォールの背後にあります。あなたのノートPCもおそらく 192.168.1.5 のようなプライベートIPを持っており、これは外部からは見えません。デバイスAがデバイスBを「呼び出そう」としても、デバイスBの公開エントリポイントがわからないため、デジタルの壁にぶつかってしまいます。

WebRTC (Web Real-Time Communication) は、これらの障壁を突破するために設計されました。しかし、自動的に解決してくれるわけではありません。オフグリッド(直接接続)に移行する前に、両者を引き合わせる「仲介役」が依然として必要です。この引き合わせは シグナリング(Signaling) と呼ばれます。

WebRTC의 3つの柱

  • シグナリング: IPアドレス、ポート、コーデックのサポート状況などの接続メタデータを交換するためのアウトオブバンド(帯域外)チャネル。
  • NATトラバーサル (STUN/TURN): デバイスが自身の公開IPを発見するのを助けたり、直接の経路が遮断された場合にフォールバックリレーとして機能したりするサーバー。
  • P2Pストリーミング: ブラウザがSRTP (Secure Real-time Transport Protocol) を使用して、暗号化されたメディアを直接ストリーミングする最終状態。

Node.jsによるシグナリングサーバーの構築

Node.jsとSocket.ioを使用して、シグナリングハブを構築します。このサーバーはビデオデータには触れず、ユーザー間でメモを渡すだけです。これにより、数千人の同時接続ユーザーがいても、リソース消費を非常に低く抑えることができます。

// server.js
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = new Server(server, {
  cors: { origin: "*" }
});

io.on('connection', (socket) => {
  console.log('ピアが接続されました:', socket.id);

  socket.on('offer', (data) => {
    socket.broadcast.emit('offer', data);
  });

  socket.on('answer', (data) => {
    socket.broadcast.emit('answer', data);
  });

  socket.on('ice-candidate', (data) => {
    socket.broadcast.emit('ice-candidate', data);
  });
});

server.listen(3000, () => {
  console.log('シグナリングハブがポート3000でアクティブです');
});

このサーバーは電話交換機のように機能します。ブラウザが独自のトンネルを確立するために必要な、不可欠なネットワーク座標である「Offer(オファー)」、「Answer(アンサー)」、および「ICE Candidate(ICE候補)」を処理します。

ピア接続の実装

クライアントサイドでは、WebRTCのエンジンである RTCPeerConnection APIを使用します。実装ステップを見ていきましょう。

1. ストリームのキャプチャ

まず、カメラとマイクを取得します。これにより MediaStream オブジェクトが返されます。

const localStream = await navigator.mediaDevices.getUserMedia({ 
  video: true, 
  audio: true 
});
document.getElementById('localVideo').srcObject = localStream;

2. 接続の初期化

接続オブジェクトを作成し、STUNサーバーを指定します。Googleの公開STUNサーバーは、開発やテストにおいて信頼できる選択肢です。

const config = {
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
};
const peerConnection = new RTCPeerConnection(config);

// ローカルメディアトラックをピア接続にアタッチする
localStream.getTracks().forEach(track => {
  peerConnection.addTrack(track, localStream);
});

3. ICE候補の交換

ブラウザが潜在的な接続経路を特定すると、ICE候補を生成します。これらをすぐに相手のユーザーにリレーする必要があります。

peerConnection.onicecandidate = (event) => {
  if (event.candidate) {
    socket.emit('ice-candidate', event.candidate);
  }
};

socket.on('ice-candidate', async (candidate) => {
  try {
    await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
  } catch (err) {
    console.error('ICE候補の処理に失敗しました:', err);
  }
});

4. リモートビデオの受信

P2Pハンドシェイクが成功すると、リモートストリームが届きます。これをビデオ要素に接続するだけです。

peerConnection.ontrack = (event) => {
  const [remoteStream] = event.streams;
  document.getElementById('remoteVideo').srcObject = remoteStream;
};

ハンドシェイク:オファーとアンサー

「発信側(caller)」は、Offer(オファー)を作成してプロセスを開始します。これは、自身のハードウェア能力やコーデックを記述したSDP (Session Description Protocol) オブジェクトです。

const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
socket.emit('offer', offer);

「着信側(callee)」はこれを受け取り、自身のリモート記述として設定し、Answer(アンサー)で返答します。双方がお互いの記述を持つと、直接のメディアフローが始まります。

socket.on('offer', async (offer) => {
  await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
  const answer = await peerConnection.createAnswer();
  await peerConnection.setLocalDescription(answer);
  socket.emit('answer', answer);
});

socket.on('answer', async (answer) => {
  await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
});

RTCDataChannelによるデータの直接送信

RTCDataChannel を見逃さないでください。これにより、多くの場合50ms未満のレイテンシで、ユーザー間でJSONやバイナリファイルを直接送信できます。チャットメッセージや共有ステータスにおいて、サーバーを完全にバイパスできます。

const dataChannel = peerConnection.createDataChannel("chat");

dataChannel.onopen = () => dataChannel.send("P2Pメッセージを直接送信しました!");
dataChannel.onmessage = (e) => console.log("メッセージを受信しました:", e.data);

// 受信側のセットアップ
peerConnection.ondatachannel = (event) => {
  const receiveChannel = event.channel;
  receiveChannel.onmessage = (e) => console.log("P2Pデータ:", e.data);
};

本番環境での検討事項

サーバー中心のモデルからP2Pに切り替えるには、ネットワークに対する異なる考え方が必要です。ビデオパケットのデータベースを管理するのではなく、ハンドシェイクのオーケストレーター(調整役)になるのです。パフォーマンスの向上は絶大ですが、エッジケースは巧妙です。

本番環境では、約15〜20%のユーザーが、STUNでは突破できない企業内ファイアウォールや対称型NATの背後にいます。そのため、フォールバックとして coturn のようなTURN (Traversal Using Relays around NAT) サーバーをデプロイする必要があります。シグナリングフローとICE候補の交換を理解し、エッジケースに対応することで、1対1の通話から高速なP2Pファイル転送まで、あらゆるものを処理できる真にスケーラブルなシステムの基盤を構築できたことになります。

Share: