MENU

問い合わせ


    【NuxtとWebRTCで作るビデオチャット 第6回】STUN/TURN と本番デプロイ:「社外とつながらない」を解消する

    前回の第5回:ミュートとカメラオフまでで、1対1のビデオ通話に必要な画面と操作がひととおりそろいました。ただし、ここまでの確認はすべて「同じPCの中」です。第3回から「社外の相手とつながる保証はない」と書いてきた問題に、今回向き合います。

    「社内のテストでは問題なく通話できたのに、取引先とつなごうとしたら映像が出なかった。何が足りないの?」

    結論から言うと、足りないのは多くの場合 TURN サーバー(=直接つながらないときに映像・音声を中継するサーバー)です。WebRTC は端末どうしの直接通信を試みますが、会社のファイアウォールや携帯回線の仕組みによっては直接の経路が見つかりません。そのときの「最後の手段」が TURN です。今回は、TURN の認証情報を安全に発行する API を Nuxt に追加し、TURN 経由だけで通話できることを確認したうえで、HTTPS 環境へのデプロイ構成をまとめます。

    目次

    今回作るもの:STUN/TURN の設定配布とデプロイ構成

    • GET /api/ice-servers:STUN/TURN の設定と、時間制限付きの TURN 認証情報を返す API
    • 入室時にこの API を呼び、RTCPeerConnection の設定(iceServers)に使う
    • 接続中の経路の種類(直接/TURN 経由)を画面に表示する
    • 動作確認用に「TURN 経由でしか接続しない」モードを環境変数で切り替えられるようにする
    • 本番構成:Nuxt を Node サーバーとして起動し、HTTPS のリバースプロキシ(nginx)配下に置く

    追加・変更するファイル

    ファイル役割
    code/server/api/ice-servers.get.tsSTUN/TURN 設定と TURN 認証情報を返す API(新規)
    code/shared/types/ice.tsAPI のレスポンスの型(新規)
    code/nuxt.config.ts / code/.env.exampleruntimeConfig に TURN の設定項目を追加
    code/app/composables/usePeerConnection.ts設定を接続のたびに受け取る形に変更、経路の種類を取得
    code/app/pages/room/[id].vue入室時に API を呼ぶ、経路の表示
    package.json型チェック用に @types/node(Node 24 系)を devDependencies に追加

    仕組み:host・STUN・TURN、3種類の経路

    第3回で、ICE 候補(=「この宛先なら届くかもしれない」という経路の候補)を交換すると説明しました。候補には主に3種類あります。

    候補の種類中身必要なサーバーつながる条件の目安
    host端末自身が持つIPアドレスなし同じPC・同じLAN内
    srflx(サーバー再帰)ルーターの外側から見た自分のアドレスSTUN サーバー多くの家庭用・一般的なNAT
    relayTURN サーバー上に借りた中継用のアドレスTURN サーバー直接つながらない場合の最後の手段
    • STUN サーバーは「外から見て、私のアドレスは何番ですか?」と聞くためだけのサーバーです。映像は通らないので負荷は小さく、認証もしないのが一般的です。
    • TURN サーバーは映像・音声そのものを中継します。企業のファイアウォールが UDP を通さない、対称型NAT(=相手ごとに外側のポートが変わるNAT)どうし、などで直接の経路が見つからないときに使われます。映像がサーバーを通るため、通信量に比例して帯域と費用がかかります。

    TURN は誰でも使えると「他人のための無料中継サーバー」になってしまうので、認証が必須です。一方、ブラウザに渡した認証情報はブラウザの開発者ツールで誰でも見られます。そこで、サーバーだけが共有シークレット(=TURN サーバーと Nuxt サーバーだけが知っている鍵)を持ち、ブラウザには有効期限付きの認証情報だけを渡す方式を使います。

    OSS の TURN サーバー coturn では、この方式を「TURN REST API」と呼び、次のように認証情報を作ります。

    • ユーザー名 = 「タイムスタンプ(UNIX時間の秒):任意のID」
    • パスワード = ユーザー名を共有シークレットで HMAC-SHA1 した値を Base64 にしたもの

    📰 出典:coturn「README.turnserver」

    タイムスタンプの意味は、今回の検証で確認しました。過去の時刻を入れた認証情報は、coturn のログに「time-limited username timestamp is in the past(期限切れ)」と出て拒否されます。つまりタイムスタンプは「有効期限」として扱われます。

    実装1:設定項目を runtimeConfig に追加する

    code/nuxt.config.ts(runtimeConfig 部分)

      runtimeConfig: {
        // 秘密情報は .env (NUXT_* 環境変数) から注入する。コードに直書きしない。
        // TURN の共有シークレット(coturn の static-auth-secret と同じ値)。サーバー側だけで使う
        turnSecret: '', // NUXT_TURN_SECRET
        // 例: turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?transport=tcp,turns:turn.example.com:5349
        turnUrls: '', // NUXT_TURN_URLS
        // ブラウザに渡す TURN 認証情報の有効期間(秒)
        turnTtlSeconds: 3600, // NUXT_TURN_TTL_SECONDS
        // 'relay' にすると TURN 経由だけで接続する(TURN の動作確認用)。通常は 'all'
        iceTransportPolicy: 'all', // NUXT_ICE_TRANSPORT_POLICY
        public: {
          // 例: stun:stun.example.com:3478(カンマ区切りで複数可)
          stunUrls: '', // NUXT_PUBLIC_STUN_URLS
        },
      },

    Nuxt の runtimeConfig は、NUXT_ で始まる環境変数で上書きできます。public の外に置いた値(turnSecret など)はサーバー側でしか読めないので、共有シークレットがブラウザ向けの JavaScript に含まれることはありません。実際の値は .env(git 管理外)かサーバーの環境変数で渡し、リポジトリには値を空にした .env.example だけを置きます。

    📰 出典:Nuxt「Runtime Config」

    実装2:TURN 認証情報を発行する API

    code/server/api/ice-servers.get.ts

    import { createHmac, randomUUID } from 'node:crypto'
    import type { IceConfigResponse, IceServer } from '#shared/types/ice'
    
    /** カンマ区切りの URL 一覧を配列にする */
    function splitUrls(value: string): string[] {
      return value.split(',').map(s => s.trim()).filter(Boolean)
    }
    
    export default defineEventHandler((event): IceConfigResponse => {
      const config = useRuntimeConfig(event)
      const iceServers: IceServer[] = []
    
      // STUN: 自分のグローバルIP・ポートを知るためのサーバー(認証なし)
      const stunUrls = splitUrls(config.public.stunUrls)
      if (stunUrls.length > 0) iceServers.push({ urls: stunUrls })
    
      // TURN: 直接つながらないときに映像・音声を中継するサーバー(認証あり)
      let expiresAt: number | null = null
      const turnUrls = splitUrls(config.turnUrls)
      if (turnUrls.length > 0 && config.turnSecret) {
        expiresAt = Math.floor(Date.now() / 1000) + Number(config.turnTtlSeconds)
        const username = `${expiresAt}:${randomUUID()}`
        const credential = createHmac('sha1', config.turnSecret).update(username).digest('base64')
        iceServers.push({ urls: turnUrls, username, credential })
      }
    
      // 認証情報を含むのでキャッシュさせない
      setResponseHeader(event, 'Cache-Control', 'no-store')
    
      return {
        iceServers,
        iceTransportPolicy: config.iceTransportPolicy === 'relay' ? 'relay' : 'all',
        expiresAt,
      }
    })

    server/api/ に ice-servers.get.ts を置くと、Nuxt(Nitro)が GET /api/ice-servers として公開します。ユーザー名の後半は randomUUID() で毎回変えています。本番でログイン機能があるなら、ここを社員IDなどにしておくと、TURN のログから「誰の通信か」を追えるようになります。

    TURN を設定していない場合は、空の iceServers が返ります。この場合でも、同じPC・同じLAN内であれば第5回までと同じようにつながります。

    実装3:接続のたびに設定を使う

    usePeerConnection の変更

    これまで usePeerConnection は、設定(rtcConfig)を最初に1回受け取るだけでした。TURN の認証情報には有効期限があるので、接続を作るたびに最新の設定を取り出す関数を受け取る形に変えます。

    code/app/composables/usePeerConnection.ts(変更部分)

    export function usePeerConnection(
      signaling: SignalingChannel,
      getLocalStream: () => MediaStream | null,
      // 第6回: STUN/TURN の設定。接続を作るたびに呼ぶ(TURN の認証情報は時間制限付きのため、固定値にしない)
      getRtcConfig: () => RTCConfiguration = () => ({}),
    ) {
      /** 第6回: 実際に使われている経路の種類(host=直接 / srflx=STUNで分かったアドレス / relay=TURN中継 など) */
      const routeType = ref<RTCIceCandidateType | null>(null)
    
      // connect() の中
        const conn = new RTCPeerConnection(getRtcConfig())
    
        conn.addEventListener('connectionstatechange', () => {
          connectionState.value = conn.connectionState
          if (conn.connectionState === 'connected') void updateRouteType(conn)
        })
    
      /** 第6回: getStats() で、選ばれた経路(候補ペア)の自分側の候補の種類を調べる */
      async function updateRouteType(conn: RTCPeerConnection) {
        const stats = await conn.getStats()
        let pair: RTCIceCandidatePairStats | undefined
        stats.forEach((report) => {
          // 標準は transport の selectedCandidatePairId。Firefox 向けに candidate-pair の selected も見る
          if (report.type === 'transport' && report.selectedCandidatePairId) pair = stats.get(report.selectedCandidatePairId)
          if (!pair && report.type === 'candidate-pair' && report.selected) pair = report
        })
        const local = pair ? stats.get(pair.localCandidateId) : undefined
        if (pc === conn) routeType.value = local?.candidateType ?? null
      }

    接続できたら getStats() で統計情報を取り、実際に選ばれた経路(候補ペア)の自分側の候補の種類を調べます。これを画面に「経路: TURN サーバー経由」のように表示しておくと、問い合わせ対応やテストのときに「どの経路でつながったか」がすぐ分かります。

    入室時に API を呼ぶ

    code/app/pages/room/[id].vue(変更部分)

    <script setup lang="ts">
    import type { IceConfigResponse } from '#shared/types/ice'
    
    // 第6回: STUN/TURN の設定(入室のたびに取り直す。TURN の認証情報には有効期限があるため)
    const iceConfig = shallowRef<RTCConfiguration>({})
    const { remotePeerId, remoteStream, connectionState, routeType, close: closePeer }
      = usePeerConnection(signaling, () => localStream.value, () => iceConfig.value)
    
    async function enter() {
      if (!localStream.value) return
      joinError.value = null
      try {
        const res = await $fetch<IceConfigResponse>('/api/ice-servers')
        iceConfig.value = { iceServers: res.iceServers, iceTransportPolicy: res.iceTransportPolicy }
      }
      catch {
        joinError.value = '接続設定を取得できませんでした。時間をおいて、もう一度お試しください。'
        return
      }
      signaling.join(roomId.value)
    }
    </script>

    iceTransportPolicy を 'relay' にすると、ブラウザは TURN 経由の候補しか使わなくなります。同じPCの中では普通は直接つながってしまうため、TURN が正しく動いているかを確かめるには、このモードで通話できるかを見るのが確実です。本番では 'all'(既定値)に戻します。

    📰 出典:MDN「RTCPeerConnection() constructor」

    本番デプロイの構成

    全体像

    要素役割公開するポート(例)
    リバースプロキシ(nginx 等)HTTPS の終端。/_ws の WebSocket も中継443/TCP
    Nuxt(Node サーバー)画面、/api/ice-servers、シグナリング外部には公開しない(例: 127.0.0.1:3000)
    TURN サーバー(coturn 等)STUN と TURN の中継3478/UDP・TCP、5349/TCP(TLS)、中継用の UDP ポート範囲

    Nuxt は npm run build で .output/ に Node サーバー一式を出力します。本番サーバーでは node .output/server/index.mjs で起動し、待ち受けるポートとアドレスは PORT(または NITRO_PORT)と HOST(または NITRO_HOST)で指定できます。

    📰 出典:Nitro v2「Node.js」

    npm ci
    npm run build
    # .env の内容(NUXT_TURN_SECRET など)はサーバーの環境変数として渡す
    HOST=127.0.0.1 PORT=3000 node .output/server/index.mjs

    実運用では、systemd などのプロセス管理で自動起動・再起動するようにします。シグナリングはメモリ上でルームを管理しているため(第2回)、Node プロセスは1つで動かす前提です。複数台に増やす場合は、ルーム情報の共有と WebSocket の振り分けを別途設計する必要があります。

    リバースプロキシで HTTPS と WebSocket を中継する

    カメラ・マイクは HTTPS でしか使えず(第0回)、HTTPS のページからは wss:// の WebSocket しか使えません。useSignaling はページが https なら自動的に wss:// で接続するので(第2回)、プロキシ側で WebSocket の「アップグレード」を中継する設定が必要です。

    nginx の設定例(video.example.com と証明書のパスは置き換えてください)

    map $http_upgrade $connection_upgrade {
      default upgrade;
      ''      close;
    }
    server {
      listen 443 ssl;
      server_name video.example.com;
      ssl_certificate     /path/to/fullchain.pem;
      ssl_certificate_key /path/to/privkey.pem;
    
      location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        # WebSocket(/_ws)のアップグレードを中継する
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        # 通話中に WebSocket が無通信で切られないよう長めにする
        proxy_read_timeout 3600s;
      }
    }

    筆者の検証では、Upgrade と Connection の2行を外すと、ブラウザの WebSocket 接続が「426」エラーで失敗し、入室できなくなりました。「画面は表示されるのに通話だけできない」ときは、まずここを疑ってください。

    なお、サーバーレスやエッジ系のホスティングでは、WebSocket を常時接続で使えるかどうかがサービスごとに異なります。Nuxt の実験的な WebSocket 機能を使う今回の構成は、常駐する Node サーバーを前提にしています。

    TURN サーバー(coturn)の設定例

    coturn の設定ファイル(turnserver.conf)の主な項目の例です。値はすべて置き換えてください。

    # 待ち受けポート(STUN/TURN)と TLS 用ポート
    listening-port=3478
    tls-listening-port=5349
    # クラウドのように内側と外側のIPが違う場合、外側のIPを指定する
    external-ip=<サーバーのグローバルIP>
    # 中継に使う UDP ポートの範囲(ファイアウォールでも開ける)
    min-port=49152
    max-port=65535
    # 時間制限付き認証(TURN REST API 方式)。Nuxt の NUXT_TURN_SECRET と同じ値
    use-auth-secret
    static-auth-secret=<十分に長いランダムな文字列>
    realm=turn.example.com
    # TLS(turns:)用の証明書
    cert=/path/to/fullchain.pem
    pkey=/path/to/privkey.pem
    fingerprint
    no-cli

    共有シークレットは、Nuxt 側(環境変数 NUXT_TURN_SECRET)と coturn 側(static-auth-secret)の2か所だけに置きます。コードや記事、チケットに実際の値を書かないよう注意してください。

    また、TURN サーバーは「指定された宛先へデータを中継する」サーバーなので、社内ネットワークやクラウドのメタデータ用アドレスなど、中継させたくない宛先を拒否する設定(coturn では denied-peer-ip など)も検討が必要です。どの範囲を拒否すべきかは設置するネットワークによって違うため、インフラ担当と確認してください。

    動作確認の方法

    1. .env に STUN/TURN の URL と共有シークレットを設定し、NUXT_ICE_TRANSPORT_POLICY=relay にして起動する
    2. 2つのウィンドウで同じルームに入り、「接続しました(経路: TURN サーバー経由)」と表示されることを確認する(Chrome の chrome://webrtc-internals でも relay の候補が選ばれていることを確認できる)
    3. NUXT_ICE_TRANSPORT_POLICY=all に戻し、本番と同じ HTTPS の URL で、PC とスマホ(Wi-Fi をオフにしてモバイル回線)の組み合わせで通話する

    筆者の環境では、Docker で coturn(4 系)と nginx を起動し、Playwright で Chromium を操作して次の点を確認しました。

    確認したこと結果
    /api/ice-serversSTUN/TURN の URL、期限付きのユーザー名とパスワード、Cache-Control: no-store が返る。TURN 未設定なら iceServers は空
    relay モードで2人が入室両方「接続しました(経路: TURN サーバー経由)」。使われた候補は relay のみ、映像も受信
    有効期限を過去にした認証情報TURN で認証エラー(期限切れ)になり、接続できない
    nginx(自己署名証明書の HTTPS)経由wss:// でシグナリングが接続(101 応答)し、TURN 経由で通話できる
    nginx の Upgrade 設定を外すWebSocket が 426 エラーで失敗し、入室できない
    all モード直接の経路(host/srflx)で接続し、その種類が画面に表示される

    一方、本当に別々のネットワーク(社外、携帯回線、企業のファイアウォールの内側)どうしでの通話はこの環境では確認していません。検証はすべて1台のPCの中で、TURN サーバーも同じPC上の Docker で動かしています。TLS 経由の TURN(turns:)、認証情報の有効期限を過ぎた後も長時間続く通話の挙動、実際の証明書での運用も未確認です。npm run build と npm run typecheck は通っています。

    つまずきやすい点とセキュリティ上の注意

    • /api/ice-servers を誰でも呼べる状態にしない:今回のサンプルは認証なしで誰でも TURN の認証情報を受け取れます。放置すると、第三者に TURN を中継サーバーとして使われ、通信費がかさむおそれがあります。本番ではログインしている人だけに発行し、呼び出し回数の制限も検討してください。
    • 有効期限は「通話時間」とのバランスで決める:短すぎると入室直後以外で使えなくなり、長すぎると漏れたときのリスクが増えます。再接続(第10回)のときには取り直す設計にします。
    • TURN のポート開放漏れ:3478 だけ開けて中継用の UDP ポート範囲を開け忘れると、認証は通るのに映像が流れません。UDP が通らない企業ネットワーク向けには、TCP や TLS(443 番ポートの turns: 等)での提供も検討します。
    • STUN だけでは足りない:STUN は「自分のアドレスを知る」だけなので、直接つながらない環境は救えません。公開されている無料の STUN サーバーを使う場合も、提供条件を確認してください。
    • ICE 候補にはIPアドレスが含まれる:TURN のログやブラウザのログにも利用者のIPアドレスが残ります。ログの保存期間と閲覧権限を決めておきます。

    発注者向けメモ:TURN は「運用し続けるもの」として見積もる

    TURN は、つながらない利用者を救うための必須部品ですが、映像を中継する分だけ通信量が発生し続けます。自前で coturn を運用するか、マネージドな TURN サービスを使うかによって、費用の構造と運用の負担が変わります(サービス名や料金の比較はここでは扱いません。最新の条件は各社の公式情報で確認してください)。

    観点自前で運用(coturn 等)マネージドな TURN サービス
    主な費用サーバー費と通信量、構築・監視の人件費利用量に応じた料金
    運用の手間OS・証明書・セキュリティ更新、ポート開放、監視事業者に任せられる部分が多い
    検討点障害時に誰が対応するか事業者の提供地域・データの扱い・契約条件
    • ☐ 見積もりに TURN(構築または利用料)と、その継続費用が含まれているか
    • ☐ 利用者の環境(社外、携帯回線、ファイアウォールの厳しい取引先)で接続テストをする計画があるか
    • ☐ TURN の認証情報を発行する API が、ログインした人だけに限定されているか
    • ☐ 本番環境が WebSocket を常時接続で使える構成か(HTTPS、リバースプロキシの設定を含む)
    • ☐ 通話が「どの経路でつながったか」を、問い合わせ対応のときに確認できるか

    開発会社への質問例:

    • 「TURN サーバーは自前で運用しますか、サービスを使いますか?それぞれの場合の月々の運用作業は何ですか?」
    • 「TURN 経由でしか接続できない条件でのテストは実施しますか?」
    • 「TURN の認証情報はどのように発行し、有効期限はどれくらいですか?」
    • 「通話がつながらないという問い合わせがあったとき、原因をどう切り分けますか?」

    まとめと次回予告

    • 同じPC・同じLANでつながっても、社外とはつながらないことがある。最後の手段が TURN(映像の中継)
    • TURN の共有シークレットはサーバーだけが持ち、ブラウザには有効期限付きの認証情報を API で渡す(coturn の TURN REST API 方式)
    • iceTransportPolicy: 'relay' で「TURN 経由だけ」の通話を試すと、TURN の動作を確実に確認できる
    • 本番は HTTPS のリバースプロキシ配下で Node サーバーとして動かし、WebSocket のアップグレード設定を忘れない
    • TURN は継続的な通信量と運用が発生するため、発注時に「誰が・どう運用するか」まで決める

    次回(第7回)は「画面共有」です。getDisplayMedia で画面を取得し、replaceTrack() を使ってカメラ映像と差し替えます。ブラウザの「共有を停止」ボタンで自動的にカメラに戻る動きも作ります。

    この連載の記事一覧

    この記事は連載「NuxtとWebRTCで作るビデオチャット」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次