libp2p:PeerIdから分散型ホールパンチングまでを支える、モジュール式ネットワークスタック
かつてP2Pプロジェクトはどれも、ピアの命名・発見・NAT越えといったネットワーク層を、自前で(たいてい出来の悪い形で)作り直していました。libp2pはそれらの関心事を差し替え可能なモジュール群へと分解し、IPFS、Ethereumのコンセンサスレイヤー、Filecoin、Polkadotを支える共通基盤になりました。このページでは、PeerIdや自己記述的なmultiaddrから、トランスポート・セキュリティ・pubsubまでスタックを一通りたどった後、その真価(AutoNAT、Circuit Relay v2、DCUtRが、STUNやTURNサーバーを一切使わず、他のピアだけを頼りにNATを突破する仕組み)にじっくり時間を割いて解説します。
libp2pとは何か
あらゆるP2Pアプリケーションは、最終的に同じ一握りの課題に行き着きます。ピアをどう名付けるか、どう見つけるか、間に立ちはだかるNATを越えてどう接続を開くか、その接続をどう暗号化するか、そして複数の論理ストリームへどう多重化するか、という課題です。長年、各プロジェクトはこれらをゼロから作り込んでおり、そのほとんどが出来の悪いものでした。libp2pを作った当人たちの言葉を借りれば、既存のソリューションは「ドキュメントが乏しく、ライセンスが制約的で、コードは古く、問い合わせ窓口もない……あるいは特定のユースケースに強く結合していて、アップグレードもできない」ものになりがちでした。
libp2p(「library peer-to-peer」の略)は、もともとInterPlanetary File System(IPFS)プロジェクトの内部でIPFS自身のワイヤプロトコルとして生まれ、その後、独立した汎用ネットワークスタックとして切り出されました。その狙いは、ピア発見・トランスポート・セキュリティ・多重化を1つの巨大なモノリシックプロトコルとして扱うのをやめ、それぞれの関心事を差し替え可能なモジュールとして切り出すことにありました。これにより、同じアプリケーションが、ビジネスロジックを一切変えることなく、TCPでもWebRTCでも動作し、NoiseでもTLSでも認証でき、DHTでもローカルmDNSでもピアを発見できるようになります。
この差し替え可能性こそが、libp2pが生まれ故郷のIPFSファミリーをはるかに超えて広く採用されている理由です。EthereumのProof of Stakeコンセンサスレイヤーはlibp2p上で動いており、LighthouseとPrysmの両クライアントがピアネットワーキングにlibp2pを使っています。「最大級の分散型ストレージネットワーク」を謳うFilecoinも、大規模環境での信頼性をlibp2pに依存しています。PolkadotはSubstrateアーキテクチャの一部としてlibp2pを活用しており、AlgorandからStarknet、Uniswap、Baseに至るまでのプロジェクトが、ネットワーキング基盤の1つとしてlibp2pを挙げています。1つのプロジェクトのワイヤフォーマットとして始まったものが、P2P界隈全体を横断する共通の結合組織になったのです。
| プロジェクト | libp2pの使われ方 |
|---|---|
| IPFS | 生みの親であり、今も数十万ノード規模の分散型コンテンツ配信におけるリファレンス実装的な利用者 |
| Ethereum(コンセンサスレイヤー) | Proof of Stakeクライアント(Lighthouse、Prysm)のピアネットワーキング |
| Filecoin | 最大級の分散型ストレージネットワークにおけるネットワーク性能と信頼性の担保 |
| Polkadot | Substrateベースのアーキテクチャの一部として |
| Algorand | 中央集権的なリレーノードからの移行を進める中で |
アイデンティティ:PeerIdと公開鍵
- PeerId
- ピアとその公開鍵を検証可能な形で結びつける識別子であり、概念的には、その公開鍵の暗号学的ハッシュです。ハッシュが鍵と直接結びついているため、セキュアチャネルのハンドシェイクを受け取ったピアは、チャネルの保護に使われた鍵がPeerIdの示す鍵と同一であることを検証できます。
- multihash
- libp2pの仕様がPeerIdの符号化に使う、コンパクトで自己記述的なハッシュのバイナリ形式です。ハッシュ本体の前に、どのハッシュ関数で生成されたかを示すコードを付与します。
生成規則は、小さな鍵に対してあえて低コストになるよう設計されています。シリアライズした公開鍵が42バイト以下であれば、ハッシュ化を一切せずそのまま「identity」multihashとして埋め込みます。42バイトを超える場合はSHA-256でハッシュ化してからmultihashにラップします。Ed25519のサポートはすべての実装で必須とされ、RSAは「推奨(should)」レベルで、主にIPFS DHTの既存ブートストラップノードとの相互運用性のために残されています。Secp256k1とECDSAはオプション扱いの追加要素で、サポート状況にはややムラがあります。
PeerIdには2つの有効なテキスト表現があります。1つは従来形式で、プレフィックスなしのmultihashをbase58btcでエンコードしたもの(ハッシュ化された鍵ならQm...、identityエンコードされたEd25519鍵なら1...で始まる文字列)です。もう1つは新しい形式で、multihashをlibp2p-keyマルチコーデックを使ってCIDv1にラップし、base32でエンコードします(bafz...で始まる文字列)。実装は両方をパースできる必要があります。PeerIdは、次に説明するmultiaddrへ/p2p/<PeerId>セグメントとして埋め込まれます。改名前は、古い/ipfs/プレフィックスが同じ意味を持っていました。
multiaddr:自己記述するアドレス
従来型のアドレス(host:port)は、そこへたどり着いた後にどのプロトコルを話せばよいかを何も教えてくれません。libp2pの答えがmultiaddrです。アドレッシング情報のすべての層を、1本の自己記述的なパスへエンコードする規約です。/ip4/198.51.100.0/tcp/4242は、左から右へ「たどるべき手順」として読めます。まずこのIPv4ホストへ到達し、次にこのTCPポートを開く、という具合です。個々のセグメントはそれ自体が有効なmultiaddrであり、あるmultiaddrを別のmultiaddrの中に包み込むことをカプセル化(encapsulation)、層を1つ剥がすことをデカプセル化(decapsulation)と呼びます。
すべての層が明示的であるため、1本の文字列だけでピアの完全な発呼可能なアイデンティティを運べます。/ip4/198.51.100.0/tcp/4242/p2p/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5Nは、どのホストの、どのポートで、相手側にどのPeerIdを期待すべきかをそのまま示しています。新しいトランスポートも同じやり方で暗号学的な素材を組み込みます。ブラウザ向けのWebRTCアドレスは/ip4/1.2.3.4/udp/1234/webrtc/certhash/<hash>/p2p/<peer-id>のような形になり、証明書ハッシュを埋め込むことで、ブラウザは信頼されたCAなしに自己署名証明書を検証できます。リレー経由の接続は2つのPeerIdを連結する形になり、/ip4/198.51.100.0/tcp/4242/p2p/QmRelay/p2p-circuit/p2p/QmRelayedPeerのようになります。
接続を構成する層:トランスポート・セキュリティ・多重化
libp2pはトランスポートに依存しない設計を貫きます。TCP、QUIC、WebSocket、WebRTC、WebTransportのどれを使うかはアプリケーション側に委ねられており、1つのノードが複数のトランスポートで同時にリッスンすることもできます。トランスポートが何であれ、あらゆる接続は同じ概念的なスタックを通過します。セキュアチャネルがネゴシエートされ、その上に(トランスポートが自前で提供していない限り)ストリーム多重化層が重ねられます。
QUICは、1つのステップを丸ごと省略できる特別なトランスポートです。2014年にGoogle/Chromeの実験プロジェクトとして始まり、その後IETFによってRFC 9000(トランスポート)、RFC 9001(TLS統合)、RFC 9002(損失検出)として標準化されたQUICは、常時オンの暗号化とネイティブなストリーム多重化をトランスポート自体に組み込んでいます。libp2pは独自のセキュリティハンドシェイクや多重化層を重ねるのではなく、この2つをそのまま再利用します。自己署名証明書がPeerIdを運び、libp2pのTLSセキュリティモジュールと同じハンドシェイクロジックで検証されるため、QUIC上でのlibp2p接続確立はわずか1往復で済みます。go-libp2pはまず標準化前のdraft-29(multiaddrコードquic)を実装し、後にRFC 9000(multiaddrコードquic-v1)を追加しました。両者は現在も区別されて使われています。
トランスポート自体がセキュリティを提供しない場合、libp2pは2つのセキュアチャネルプロトコルのどちらかをネゴシエートします。
Noise(/noise) | TLS 1.3(/tls/1.0.0) | |
|---|---|---|
| ハンドシェイク | XXパターン、Noise_XX_25519_ChaChaPoly_SHA256 | TLS 1.3(RFC 8446)以降のみ許可 |
| ピア認証 | libp2pのアイデンティティ鍵で署名された静的Noise鍵ペアを、ハンドシェイクのペイロードに乗せて運ぶ | 自己署名証明書内の「libp2p Public Key Extension」に埋め込まれたホスト鍵 |
| クライアント認証 | 該当なし(構造上、常に相互認証) | サーバーはクライアント証明書の要求が必須 |
| 早期ネゴシエーション | 独自の拡張レジストリ(Noiseにネイティブな拡張機構はない) | 標準のALPN(RFC 7301) |
どちらのプロトコルもマルチプレクサの早期ネゴシエーションをサポートしています。ストリーム多重化方式の選択をセキュリティハンドシェイクのペイロード自体に織り込み、別途ネゴシエーションのステップを設けた場合にかかる追加の往復を節約する仕組みです。現時点でこの最適化が実装されているのはgo-libp2pのみで、しかもQUICのようにネイティブな多重化を持たないトランスポート(TCP、WebSocket)に対してのみです。
多重化の面では、libp2pは2種類のマルチプレクサをサポートしています。1つはyamux(プロトコルID /yamux/1.0.0)で、Hashicorp設計の、本物のフロー制御を備えたマルチプレクサで、受信側はオフセットベースのバックプレッシャー機構によって送信側を絞ることができます。もう1つはmplexで、より古くシンプルな設計ゆえフロー制御を持たず、ピアが開けるストリーム数の上限もありません。仕様書はこのトレードオフについて率直で、yamuxはmplexより優先されるべきであり、mplexは後方互換性以外の用途では非推奨化が進められています。QUIC、WebTransport、WebRTCのようにネイティブな多重化を持つトランスポートは、このネゴシエーション自体を丸ごとスキップします。
このネイティブな多重化こそが、WebTransportが素のWebSocketよりもはるかに安価に接続できる理由でもあります。標準的なWebSocketベースのlibp2p接続は、TCPハンドシェイク(1 RTT)、TLS 1.3ハンドシェイク(1 RTT)、WebSocketアップグレード(1 RTT)を積み上げた上に、さらにmultistreamネゴシエーション2往復とセキュリティハンドシェイク自体が乗ります。アプリケーションデータが動き出すまでに6往復です。QUIC上に構築されたWebTransportは、これをQUICハンドシェイク、WebTransportハンドシェイク、そしてmultistreamとNoiseを1つにまとめたlibp2pハンドシェイクへと圧縮します。合計3往復で済み、コストは半分です。
| スタック | データが流れ始めるまでの往復数 |
|---|---|
| TCP + TLS 1.3 + WebSocketアップグレード + multistream-select + セキュリティハンドシェイク | 6 RTT |
| QUIC + WebTransportハンドシェイク + libp2pハンドシェイク(multistream + Noise) | 3 RTT |
| QUICのみ(ネイティブなセキュリティと多重化、追加ネゴシエーションなし) | 1 RTT |
プロトコルの合意:multistream-selectとidentify
2つのピアがセキュアで多重化された接続を確立した後も、個々のストリーム上でどのアプリケーションプロトコルを話すかについては、まだ合意が必要です。これを担うのがmultistream-selectです。発呼側が/multistream/1.0.0ヘッダーに続けて望むプロトコルID(例えば/ipfs/id/1.0.0)を送るという軽量なネゴシエーションで、リスナー側がそのプロトコルに対応していればIDをそのままエコーバックして受諾を示し、対応していなければna(「not available」)と返し、発呼側はフォールバックのプロトコルIDで再試行できます。すべてのlibp2pプロトコルは、/my-app/amazing-protocol/1.0.1のような、パスに似たバージョン付き文字列で自らを識別します。
| プロトコル | プロトコルID |
|---|---|
| Ping | /ipfs/ping/1.0.0 |
| Identify | /ipfs/id/1.0.0 |
| Identify Push | /ipfs/id/push/1.0.0 |
| Noise | /noise |
| TLS 1.3 | /tls/1.0.0 |
| yamux | /yamux/1.0.0 |
| Circuit Relay v2 (hop) | /libp2p/circuit/relay/0.2.0/hop |
| DCUtR | /libp2p/dcutr |
| gossipsub v1.1 | /meshsub/1.1.0 |
| Rendezvous | /rendezvous/1.0.0 |
| Kademlia DHT | 一般には/ipfs/kad/1.0.0として知られる(go-libp2p-kad-dht実装では、設定可能なプレフィックスと/kad/1.0.0を組み合わせて構築する) |
ほとんどのピアが最初に交わすプロトコルがidentifyです。公開鍵、リッスンしているアドレス、サポートするプロトコルIDを交換します。このやり取りの中に、後になって非常に重要な意味を持つフィールドが埋め込まれています。それがobservedAddrで、応答側のピアが実際に接続がどこから来たのを見たかを示すアドレスです。NAT配下のピアは、自分のネットワークインターフェースを眺めるだけでは自分の公衆向けアドレスを知りようがありません。observedAddrは、誰か他人に「何が見えたか」を尋ねることでそれを知る手段です。このたった1つのフィールドこそが、この後説明するNAT越えのスタック全体が育っていく種になります。付随するプロトコルであるidentify/pushは、ピアが知っている全員に対して更新済みのIdentifyメッセージを能動的にブロードキャストできるようにするもので、新しいリレーアドレスや公開アドレスを知った瞬間に使うと、ネットワークの他のメンバーが改めて尋ね直すのを待たずに済みます。
ピアを見つける:Kademlia DHT、mDNS、rendezvous、bootstrap
プロトコルIDを知っていても、誰とストリームを開くのかが分からなければ意味がありません。libp2pは、ピアの発見(自分を他人に見つけてもらうこと)とピアへのルーティング(特定の相手を見つけ出すこと)を、関連はしているが別個の問題として扱い、複数の相互運用可能な仕組みで対応しています。
インターネット規模での主役は、S/Kademlia、Coral、そしてBitTorrent自身のDHTのアイデアを取り入れたKademliaのDHT実装です。2つのキー間の距離は256ビットのSHA-256キー空間上でのXOR(sha256(key1), sha256(key2))で定義され、ルーティングテーブルはプレフィックス長ごとのバケットにつきk = 20ピア(複製パラメータ)を維持しようとし、1回のlookupはα = 10件のリクエストを並行してファンアウトします。公衆網から到達可能なノードはサーバーモードで動作し、Kademliaプロトコルをアドバタイズしてインバウンドのストリームを受け付けます。NAT配下やリソース制約のあるノードはクライアントモードで動作し、他人のルーティングテーブルに登録されることなくlookupに参加します。ノードは、ランダムなピアID(自分自身のものも含む)を定期的にlookupし、そこで見つかったものをルーティングテーブルへ取り込むことでブートストラップします。
同一ローカルネットワーク上のピア同士であれば、mDNS(RFC 6762)は設定を一切必要としません。ノードがクエリをブロードキャストすると、同一セグメント上のピアが自分のmultiaddrで応答します。
Rendezvousはまったく異なる形をとります。分散型ではなく連合型(federated)であり、これは1つのrendezvousポイントが、DHTやgossipsubが意図的に避けているようなボトルネックや単一障害点になり得ることを意味します。ピアはプロトコルID/rendezvous/1.0.0を通じて、rendezvousポイント上のある名前空間に自らをREGISTERします。登録の既定の有効期間は2時間、上限は72時間です。ブラウザノード向けのCircuit Relay発見や、pubsubトピックの購読者発見をブートストラップする用途でよく使われます。
最後に、素朴なブートストラップリスト(起動時に発呼する既知ピアのハードコードされた、あるいは設定済みの集合)が、新規ノードが上記のいずれかへ最初の数本の接続を得るための、最もシンプルな手段であり続けています。
伝播:gossipsub v1.1
発見が答えるのは「外に誰がいるか」であり、pubsubが答えるのは「ブロードキャストなしにどうやって全員に何かを伝えるか」です。libp2pにおけるこの役割を担うゴシッププロトコルがgossipsubです。トピックごとのオーバーレイは2種類のピアリングを維持します。1つは、目標次数D = 6(許容範囲4〜12)にサイズを合わせた疎なフルメッセージメッシュで、メッセージ全体はこの上を転送されます。もう1つは、ピアがどのメッセージを見たかだけをゴシップするための、より密なメタデータのみのピアリングです。1秒ごとのハートビートがメッシュのメンテナンスを駆動し、メタデータ限定のピアをフルメッシュへ昇格させたり(graft)、メッシュが過密になればフルメッシュのピアをメタデータのみへ降格させたり(prune)します。IHAVEは最近見たメッセージIDを告知し、IWANTはピアが実際に持っていないメッセージを要求します。購読していないトピックへのパブリッシュはfanoutにフォールバックします。トピックごとにランダムに選んだ6ピアを配送先として記憶し、そのトピックへの発行が2分間なければ忘れられます。
バージョン1.1は、この基本設計の上にセキュリティ動機のいくつかの拡張を重ねました。明示的ピアリング契約(explicit peering agreements)は、スコアリングの枠外で無条件・常時接続を望むオペレーター向けの仕組みです。ピア交換(PX)はpruneの際、切り離されるピアをただ切るのではなく代替候補を提示します。フラッドパブリッシュは、購読の有無にかかわらず十分にスコアの高い接続済みピア全員へ自分のメッセージを送りつけることで、Eclipse攻撃への防御として機能します。適応的ゴシップ伝播はゴシップ係数0.25でチューニングされており、3ラウンドの伝播によって、任意のピアが新規メッセージのゴシップ告知を受け取る確率はおよそ58%(1 − (3/4)³)になります。そしてアウトバウンドメッシュ枠(D_out)は、メッシュ内で自分から能動的に張った(インバウンドではない)接続の最低本数を保証するもので、被害者のインバウンドスロットを支配しようとするSybil攻撃を鈍らせるために設けられています。
すべてのピアは、他のすべてのピアをローカルにスコアリングします(スコアは決して共有されません)。トピックごとの振る舞い(メッシュ滞在時間、最初のメッセージ配送、メッセージ配送率、不正なメッセージ)とグローバルな振る舞い(IPの同居、明示的な行動ペナルティ)を重み付けして組み合わせたものです。GraylistThresholdを下回ったピアのメッセージは即座に無視されます。健全なメッシュでもスコアの中央値がOpportunisticGraftThresholdを下回ると、少なくとも2つのより高スコアなピアを日和見的にグラフト(opportunistic grafting)する仕組みが、おおよそ1分に1回のチェックでトリガーされます。このスコアリングの仕組みによって、「全員が全員とゴシップする」という単純な発想が、単独の悪意あるアクターに静かに機能不全へ追い込まれることのない、敵対的なピアにも耐えうる仕組みへと変わっています。
NAT越えのスタック
インターネット上のほとんどのピアはNATやファイアウォールの内側にいて、招かれざるインバウンド接続には到達できません。これはNAT越えやWebRTCがSTUN/TURNで解決しているのと同じ根本問題です。STUNとTURNは機能しますが、それは専用の、中央集権的に運用されたサーバーに頼ることで成り立っています。STUNサーバーはクライアントに「外から見た自分の公開アドレス」を教えるためだけに存在し、TURNサーバーは直接経路が見つからないときにトラフィックを中継するためだけに存在します。libp2pのスタックは、専用インフラを一切立てることなく同じ問題を解決します。すべての役割を、ネットワーク上にすでにいる普通のピアが担います。
対応関係は明快です。STUNが自分の反射アドレスを教えてくれるのに対応する役割を、AutoNATは他のlibp2pピアに自分へ電話をかけ直してもらい、何が見えたかを報告してもらうことで果たします。TURNが最後の手段としてトラフィックを中継するのに対応する役割を、Circuit Relay v2は他のピアがリレー役を買って出ることで果たします。そしてICEが2つのNAT配下ピア間の同時接続試行を調整するのに対応する役割を、DCUtR(その元になった査読済み論文の言葉を借りれば「分散型ホールパンチング(decentralized hole punching)」)が、専用のシグナリングサーバーの代わりに既存のリレー接続の上で同じ調整を行うことで果たします。必要な事前知識は、ブートストラップノード1つに到達できることだけです。
AutoNAT:自分が到達可能かを知る
AutoNAT v1(/libp2p/autonat/1.0.0)は、単純だが本質的な問題を解決します。ノードには、自分が公衆から到達可能なのか、NATの陰に隠れているのかを知る手立てが標準では備わっていません。そこで他のピアに「電話をかけ直して」もらいます。候補アドレスを列挙したDIALメッセージを送り、OK、E_DIAL_ERROR、E_DIAL_REFUSEDのいずれかのステータスを持つDIAL_RESPONSEを受け取ります。このプロトコルが増幅攻撃に悪用されるのを防ぐため、ダイヤルバックを行うピアはリクエストの到着元として観測したアドレスにのみダイヤルしなければならず、リレー経由で届いたリクエストは拒否しなければなりません。3つを超えるピアからダイヤル成功の報告を受ければ、ノードは自分が公開状態にあると合理的に判断でき、3つを超える失敗があれば、非公開状態にあると判断します。
AutoNAT v2は、この仕組みを2つの点で洗練させています。第一に、ノードの全アドレスを1つの判定にまとめるのではなく、アドレスごとに検証します。1つのノードが複数のアドレス(異なるトランスポート、異なるインターフェース)を持ち、それぞれ本当に異なる到達性を持ちうるため、これは有用です。第二に、本物の検証メカニズムを追加しています。DialBackステップで交換されるナンスにより、リクエスト側はダイヤルバックを行うピアが実際に申告されたアドレスへ接続したことを証明できるようになりました。また、v1では完全に禁止されていた「観測された送信元IPとは異なるアドレスへのダイヤル」も、増幅攻撃への耐性を保つコストを課すことで許可されるようになりました。具体的には、ダイヤルバックの前にリクエスト側が4,096バイト単位のチャンクで30,000〜100,000バイトの埋め草データを先にアップロードする必要があります。
Circuit Relay v2:上限付きのリレー
Circuit Relay v2は2つのプロトコルに分かれます。hop(/libp2p/circuit/relay/0.2.0/hop、クライアントと、それが使いたいリレーの間)と、stop(/libp2p/circuit/relay/0.2.0/stop、リレーと、到達先のピアの間)です。リレーが必要になりそうなクライアントは、hop経由でRESERVEメッセージを送ります。受け入れるリレーは、署名付きバウチャー、有効期限、そして任意で、そのリレーが運ぶ接続の継続時間やデータ量の上限を返します。この予約は期限が切れる前に更新する必要があり、そもそもクライアントがリレーへの接続を維持している間しか有効ではありません。
この上限は、意図的な設計判断を体現しているという点で重要です。Circuit Relay v1には予約の仕組みが一切なく、誰であれ好きなだけトラフィックを流し込めばリレーが過負荷になりかねない状態でした。v2の必須の予約と任意の継続時間/データ上限は、リレーをホールパンチング試行を始めるための一時的な橋渡しであって、直接接続の恒久的な代替ではないものへと変えます。ネットワークがどれだけ成長しても、リレー運営者の帯域コストは頭打ちになります。完全なリレー経由multiaddrは/ip4/198.51.100.0/tcp/55555/p2p/QmRelay/p2p-circuit/p2p/QmAliceのような形になります。リレーは/p2p-circuitサフィックスなしで自分自身のアドレスをアドバタイズし、特定ピアへのリレー経由パスを組み立てる際にクライアント自身がこれを付け足します。
DCUtR:シグナリングサーバーなしで穴を開ける
すでに確立されたリレー経由の接続(通常どおりNoise/TLSハンドシェイクでエンドツーエンドに保護されている)の上で、DCUtR(/libp2p/dcutr)はその接続自体を独自のシグナリングチャネルとして使い、同時直接ダイヤル試行を調整します。メッセージのやり取りは意図的に最小限に抑えられています。CONNECTメッセージが双方の観測アドレスと予測アドレスを運び、SYNCメッセージがタイミングを合わせた同時ダイヤルそのものを引き金にします。
ホールパンチングを成立させているのは、このタイミングの工夫です。インバウンドのリレー接続を受け取った側のピア(Bとします)が最初のCONNECTを送ってタイマーを開始し、AからのCONNECT応答が届いた時点でBはタイマーを止め、これでRTT(往復時間)が測定できます。BはSYNCを送る前にそのRTTの半分(片道の遅延のおおよその見積もり)だけ待ちます。こうすることで、AがSYNCを受け取った瞬間にダイヤルし、BがRTT/2のタイマーが切れた瞬間にダイヤルすれば、両方のダイヤル試行がほぼ同じ瞬間に着地します。これこそがNATのホールパンチングが実際に依存している条件です。
ここから先、TCPとQUICでは挙動が分かれます。TCPの場合、双方が同期した瞬間にアウトバウンドソケットを開き、TCP同時オープン(simultaneous open)によって一発でハンドシェイクを完了させます。その上に乗るものについては、Aがクライアント、Bがサーバーとして扱われます。QUICの場合、AはSYNCを受け取った瞬間にすぐダイヤルしますが、Bはそのままダイヤルはしません。代わりに10〜200ミリ秒ごとにランダムなバイト列で満たしたUDPパケットをAのアドレスへ撃ち続け、実際のQUICハンドシェイクを試みることなくNATのバインディングをこじ開けるのに必要なだけのトラフィックを送り、Aからの本物の接続試行が通るのを待ちます。最初の試行が失敗した場合、インバウンド側のピアは最大あと2回リトライします(合計3回)。
すべてをつなげると、一連の流れは次のようになります。Identifyが双方に自分の観測アドレスを教える → AutoNATがそのアドレスが実際に外部から到達可能かどうかを教える → 非公開だと分かれば、(通常はDHT経由で)リレーを発見し、Circuit Relay v2の予約を確保する → そのリレー経由で対象への最初の接続が乗る → DCUtRがリレー経由チャネルの上でCONNECT/SYNCのやり取りを行い、同期した直接ダイヤル試行を発火させる → 成功すれば接続は直接経路へアップグレードされリレーは手放され、失敗すればリレー経由の接続がフォールバックとしてそのまま残ります。
実世界での計測:punchrキャンペーン
設計上の意図と、実際に機能するかどうかは別問題です。Protocol Labsが2022年12月から2023年1月にかけて、punchrという専用ツールを使って実施した計測キャンペーンが、分散型ホールパンチングがインターネット規模で実際に機能するかどうかを教えてくれます。データは、167カ国にまたがる85,000を超えるユニークなネットワークでの、およそ440万回のホールパンチング試行から得られたものです。
最も注目すべき数字はこうです。ピアが前段のステージ(使える観測アドレスの取得、リレー予約の確保)を突破すれば、ホールパンチング自体は70% ± 7.1%の確率で成功します。ただしこれは条件付きの数字で、全試行の約29%はホールパンチングの段階にすら到達せず、アドレス発見やリレー予約の失敗によってそれ以前に脱落しています。そしてホールパンチングが成功する場合、圧倒的多数が1回目の試行での成功です。97.6%が最初の試行で成功しており、DCUtRに組み込まれたリトライが担っているのは残り2.4%だけです。
生の成功率だけを見ると、TCPとQUICの間に統計的に有意な差はなく(どちらもおよそ70%前後に収まります)、ただしQUICはIPv4に限ってばらつきが小さい傾向があり、2つのトランスポートを同時に競走させた場合には、QUICが直接接続の確立を81%の確率で先に勝ち取ります。やや直感に反しますが、RTT(往復時間)は結果と測定可能な相関を持たず、調査ではホールパンチングの成功は「往復時間には依存しない」と結論づけられています。IPv6はIPv4に比べて意外にも成績が振るいませんでしたが、その差をさらに細かく分解できるデータは公開されていません。従来の2つの学術的なベンチマーク(約64%の成功率を報告した2011年の研究と、93台の家庭用NATを対象にした88%を報告するより小規模な2005年の研究)と比べると、今回の70%という数字は、桁違いに大きく、地理的な代表性もはるかに高いデータセットから得られたものです。
確実に失敗するNAT越えのケースが1つだけあります。Symmetric NAT配下のピアです。Symmetric NATはアウトバウンド接続ごとに異なる、事実上予測不可能な外部ポートを割り当てます。DCUtRの調整プロトコル自体が根本的に壊れるわけではなく、本当の障害は、相手側が推測しなければならないポートが事前に予測できないという点にあります。ホールパンチングが失敗した場合、接続はそれまで使っていたCircuit Relay v2の経路をそのまま使い続けます。リレーは単にホールパンチング試行への橋渡し役であるだけでなく、ホールパンチングが決して届かないケースのための安全網でもあります。
P2Pの共通基盤としてのlibp2p
個々の層から一歩引いて眺めると、同じパターンが繰り返し現れます。アイデンティティはアドレスではなくハッシュ化された公開鍵であり、アドレスは前提とされるものではなく自己記述的であり、トランスポート・セキュリティ・多重化はすべて固定ではなくネゴシエートされ、NATを越える仕組みでさえ専用サーバーではなく普通のピアから組み立てられています。これらの要素のどれ1つとして、libp2p固有のものではありません。DHTもゴシッププロトコルもNAT越えも、それぞれがすでによく研究された独立の分野として存在しています。しかし、それらを一貫した差し替え可能なモジュールインターフェースの背後にまとめ上げたことが、1つのファイル共有プロジェクトのために作られたワイヤプロトコルを、ブロックチェーンのコンセンサスクライアント、ストレージネットワーク、ブラウザトランスポートを支えるネットワーク層へと押し上げた要因です。それらのどれもが、どのモジュールを差し込むか以上の合意を必要としていません。