はじめに
自宅のPCとスマホ、離れた場所にある2台のサーバー——それぞれがNAT配下にあるとき、この2台を直接つなぐのは自明ではない。
- ビデオ通話やゲーム、ファイル共有のようなP2Pアプリケーション
- リモートワークで使うVPN
どちらも、同じ壁にぶつかっている。この記事では、その壁の正体と、越えるための道具立てを整理する。
👉 記事中に出てくるグローバルIPアドレスとホスト名はすべてマスクしてある(グローバルIPアドレスは RFC 5737 のドキュメント用アドレスに置き換えてある)。ポート番号やレイテンシは実測値のまま載せる。
NATは何をしているか
NAT(Network Address Translation)は、内側のホストが外へパケットを送り出すときに、送信元アドレス・ポートを変換し、その対応関係をマッピング表に記録する。戻ってきたパケットは、この表と照合して内側のホストへ届けられる。
内側 NAT 外側
192.168.18.20:50000 → [変換] → 203.0.113.10:46760 → 相手サーバ
↑
マッピング表に記録
192.168.18.20:50000 ⇔ 203.0.113.10:46760
戻り NAT
相手サーバ → 203.0.113.10:46760 → [表と照合] → 192.168.18.20:50000
👉 マッピングは内側から出ていくパケットによって作られる。この一点が記事全体の出発点である。
(注: このマッピング表は概念図であり、本記事では実測していない)
なぜ「外から」つなげないのか
内側のホストが何も送信していなければ、マッピングはそもそも存在しない。
マッピングの無い宛先に外から届いたパケットは、NATがどの内側ホストに渡せばいいか分からないため、そのまま捨てられる。
❌「グローバルIPさえ分かれば繋がる」は誤り。相手のグローバルIPが分かっていても、対応するマッピングが無ければパケットは届かない。
だから、双方がNAT配下にある2つのホストは、素朴にはどちらからも接続を開始できない。
この壁を越える方法は、原理的に4つしかない。
- ルータに開けてもらう
- 外側の自分を知る
- 自分で穴を開ける
- 諦めて中継する
そして、この4つを順に試す仕組みが ICE である。
NATの挙動は2軸で分類する
NATの挙動は「マッピング挙動」と「フィルタリング挙動」の2軸で分類される(RFC 4787)。
マッピング挙動 = 同じ内側 X:x から出たとき、同じ外側アドレスを再利用するか
- Endpoint-Independent Mapping — 宛先が何であれ同じマッピングを再利用する
- Address-Dependent Mapping — 宛先の外部IPが同じなら(ポートは問わず)再利用する
- Address and Port-Dependent Mapping — 宛先のIPとポートの両方が同じときだけ再利用する
フィルタリング挙動 = 外から来たパケットを内側に通すかどうかの条件。同じく3分類。
-
Endpoint-Independent Filtering — 宛先が
X:xでありさえすれば通す -
Address-Dependent Filtering —
X:xが過去にその相手IPへ送っていれば通す -
Address and Port-Dependent Filtering —
X:xが過去にその相手IP:portへ送っていれば通す
👉 NAT超えで効いてくるのはマッピング挙動のほう。 Endpoint-Independent Mapping なら「自分の外側アドレス」が一つに定まるので、それを相手に教えれば的が絞れる。宛先ごとにマッピングが変わるNATでは、相手に教えるべきアドレスがそもそも決まらない。
古くは「Full Cone」「Symmetric」のような呼び方が使われていた。よく見る呼び方との対応は次の通り(厳密な1対1対応ではない)。
| よく見る呼び方 | マッピング挙動 | フィルタリング挙動 |
|---|---|---|
| Full Cone | Endpoint-Independent | Endpoint-Independent |
| Restricted Cone | Endpoint-Independent | Address-Dependent |
| Port Restricted Cone | Endpoint-Independent | Address and Port-Dependent |
| Symmetric | Address and Port-Dependent | Address and Port-Dependent |
RFC 4787 はこの旧用語について、次のように述べて退けている。
Unfortunately, this terminology has been the source of much confusion, as it has proven inadequate at describing real-life NAT behavior.
👉 実際のNATはこの4分類にきれいには収まらない挙動をすることがある。だからこの記事でも、マッピング挙動とフィルタリング挙動の2軸で捉える。
手法① ルータに開けてもらう
一番素直な解法。ルータに「このポートに来たら内側のこのホストへ渡せ」と設定してしまう。
- 静的ポートフォワード — 手で設定する。確実だが、繋ぎたい端末が増えるたびに人手が要る
-
プロトコルで自動化する仕組みが3つある:
- UPnP-IGD
- NAT-PMP
- PCP(RFC 6887)— NAT-PMP より後発。RFC 6887 は NAT-PMP を "the older NAT-PMP protocol" と呼んでいる
アプリがルータに「このポートを開けて」と頼めるので、ユーザーの手作業が要らない。
❌ ただし当てにはできない。セキュリティ上の理由で無効化されていることが多く、そもそも実装が無いこともある。
筆者の家庭用ルータ配下で tailscale netcheck を実行した結果の一部を見てみる。
* PortMapping:
* CaptivePortal: false
👉 PortMapping は空だった。UPnP-IGD・NAT-PMP・PCP のいずれも使えていない。手法①が使えない環境は普通にある。
手法② 外側の自分を知る
相手に自分の居場所を教えるには、まず自分が外からどう見えているかを知る必要がある。
内側のホストは自分のプライベートIPしか知らない。NATがそれを何に変換しているかは、外側から教えてもらうしかない。
これをやるのが STUN(Session Traversal Utilities for NAT、RFC 8489)。
仕組み: STUNサーバに Binding request を送ると、サーバは「あなたのパケットは IP:port から来たように見えた」と教えてくれる。これが server-reflexive アドレス。
デフォルトポートは 3478(TCP/UDP)、TLS/DTLS 上は 5349。
⚠️ STUN は NAT超えの解法ではなく、解法の中で使う道具である。RFC 8489 自身がそう位置づけている。
STUN is not a NAT traversal solution by itself. Rather, it is a tool to be used in the context of a NAT traversal solution.
同じ tailscale netcheck の実測(グローバルIPは 203.0.113.10 にマスクしてある)。
Report:
* UDP: true
* IPv4: yes, 203.0.113.10:46760
* IPv6: no, but OS has support
* MappingVariesByDestIP: false
* PortMapping:
* CaptivePortal: false
* Nearest DERP: Hong Kong
* DERP latency:
- hkg: 39.9ms (Hong Kong)
- tok: 69.6ms (Tokyo)
- sin: 82.4ms (Singapore)
(以下省略)
-
IPv4: yes, 203.0.113.10:46760— これが server-reflexive アドレス。手元の192.168.18.20が外側では別のIP:port に化けている -
MappingVariesByDestIP: false— 「NATの挙動は2軸で分類する」で見た Endpoint-Independent Mapping が、そのまま実測値として出てきている -
UDP: true— UDPが外に出られている
👉 ただし、外側の自分を知っただけでは繋がらない。 相手にそれを伝える手段が要るし、伝えたところで相手からのパケットはフィルタで落とされる。次の手法がそこを埋める。
手法③ 自分で穴を開ける(UDPホールパンチング)
ルータが開けてくれないなら、自分で内側からパケットを出してマッピングを作ればいい。これが UDP ホールパンチング。
手順:
- A も B も STUN で自分の server-reflexive アドレスを知る(手法②)
- お互いのアドレスを交換する
- A → B、B → A へほぼ同時にパケットを送る
- 双方のNATに「内側から出ていった」記録ができるので、相手からの返りが通るようになる
A(NAT配下) B(NAT配下)
|--- A→B へ送信(Bのフィルタで落ちる)---> X
X <--- B→A へ送信(Aのフィルタで落ちる)---|
| |
ここで両側にマッピングができている
|--- A→B へ送信 --------------------------> ○ 通る
○ 通る <--- B→A へ送信 -------------------------- |
👉 最初の数パケットは捨てられて構わない。捨てられる過程でマッピングができるのが要点。Tailscale の解説にある通り、行き交うパケット同士がIPとポート以外で関連している必要はない。
シグナリングが要る
❌「STUN があればホールパンチングできる」は誤り。
- 手順2の「お互いのアドレスを交換する」には、すでに繋がっている別の経路が要る
- NAT超えをしようとしているのに、そのために別の通信経路が必要——ここが直感に反するところ
- 実際には中央のサーバ(シグナリングサーバ、Tailscale なら DERP)がこの役目を担う
限界
- ホールパンチングが素直に成立するのは、双方が Endpoint-Independent Mapping のとき
- 宛先ごとにマッピングが変わるNAT(Address-Dependent / Address and Port-Dependent Mapping、俗に hard NAT)だと、相手に教えたアドレスと実際に相手へ送るときのアドレスが別物になるので破綻する
- ポートを推測して総当たりする手法は存在するが、Tailscale の試算では両側が hard NAT の場合、20秒粘って成功率0.01%、99.9%にするには17万回のプローブが必要——現実的な手ではない
手法④ 諦めて中継する(TURN)
どうしても直接繋がらない組み合わせは残る。そのときは中継サーバに肩代わりさせる。
TURN(Traversal Using Relays around NAT、RFC 8656)。中継サーバにアドレスを割り当ててもらい(Allocate)、そこ経由で相手とやりとりする。
正式名称は "Relay Extensions to Session Traversal Utilities for NAT (STUN)"——TURN は STUN の拡張として定義されている。
👉 いつ必要になるかは RFC 8656 自身が明言している。 双方のNATが address-dependent mapping または address- and port-dependent mapping の場合、ホールパンチングは失敗する。
コスト:
- 必ず繋がるのが最大の利点
- 全トラフィックが中継サーバを通るので帯域コストを誰かが払う
- 経路が遠回りになるのでレイテンシが増える
👉 だから中継は「最後の手段」として使い、可能なら直接接続を優先したい。この優先順位づけをやるのが次の ICE である。
手法⑤ 全部順に試す — ICE
ここまでの手法は、どれも単独では万能でない。ならば全部集めて順に試せばいい、というのが ICE(Interactive Connectivity Establishment、RFC 8445)である。
ICE は候補(candidate)を集めて、優先度順に接続性チェックを行い、通った経路を使う。candidate は4種類ある。
| 種別 | 表記 | どう得るか |
|---|---|---|
| host | host |
ローカルのインターフェースにバインドして得る |
| server-reflexive | srflx |
STUNサーバに Binding request を送り、返ってきた変換後アドレス |
| peer-reflexive | prflx |
接続性チェックの最中に新たに判明したアドレス |
| relayed | relay |
TURNサーバが Allocate 応答で割り当てた中継アドレス |
👉 手法②と④で得たアドレスが、そのまま candidate の種類になっている。 ICE は新しい技術というより、ここまでの道具を束ねる調停役である。
SDP では a=candidate 行として表現される。書式(RFC 8839):
candidate-attribute = "candidate" ":" foundation SP component-id SP
transport SP priority SP
connection-address SP port SP cand-type
[SP rel-addr] [SP rel-port]
candidate-types = "host" / "srflx" / "prflx" / "relay" / token
RFC 8839 に載っている例(仕様書の例であり、本記事の実測ではない):
a=candidate:2 1 UDP 1694498815 192.0.2.3 45664 typ srflx raddr 203.0.113.141 rport 8998
フィールドの読み方:
-
2= foundation -
1= component-id(RTP は 1、RTCP は 2) -
UDP= transport -
1694498815= priority(RFC 8445 の手順で計算する) -
192.0.2.3 45664= このcandidateのアドレスとポート -
typ srflx= 種別 -
raddr/rport= 元になったアドレス。srflx・prflx・relay では必須、host では省略される
WebRTC での位置づけ:
-
RTCPeerConnectionに ICE server(STUN または TURN)を渡すと、ブラウザが candidate を集めてくれる - Trickle ICE — candidate を集め終わるのを待たず、見つけ次第相手に送る方式。接続確立が速くなる
- ⚠️ シグナリングは WebRTC の仕様外。公式ドキュメントも "the signaling component is not part of it" と明言している。candidate を相手に届ける経路は自分で用意する必要がある(「手法③ 自分で穴を開ける(UDPホールパンチング)」で述べた通り)
実装ごとの答え合わせ
ここまでの4手法とICEが、実際の実装でどう使われているかを並べる。
| 実装 | ①ポート開放 | ②STUN | ③ホールパンチング | ④中継 | ⑤統合 |
|---|---|---|---|---|---|
| WebRTC | — | ○ | ○ | ○(TURN) | ICE |
| WireGuard 素 | 手動(必要なら) | — | — | — | なし |
| Tailscale | ○(UPnP/NAT-PMP/PCP) | ○ | ○ | ○(DERP) | 自前実装 |
| IPsec / OpenVPN | — | — | — | サーバ集中型で不要 | NAT-T(UDP 4500) |
| ゲーム / BitTorrent | ○(UPnP-IGD) | ○ | ○ | 実装による | 実装による |
WireGuard 単体にはNAT超えの機構がない
- WireGuard は鍵交換とパケットの暗号化に徹していて、経路を探す機能を持たない
- 成り立つのは、片側がグローバルに到達可能な場所に居る(VPSなど)前提があるから。NAT配下の側から接続を開始すればマッピングができる
- そのマッピングを維持するのが
PersistentKeepalive。認証済みの空パケットを一定間隔で送り、NATやステートフルFWのマッピングを保つ - ⚠️ デフォルトは off。
wg(8)は "By default or when unspecified, this option is off." と書いている - NAT配下で受信も待ちたい場合に 25秒という値が推奨されうる、という位置づけ。同manは "Most users will not need this." とも書いている
Tailscale は WireGuard の上に①〜④を全部足したもの
- Tailscale のデータ経路は WireGuard そのもの。足しているのは経路探索の層
- ①UPnP-IGD / NAT-PMP / PCP を試し、②STUN で自分の外側を知り、③ホールパンチングし、④駄目なら DERP で中継する
- つまり 「手法⑤ 全部順に試す — ICE」と同じことを、自前の実装でやっている
- DERP は二役を担う: 直接接続のネゴシエーション(=シグナリング)と、直接接続できないときの中継
- 🔒 中継されても中身は読まれない。Tailscale の秘密鍵は端末から出ないため、DERP は暗号化済みのパケットをそのまま転送するだけで復号できない
筆者の手元のマシンから、グローバルIPを持つVPS(vps-a)へ tailscale ping した実測(アドレスは記事冒頭と同じ方針でマスクしてある)。
$ tailscale ping vps-a
pong from vps-a (100.x.x.x) via 198.51.100.20:41641 in 194ms
tailscale status --json の該当ピア:
HostName = vps-a
Online = True
Relay = ord
CurAddr = 198.51.100.20:41641
読み解き:
-
CurAddrが相手のグローバルIP:port になっている=いま使われている経路は中継を経ない直接接続 - それでも
Relay = ordが保持されている=中継はフォールバックとして常に確保されている。直接経路が切れても通信は落ちない - ⚠️ ただしこれはホールパンチングの成功例ではない。 相手はグローバルIPを持つホストなので、こちらから送るだけで経路が張れる「片側がグローバル」のケースにあたる
IPsec NAT-T は「超える」のではなく「通れる形に変える」
- 素の ESP はポート番号を持たないため、NATが同一NAT配下の複数セッションを識別できない。IPヘッダの書き換えで内包するTCP/UDPヘッダのチェックサムも壊れる
- そこで ESP を UDP 4500 に包む(RFC 3948)。IKE の NAT-T と同じポートを使う
- RFC 3948 はこの方式の利点として "better scaling (only one NAT mapping in the NAT; no need to send separate IKE keepalives)" を挙げている
- 👉 これはサーバ集中型だからできる解法。片側(VPNサーバ)がグローバルに居るので、経路を「探す」必要がそもそも無い。NATを通れる形にパケットを変えるだけで済む
ゲーム機の「NATタイプ」——外からの到達可能性を示す指標
- ゲーム機やルータが表示する「NATタイプ A / B / C」「オープン / モデレート / ストリクト」は、要するに外から入ってこられる度合いの表示
- ⚠️ ただし各社の定義は独自であり、RFC 4787 の分類と機械的に対応づけられるものではない
まとめ
- NAT配下のホストは、内側から出ていかない限り外から到達できない。これが全ての出発点
- 解法は4つ: ①ルータに開けてもらう ②外側の自分を知る ③自分で穴を開ける ④諦めて中継する
- ICE は candidate を集めて優先度順に試し、最初に通った経路を使う調停役
- P2P も VPN も、この同じ道具立てを使っている。違うのはどこまで自前でやるかだけ
- WebRTC は ICE をそのまま使う
- Tailscale は ICE 相当を自前で組んだ
- WireGuard 素と従来型VPNは、片側がグローバルに居る前提を置くことで経路探索そのものを回避している