SaaS を作っていると、「音声通話ほしい」って話になることがある。Slack の Huddle とか Discord のボイスチャンネルみたいなやつ。
実際に自分のプロダクトに音声通話を組み込んでみて、技術選定で「これ先に知っておきたかったな」ってことがけっこうあったので整理しておく。
この記事でわかること:
- HTTP や WebSocket じゃなくて WebRTC じゃないとダメな理由
- WebRTC を自前で組むとどれくらい地獄か
- LiveKit Cloud がその地獄をどう肩代わりしてくれるか
- アプリ側で実際に書くコードの量
なぜ WebRTC なのか — HTTP / WebSocket じゃダメな理由
TCP と UDP の話
音声通話の技術選定を理解するには、TCP と UDP の違いが前提になる。
インターネットでデータを送るとき、データは「パケット」っていう小さい塊に分割されて飛んでいく。で、インターネットは不安定なので、パケットが途中で消えたり順番が入れ替わったりする。この不安定さにどう向き合うかが TCP と UDP の違い。
TCP は「正確に、順番通りに届ける」派:
- パケットが届いたら毎回確認する
- 届かなかったら再送する
- 順番通りに並べ替える
UDP は「とにかく速く届ける」派:
- 届いたか確認しない
- 届かなくても再送しない
- 順番も保証しない
音声通話に TCP が向かない理由
音声通話で「あいうえお」を送ったとき、「う」のパケットが消えたとする。
TCPの場合:
「あ」「い」まで再生 →「う」が届くまで全体が止まる → 0.2秒後に再送で届く
→ 結果: 0.2秒のフリーズ
UDPの場合:
「あ」「い」再生 →「う」は来なかった →「え」「お」をそのまま再生
→ 結果: 一瞬プツッとなるだけ
0.2秒前の音声を今さら再送されても困る。会話はもう先に進んでるんだから。通話中に一瞬途切れるのと全体がフリーズするのでは、前者のほうが圧倒的にマシだ。
WebSocket じゃダメなの?
「WebSocket って双方向リアルタイム通信できるし、それでよくない?」って思うかもしれない。
自分も最初そう思った。でもダメ。理由は2つ。
1. WebSocket は TCP ベース
さっき書いた通り、パケットロス時に再送待ちが発生する。テキストチャットなら問題ないけど、音声には致命的。
2. メディア処理が何もない
WebSocket はただのデータ通信の仕組みでしかない。音声の圧縮(コーデック)、エコーキャンセル、ノイズ抑制みたいな音声通話に必要な処理は全部自分で書くことになる。
WebRTC ならこれがブラウザに最初から入ってる。
| WebSocket | WebRTC | |
|---|---|---|
| プロトコル | TCP | UDP(SRTP) |
| 音声コーデック | 自前実装 | ブラウザ組み込み(Opus) |
| エコーキャンセル | 自前実装 | ブラウザ組み込み |
| ノイズ抑制 | 自前実装 | ブラウザ組み込み |
| 主な用途 | チャット、通知、データ同期 | 音声・映像通話 |
WebRTC を自前で組む大変さ
「WebRTC 使えばいいのね、了解」ってなるんだけど、ブラウザ同士が繋がるまでに3つの壁がある。これがなかなかしんどい。
壁1: シグナリング — お互いの情報を交換する仕組みがない
通話を始めるには「私はこういう環境です」っていう情報を交換しないといけない。
具体的には:
- 対応コーデック: 音声の圧縮方式(Opus 等)。お互い同じ方式を使えないと通話できない
- ネットワークアドレス: 相手がデータを送る先の IP アドレスとポート番号
で、WebRTC 自体にはこの交換の仕組みがない。 自分で「シグナリングサーバー」を建てて中継しないといけない。
AさんのPC → シグナリングサーバー → BさんのPC
「私の情報」 (WebSocket等で中継) 「相手の情報を受け取る」
壁2: NAT越え — 相手に直接届かない
NAT(Network Address Translation) っていうのは、ルーターが「内側のプライベートIP」と「外側のグローバルIP」を変換する仕組みのこと。
家やオフィスの PC はルーターの内側にいる。
家のネットワークだと、PC もスマホもタブレットも全部 192.168.x.x みたいなプライベート IPを持っていて、外に出るときにルーターが1つのグローバル IP に変換して送り出す。
逆に言うと、外の世界から「192.168.1.5のあの PC」に直接アクセスする方法がない。
Aさんの家 Bさんの家
┌───────────────────┐ ┌───────────────────┐
│ PC: 192.168.1.5 │ │ PC: 192.168.1.3 │
│ ↓ │ │ ↓ │
│ ルーター(NAT) │ │ ルーター(NAT) │
│ 203.0.113.10 │ │ 198.51.100.20 │
└────────┬──────────┘ └────────┬──────────┘
│ ??????? │
└──────── 直接繋がれない ────────────┘
A「自分のアドレスは 192.168.1.5 だよ」
B「…いや、それ外から届かないんだけど」
WebRTC は端末同士が直接データを送り合う仕組みだから、この NAT
が壁になる。お互い「自分のアドレスはこれです」って言いたいのに、プライベート IP しか知らない。
これを解決するのが STUN サーバー と TURN サーバー。
【STUN】自分の外向きアドレスを教えてくれる
PC → STUNサーバー
「私の外向きアドレス何ですか?」
← 「203.0.113.10:54321 だよ」
→ これで相手に「ここに送って」と伝えられる
【TURN】どうしても直接繋がれないときの中継役
A → TURNサーバー → B
← ←
直接繋がれないなら、間に立って転送する
STUN で解決できればベスト(直接通信なので速い)。でも企業のファイアウォールとかで STUNでも貫通できないケースがあって、そのときは TURN で中継する。これも自前で用意しないといけない。
壁3: 3人以上の通話 — P2P の限界
P2P(Peer-to-Peer) っていうのは、サーバーを介さずに端末同士が直接データをやり取りする方式のこと。WebRTCは基本的にこの P2P で動く。
2人なら直接繋げばいい。でも3人以上になると、全員が全員に直接音声を送ることになる。4人で6本、10人で45本の接続。CPU も帯域も爆発する。
P2P(2人): P2P(4人):
A ←→ B A ←→ B, A ←→ C, A ←→ D
B ←→ C, B ←→ D
C ←→ D
= 6本の接続
これを解決するのが SFU(Selective Forwarding Unit) というサーバー。各端末は SFU に1本だけ送って、SFU が全員に配る。
SFU方式:
A → SFU → B, C, D
B → SFU → A, C, D
各端末の負荷は人数に関係なく一定
この SFU の構築・運用が一番ヤバい。 まともにやろうとすると相当な技術力とインフラコストがかかる。
自前で用意するものまとめ
| サーバー | 役割 |
|---|---|
| シグナリングサーバー | 接続情報の交換 |
| STUN サーバー | NAT越え(アドレス取得) |
| TURN サーバー | NAT越え(データ中継) |
| SFU サーバー | 多人数通話の中継 |
音声通話を1つ実現するのにサーバーが4種類。これが「自前は地獄」の正体。
LiveKit Cloud という選択肢
LiveKit Cloud は、上の4つを丸ごと引き受けてくれるサービスだ。
┌─────────────────────────────────────┐
│ LiveKit Cloud がやること │
│ │
│ - シグナリング │
│ - STUN / TURN │
│ - SFU(多人数通話の中継) │
│ - グローバル分散配置 │
│ - 録音(Egress)→ S3保存 │
│ - Room Metadata(状態の即時配信) │
│ - Webhook(イベント通知) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ アプリ側が作ること │
│ │
│ - JWT トークンの生成 │
│ - セッション管理(誰がどの部屋に) │
│ - Webhook の受信と処理 │
│ - フロントエンドの UI と接続管理 │
└─────────────────────────────────────┘
実際に書くコード量
これを見てほしい。
バックエンド(Go): トークン生成(10行くらい)
import "github.com/livekit/protocol/auth"
at := auth.NewAccessToken(apiKey, apiSecret)
grant := &auth.VideoGrant{
RoomJoin: true,
Room: roomName,
CanPublishSources: []string{"microphone"},
}
at.SetVideoGrant(grant).
SetIdentity(userID).
SetValidFor(10 * time.Minute)
token, err := at.ToJWT()
フロントエンド(TypeScript): 接続(5行くらい)
import { Room } from "livekit-client";
const room = new Room();
await room.connect(livekitUrl, token);
await room.startAudio();
await room.localParticipant.setMicrophoneEnabled(true);
// これだけで音声通話が始まる
さっきの「サーバー4種類自前で建てる」話のあとにこれ見ると、かなり楽であるのは一目瞭然。
競合サービスとの比較
LiveKit 以外にも選択肢はある。
| LiveKit | Agora | Twilio | |
|---|---|---|---|
| OSS | あり(セルフホスト可) | なし | なし |
| ロックイン | 低い | 高い | 高い |
| 強み | 柔軟性、AI エージェント連携 | 大規模通話の品質最適化 | SMS/Email/Auth との統合 |
| 無料枠 | 月5,000分 | 月10,000分 | 従量制 |
LiveKit を選んだ一番の理由は OSS でセルフホストできること。最初は Cloud で楽をして、規模が大きくなったら自前サーバーに移行できる。ベンダーロックインを避けられるのは SaaS を作る側としてかなり安心感がある。
アプリ側が作るものの全体像
LiveKit Cloud を使った場合、ユーザーの操作フローに沿って何が起きるかを整理するとこうなる。
① ユーザーが「参加」ボタンを押す
② フロントエンド → バックエンド API
「このルームに参加したい」
③ バックエンド ← アプリの責務
- ユーザーの権限チェック
- セッション(通話部屋)の作成/取得
- JWT トークン生成
- { token, url } を返す
④ フロントエンド → LiveKit Cloud ← LiveKit の責務
- WebSocket 接続
- シグナリング / NAT越え / SFU
- 全て自動
⑤ 通話中
- 音声の送受信 ← LiveKit の責務
- 「誰が参加中か」のDB管理 ← アプリの責務
- ハートビート(生存確認) ← アプリの責務
- UIの状態管理 ← アプリの責務
⑥ LiveKit Cloud → バックエンド ← 連携
- 「参加者が入った」Webhook
- 「参加者が抜けた」Webhook
- 「録音が終わった」Webhook
- バックエンドでDB更新 ← アプリの責務
ここで大事なのは、LiveKit は電話回線を提供してくれるだけってこと。電話帳も通話履歴も、誰が誰と話してるかの管理も、全部アプリ側の仕事。ここの認識がズレてると設計で痛い目を見る。
まとめ
- 音声通話には WebRTC(UDP ベース)が必要。 TCP ベースの HTTP / WebSocket だと再送待ちの遅延が致命的
- WebRTC を自前で組むにはサーバーが4種類必要。 特に SFU の運用がしんどい
- LiveKit Cloud はその全部を肩代わりしてくれる。 アプリ側はトークン生成 + 接続コード + ビジネスロジックだけ
- LiveKit は OSS。 ロックインを避けつつ、最初は Cloud で楽ができる
この先の記事で扱うこと
この記事では「なぜ WebRTC か、なぜ LiveKit か」の全体像を掴んだ。ただ、実際にプロダクションで動かすとなると、トークン設計、セッション管理、Webhook の信頼性設計、ブラウザ互換性とか、まだ考えることはたくさんある。続編では LiveKit の中核モデル(Room / Participant / Track)を掘り下げる予定。