背景と必要性:なぜ現実環境ではダイレクトなP2P接続が失敗するのか
理論上、WebRTCによるビデオ通話やSIPベースのVoIPなどのリアルタイム通信アプリは、シンプルなピアツーピア(P2P)アーキテクチャに依存しています。ユーザーAがユーザーBへ直接UDPパケットをストリーミングするため、低遅延を維持でき、サーバーのコンピュート負荷は無視できるほど小さく、帯域コストもほぼゼロに抑えられます。
しかし、本番環境ではこの理想的な設計が瞬く間に崩れ去ります。多くのデバイスは家庭用ルーター、モバイル回線のキャリアグレードNAT(CGNAT)、あるいは企業内ファイアウォールの内側から接続されています。ノートPCが認識しているアドレスは、192.168.1.105 や 10.0.0.15 といったプライベートIPに過ぎません。パブリックインターネットから見れば、そのような内部IP宛てにはパケットのルーティングが一切不可能なのです。
NAT越え(NAT Traversal)の課題:STUNとTURNの役割
WebRTCやVoIPは、ピア間で通信可能な経路を探索するためにInteractive Connectivity Establishment(ICE)フレームワーク(RFC 8445)を利用します。ICEは2つの重要なサーバー役割を連動させます。
- STUN(Session Traversal Utilities for NAT): STUNはいわば「鏡」のような役割を果たします。クライアントがSTUNサーバーへパケットを送信すると、サーバーはクライアントのパブリックIPとマッピングされたポート(例:
203.0.113.45:49152)を応答として返します。Full-Cone、Restricted Cone、またはPort-Restricted NAT環境下であれば、両エンドポイントがシグナリング経由でこれらのReflexive Candidateを交換し、直接のP2Pメディアフローを確立できます。STUNは接続初期の探索ハンドシェイクのみを処理するため、月額5ドルのVPSが1台あれば、何百万件ものSTUNリクエストを余裕で処理できます。 - TURN(Traversal Using Relays around NAT): 実際の接続のうち約10%〜15%は、4G/5Gのモバイル回線やエンタープライズWi-Fiで広く見られる「Symmetric NAT(対称型NAT)」に遭遇します。Symmetric NATでは、通信先の外部IPごとに異なる外部ポートがルーターによって割り当てられます。このため、直接のUDPホールパンチングは完全に失敗します。ここで登場するのが、認証付きリレープロキシとして機能するTURNです。両ピアは暗号化されたSRTPパケットをTURNサーバーへ直接ルーティングし、サーバーがそのペイロードを相互に中継・転送します。
Googleの stun.l.google.com:19302 などの公開STUNエンドポイントはテスト用途として十分機能します。しかし、無料の公開TURNサーバーというものは実質存在しません。720pの映像を1.5 Mbpsで何千通話もリレーすれば、莫大な帯域コストが発生するためです。通話接続率99%以上を目指すアプリケーションを運用する場合、専用のTURNリレーサーバーの稼働は必須となります。Linuxエコシステムにおいて、そのデファクトスタンダードであり続けているのがcoturnです。
インストール:UbuntuとDebianでのcoturnのセットアップ
インスタンスを立ち上げる前に、インフラに関する前提条件を2点確認してください。
- 専用のパブリックIPv4アドレス。ネットワークインターフェースに直接バインドされているのが理想です。AWS EC2のような1:1 NATの背後にVPSがある場合は、プライベートIPとElastic IP(パブリックIP)のマッピング設定が必要になります。
- ホストを指すAレコード(例:
turn.yourdomain.com)。
1. coturnのインストールとデーモンの有効化
パッケージインデックスを更新し、coturnパッケージをインストールします。
sudo apt update
sudo apt install coturn -y
Debian系ディストリビューションでは、初期状態でcoturnデーモンが無効化されています。/etc/default/coturn の起動フラグを変更して有効化します。
sudo sed -i 's/#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn
2. 共有認証シークレットの生成
本番環境において、静的なユーザー名やパスワードのハードコードは避けるべきです。最新のWebRTCアーキテクチャでは、TURN REST API標準(RFC 5766 / draft-uberti-behave-turn-rest)を用いて一時的なクレデンシャルを発行します。OpenSSLを使用して32バイトの暗号論的シークレットを生成します。
openssl rand -hex 32
出力された64文字の16進数文字列をコピーしておきます。このシークレットはcoturnとバックエンドの認証サービス間で共有されます。
設定:turnserver.confの堅牢化
デフォルトの設定ファイルには何百行ものコメント行が含まれています。バックアップを取り、白紙の状態から作成しましょう。
sudo mv /etc/turnserver.conf /etc/turnserver.conf.bak
/etc/turnserver.conf に新規の設定ファイルを作成します。
sudo nano /etc/turnserver.conf
以下の設定を貼り付けます。YOUR_PUBLIC_IP、turn.yourdomain.com、および YOUR_STATIC_AUTH_SECRET を実際の値に置き換えてください。サーバーが1:1 NATの背後にある場合(内部IPが 172.31.10.5 のAWS EC2インスタンスなど)、external-ip 行を external-ip=YOUR_PUBLIC_IP/172.31.10.5 の形式で記述します。
# 標準リスニングポート
listening-port=3478
tls-listening-port=5349
# IPバインディング
listening-ip=0.0.0.0
external-ip=YOUR_PUBLIC_IP
# ドメインおよびレルム
realm=turn.yourdomain.com
server-name=turn.yourdomain.com
# 動的REST APIクレデンシャル
use-auth-secret
static-auth-secret=YOUR_STATIC_AUTH_SECRET
# リレーポート範囲(UDP)
min-port=49152
max-port=65535
# WebRTC ICE向けパケット検証
fingerprint
# セキュリティおよび不正利用防止の強化
no-cli
no-multicast-peers
no-loopback-peers
stale-nonce=600
# オプション:TURNS(ポート5349)用TLS証明書
# cert=/etc/letsencrypt/live/turn.yourdomain.com/fullchain.pem
# pkey=/etc/letsencrypt/live/turn.yourdomain.com/privkey.pem
# ロギング設定
log-file=/var/log/turnserver/turnserver.log
simple-log
verbose
ここではループバックやマルチキャストへのリレーを制限し、外部クライアントがリレーを経由して内部VPCホストを探索・攻撃するのを防止しています。
3. ファイアウォールルールの設定
TURNリレーには、標準の制御ポートと、メディアストリームを中継するための広範なUDPポート範囲が必要です。UFW環境では以下のコマンドでポートを開放します。
# シグナリングおよび探索用
sudo ufw allow 3478/tcp
sudo ufw allow 3478/udp
sudo ufw allow 5349/tcp
sudo ufw allow 5349/udp
# メディアリレー用上位ポート範囲
sudo ufw allow 49152:65535/udp
# ルールの適用
sudo ufw reload
AWS、Google Cloud、Azureなどをご利用の場合は、セキュリティグループや仮想ネットワークのルールもこれらのポートに合わせて更新してください。UFWの設定だけに気を取られ、クラウド側のファイアウォール設定を見落とすケースは、リレーテスト失敗の最も典型的な原因です。
4. バックエンドでの一時的クレデンシャルの生成
マスターシークレットをクライアントに直接渡してはなりません。代わりに、バックエンドAPIから短時間(通常12〜24時間程度)有効なトークンを発行します。ユーザー名にはUNIX有効期限タイムスタンプと識別子を含め、パスワードにはそのユーザー名に対するHMAC-SHA1署名を使用します。以下は本番環境で使用できるPythonの実装例です。
import base64
import hashlib
import hmac
import time
def get_turn_credentials(secret: str, username_prefix: str, ttl_seconds: int = 86400):
# 有効期限タイムスタンプの生成(例:現在時刻 + 24時間)
expiry_time = int(time.time()) + ttl_seconds
username = f"{expiry_time}:{username_prefix}"
# 共有シークレットを使用してHMAC-SHA1ダイジェストを計算
digest = hmac.new(
secret.encode('utf-8'),
username.encode('utf-8'),
hashlib.sha1
).digest()
password = base64.b64encode(digest).decode('utf-8')
return {
"iceServers": [
{"urls": "stun:turn.yourdomain.com:3478"},
{
"urls": [
"turn:turn.yourdomain.com:3478?transport=udp",
"turn:turn.yourdomain.com:3478?transport=tcp"
],
"username": username,
"credential": password
}
]
}
検証:サーバーのエンドツーエンドテスト
設定を反映するためにcoturnのsystemdユニットを再起動します。
sudo systemctl restart coturn
sudo systemctl status coturn
1. リスニング中のアクティブソケットの確認
ss コマンドを使ってデーモンが正しくリッスンしているか確認します。
sudo ss -ulpn | grep turnserver
turnserver が 0.0.0.0:3478 および 0.0.0.0:5349 にバインドされていることが確認できるはずです。
2. Trickle ICEによるCandidateの検証
設定が機能しているかを勘に頼らず、ブラウザ標準のWebRTCスタックを使って検証します。
- 公式の WebRTC Trickle ICEテストユーティリティ にアクセスします。
- デフォルトで入力されているGoogleのSTUNサーバー項目を削除します。
- 構築したSTUN URI(
stun:turn.yourdomain.com:3478)を追加し、Gather candidates をクリックします。srflx(Server Reflexive)タイプのCandidateが表示され、クライアントのパブリックIPが確認できるはずです。 - 作成したPythonスクリプトを実行して新しいトークンペアを生成します。TURN URI(
turn:turn.yourdomain.com:3478?transport=udp)と、生成された一時的なユーザー名およびパスワードを追加します。 - 再度 Gather candidates をクリックします。Candidateリストを確認し、2〜3秒以内にタイプが
relayのエントリが表示されれば成功です。coturnがHMAC署名を正常に検証し、49152-65535の範囲でメディアポートを割り当てたことが証明されます。
3. アクティブなリレートラフィックの監視
通話が途切れたり音声が届かない場合は、coturnのログを確認して認証やバインディングのイベントを調査します。
sudo tail -f /var/log/turnserver/turnserver.log
正常にアロケーションが行われている場合、以下のようなログが出力されます。
session 000000000000000001: new, realm <turn.yourdomain.com>, username <1760000000:user1>
session 000000000000000001: IPv4. UDP allocate request received
session 000000000000000001: allocation created, relay addr 198.51.100.12:51234
もし 401 Unauthorized エラーが頻発する場合は、バックエンド側のHMACシークレットが static-auth-secret と完全に一致しているか確認してください。また、サーバーで chrony や systemd-timesyncd を通じたNTP時刻同期が稼働していることも必須です。APIサーバーとcoturnの間で時刻ズレ(クロックドリフト)が生じていると、通話接続を試みる前に有効なタイムスタンプが期限切れと判定されて拒絶されてしまいます。

