高性能エッジトンネリング:ゼロコピーI/O、NAT越え、QUIC 0-RTTアーキテクチャ
低レベルのトンネル最適化を習得しましょう。Linuxのsplice/vmsplice、UDPホールパンチ、PCP、QUIC 0-RTTが遅延を削減し、CPU負荷を排除する方法を解説します。

Quick answer
高性能ローカルトンネル:ゼロコピー、CGNAT、QUIC: localhost tunnel answer
A localhost tunnel gives your local app a public HTTPS URL without opening router ports, which is useful for demos, QA, mobile testing, and provider callbacks.
How do I expose localhost without opening ports?
Use a reverse HTTPS tunnel. Your machine connects outbound to the tunnel service, and the public URL forwards requests back to your local app.
When should I use a localhost tunnel?
Use one for webhook testing, OAuth callbacks, client demos, QA previews, mobile device checks, and short-lived development reviews.
高速で耐障害性のあるトンネルクライアントを構築するには、3つの異なる層で制限を回避する必要があります:
- Kernelからユーザースペースへのオーバーヘッド:プロキシが転送する各バイトはカーネルとユーザースペースの境界を2回コピーし、各チャンクには2つのシステムコールが必要です。
- ネットワークトポロジー:エンドポイント依存のNATやCarrier-Grade NAT(CGNAT)が直接のピアツーピアパスを妨げます。
- トランスポート遅延:新しいTCP + TLS接続はハンドシェイクにラウンドトリップを要し、最初のリクエストバイトを送信する前に遅延が発生します。これが短命なリクエストに悪影響を及ぼします。
このガイドでは、各層のカーネルプリミティブ、プロトコル、設計のトレードオフについて解説します。また、各技術がどこで役立たなくなるかも示しています。以下のコードサンプルは、ループバックエコーテストまたはモックサーバーに対してコンパイル・実行されたものです。
パート1:splice()を使ったユーザースペースコピーの削減
従来のユーザースペースフォワーダーは、1つのソケットからアプリケーションバッファに読み込み、そのバッファを別のソケットに書き込みます:
[ 従来のフォワーディング ]
Kernel : [ ソケットAの受信バッファ ] --read()--> (コピー) --+
v
User : [ アプリケーションバッファ ]
|
Kernel : [ ソケットBの送信バッファ ] <--write()-- (コピー) -+
バルク転送の場合、これにはチャンクごとに2回のコピーと2つのシステムコールが必要で、CPUキャッシュと競合します。
splice()の役割
Linuxのマニュアルページによると、splice()はカーネルアドレス空間とユーザースペース間のコピーを行わずに、2つのファイル記述子間でデータを移動します。少なくとも一方はパイプでなければなりません。この呼び出しはLinux 2.6.17以降利用可能で、2.6.31以降は両方の記述子がパイプでも構いません。vmsplice(2)やtee(2)と組み合わせることで、プログラムはパイプに実装されたカーネルバッファを制御できます。
[ spliceパイプライン ]
Kernel : [ ソケットA ] --splice--> [ パイプバッファ ] --splice--> [ ソケットB ]
User : (ペイロードは一切見えません)
シグネチャは次の通りです:
#define _GNU_SOURCE
#include <fcntl.h>
ssize_t splice(int fd_in, loff_t *off_in,
int fd_out, loff_t *off_out,
size_t len, unsigned int flags);
よく誤解される3つのポイント:
SPLICE_F_MOVEは現代のカーネルでは何もしません。 マニュアルには、元の実装にバグがあったため、Linux 2.6.21以降はフラグは受け入れられるものの何もしないと記されています。ページ移動の動作には頼らないでください。SPLICE_F_NONBLOCKはパイプ側の操作をノンブロッキングにします。呼び出しは他の記述子がブロッキングソケットの場合はブロックする可能性があるため、ソケットにもO_NONBLOCKを設定してください。SPLICE_F_MOREは、後続のsplice()でさらにデータが続くことを示唆します。これはパイプからソケットへのレグで有効です。
「ユーザースペースへのコピーなし」は、「ゼロコピー」と同じではありません。マニュアルは前者のみを保証しています。
vmsplice()の位置付け(および非対応部分)
vmsplice(2)はユーザーメモリをパイプに接続します。パイプが書き込み可能な場合、メモリ範囲をパイプにマッピングし、読み取り可能な場合はバッファにデータをコピーします。これにより、vmspliceは自分のコードがデータを生成する場合(例:事前に作成したフレーム)に有効であり、ソケット間のフォワーディングには適しません。
注意点は、パイプがページをマッピングするため、カーネルが消費するまでそのメモリを変更してはいけません。SPLICE_F_GIFTはページを渡すことを宣言し、アプリケーションはそれらを読み取り専用として扱う必要があります。これにはページアラインのアドレスと長さも必要です。
spliceが役立つ場合とそうでない場合
spliceは、トンネルが読み取ったり変換したりする必要のないバイトを転送する場合にのみ適用されます:
- プレーンなTCP-から-TCPのフォワーディング(不透明なストリームを運ぶTCPトンネルやTLSパススルー):spliceは適合します。
- ユーザースペースでTLS終端、またはQUIC:アプリケーションが平文を見る必要があるため、spliceは役立ちません。カーネルのTLSオフロード(kTLS)はTCPにおいてこのギャップを狭めることができ、ドキュメントには送信経路での
sendfileサポートが記載されています。 - QUICや他のUDPプロトコルはspliceを使えません。利用可能な手段はバッチ処理です。GoogleのQUICHEライブラリはUDP GSO (
UDP_SEGMENT)を使用し、1つのsendmsgで複数のセグメントを送信し、sendmmsgで複数パケットを一度に送信します(QUICHEソース)。 - io_uringも
io_uring_prep_splice(3)を通じてspliceを提供しており、完了通知ループを構築している場合に有用です。
Go:標準ライブラリをまず使い、チューニングのためだけにハンドロール
GoのnetパッケージはすでにLinux上でspliceを利用しています。*net.TCPConnから別のTCP接続へのio.Copyは、内部でsplice経路を取り、適用できない場合は通常のコピーにフォールバックします(net/splice_linux.go)。ほとんどのプロキシでは、io.Copy(dst, src)が最適解です。
自作のバージョンは、標準ライブラリが提供しない制御(パイプサイズ、チャンクサイズ、コネクションごとのアカウンティング)を必要とする場合にのみ価値があります。以下のコードは、syscall.RawConnを通じてGoのnetpollerと連携し、ブロック待ちをゴルーチンに保留させる仕組みです:
//go:build linux
package main
import (
"errors"
"fmt"
"net"
"golang.org/x/sys/unix"
)
// SplicePipeは、2つのソケット間のステージングバッファとして使用されるカーネルパイプです。
type SplicePipe struct{ r, w int }
// NewSplicePipeは非ブロッキングパイプを作成し、サイズを調整します。特権のない
// プロセスは/proc/sys/fs/pipe-max-size(デフォルトは1MiB)によって制限されます。
func NewSplicePipe(size int) (*SplicePipe, error) {
var fds [2]int
if err := unix.Pipe2(fds[:], unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil {
return nil, fmt.Errorf("pipe2: %w", err)
}
if _, err := unix.FcntlInt(uintptr(fds[0]), unix.F_SETPIPE_SZ, size); err != nil {
unix.Close(fds[0])
unix.Close(fds[1])
return nil, fmt.Errorf("F_SETPIPE_SZ(%d): %w", size, err)
}
return &SplicePipe{r: fds[0], w: fds[1]}, nil
}
func (p *SplicePipe) Close() { unix.Close(p.r); unix.Close(p.w) }
// Copyは、srcからdstへEOFに達するまでバイトを移動します。
func (p *SplicePipe) Copy(dst, src *net.TCPConn, chunk int) (int64, error) {
srcRC, err := src.SyscallConn()
if err != nil {
return 0, err
}
dstRC, err := dst.SyscallConn()
if err != nil {
return 0, err
}
var total int64
for {
// ステージ1:socket -> pipe
var n int64
var serr error
rerr := srcRC.Read(func(fd uintptr) bool {
for {
n, serr = unix.Splice(int(fd), nil, p.w, nil, chunk, unix.SPLICE_F_NONBLOCK)
if serr != unix.EINTR {
break
}
}
// false = runtimeはソケットが読み取り可能になるまで待機し、再試行します
return !errors.Is(serr, unix.EAGAIN)
})
if rerr != nil {
return total, rerr
}
if serr != nil {
return total, fmt.Errorf("splice socket->pipe: %w", serr)
}
if n == 0 {
return total, nil // EOF
}
// ステージ2:pipe -> socket。ステージされたデータをすべて排出
for left := n; left > 0; {
var w int64
werr := dstRC.Write(func(fd uintptr) bool {
for {
w, serr = unix.Splice(p.r, nil, int(fd), nil, int(left),
unix.SPLICE_F_NONBLOCK|unix.SPLICE_F_MORE)
if serr != unix.EINTR {
break
}
}
return !errors.Is(serr, unix.EAGAIN)
})
if werr != nil {
return total, werr
}
if serr != nil {
return total, fmt.Errorf("splice pipe->socket: %w", serr)
}
left -= w
total += w
}
}
}
各方向に1つのパイプを使用します。Copyが終了したら、dst.CloseWrite()を呼び出してEOFを半クローズとして伝播させます。このコードを使った2ホップのループバックエコーで64MiBのペイロードがバイト単位で一致して返ってきました。
Rust:Tokioイベントループ内でspliceを使う
Rustでは、nixクレートがspliceをラップしていますが、そのspliceとSpliceFFlagsはzerocopy Cargo機能の下にあります(以下のコードはnix 0.30を前提)。TokioのTcpStream::async_ioは、その準備完了追跡の下で生のsyscallを実行するためのクリーンな方法です:クロージャがWouldBlockを返すと、Tokioは準備状態をクリアし、再び待機します。
[dependencies]
nix = { version = "0.30", features = ["fs", "zerocopy"] }
tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "io-util"] }
use std::io;
use std::os::fd::OwnedFd;
use nix::fcntl::{fcntl, splice, FcntlArg, OFlag, SpliceFFlags};
use nix::unistd::pipe2;
use tokio::io::Interest;
use tokio::net::TcpStream;
/// 2つのソケット間のステージングバッファとして使用されるカーネルパイプ。
pub struct SplicePipe {
r: OwnedFd,
w: OwnedFd,
}
impl SplicePipe {
pub fn new(size: i32) -> io::Result<Self> {
let (r, w) = pipe2(OFlag::O_CLOEXEC | OFlag::O_NONBLOCK)?;
fcntl(&w, FcntlArg::F_SETPIPE_SZ(size))?;
Ok(Self { r, w })
}
/// `src`から`dst`へEOFに達するまでバイトを移動します。
pub async fn forward(&self, src: &TcpStream, dst: &TcpStream, chunk: usize) -> io::Result<u64> {
let mut total = 0u64;
loop {
// socket -> pipe(EAGAINはWouldBlockに変換され、Tokioが待機します)
let n = src
.async_io(Interest::READABLE, || {
splice(src, None, &self.w, None, chunk, SpliceFFlags::SPLICE_F_NONBLOCK)
.map_err(io::Error::from)
})
.await?;
if n == 0 {
return Ok(total); // EOF
}
// pipe -> socket:ステージされたデータをすべて排出
let mut left = n;
while left > 0 {
let w = dst
.async_io(Interest::WRITABLE, || {
splice(
&self.r,
None,
dst,
None,
left,
SpliceFFlags::SPLICE_F_NONBLOCK | SpliceFFlags::SPLICE_F_MORE,
)
.map_err(io::Error::from)
})
.await?;
left -= w;
total += w as u64;
}
}
}
}
各TcpStreamをArcでラップし、一方のタスクが各方向にフォワードできるようにし、片方の終了時に書き込み側をシャットダウンします。同じ64MiBのループバックエコーテストはこのコードでも成功しました。
実用的なガイダンス
- ワークロードで計測してください。spliceはユーザースペースのコピーとチャンクごとのシステムコールペアを排除しますが、パイプ管理や(この設計では)チャンクごとに2つ目のシステムコールを追加するため、小さなメッセージや大きく変換されたストリームには効果が薄い場合があります。
- デフォルトのパイプ容量は64KiBです。
F_SETPIPE_SZで増やすことはスループットの調整に有効ですが、/proc/sys/fs/pipe-max-sizeの制限があります。 - Linux限定:spliceはLinux固有の機能です。他のプラットフォーム向けにはポータブルなフォールバック(
io.Copyやtokio::io::copy)を用意してください。
パート2:NATとCGNATの越え方
キャリアグレードNATの特徴
ほとんどの家庭用ルータは1層のNATを行います。CGNAT下では、ISPが2層目のNATを追加します。RFC 6598(BCP 153)は、サービスプロバイダがCGN間のリンクに割り当てるための共有アドレス空間100.64.0.0/10を予約しています。ルータのWANアドレスがこの範囲にある場合、ほぼ確実にCGNATの背後にいます。
[ CGNAT:2つのNATが連続 ]
ノートパソコン (192.168.1.50)
└─> ホームルータNAT WANアドレス:100.64.10.15 (RFC 6598共有空間)
└─> ISP CGNAT パブリックアドレス:198.51.100.1:45210 (ドキュメント用アドレス)
└─> インターネット
(この記事内の198.51.100.xや203.0.113.xは予約されたドキュメント用アドレスであり、実在のホストではありません。)
NATの挙動:2つの独立した特性
古い用語の「フルコーン / 制限コーン / ポート制限コーン / 対称NAT」は、STUN時代の初期の用語です。現代の説明(例: Tailscale)では、NATの挙動を2つの特性に分けて解説し、これらのカテゴリーはそれらの大まかなマッピングとしています:
- マッピング:外部ポートは宛先に依存しますか? 依存しなければ「簡単」なNAT、依存すれば「難しい」です。
- インバウンドフィルタリング:どのリモートアドレスからのパケットを許可しますか?
| 旧称 | マッピング | インバウンドフィルタリング | UDP直接通信 |
|---|---|---|---|
| フルコーン | すべての宛先で同じ外部ポート | 任意のリモートホスト | 容易 |
| アドレス制限コーン | すべての宛先で同じ外部ポート | 既に送信したIPのみ | 一方が最初に送信すれば容易 |
| ポート制限コーン | すべての宛先で同じ外部ポート | 既に送信したIP:ポートのペア | 一方が最初に送信すれば容易 |
| 対称 | 宛先ごとに新しい外部ポート | 既に通信したIP:ポート | 難しい |
直接パスには、少なくとも片側に簡単なマッピングNATが必要です。家庭用ルータは一般的に容易なNATですが、オフィスルータやクラウドのNATゲートウェイは難しいNATです。
STUN、ランデブー、ホールパンチング
RFC 8489(RFC 5389の後継)は、STUNを補助ツールとして定義しています:エンドポイントはこれを使ってNATが割り当てたパブリックアドレスとポートを学習し、接続性を確認し、NATのマッピングを維持します。RFCは、STUNはICEのような完全なトラバーサルソリューション内で使われるツールであり、単体の解決策ではないと明示しています。
これにより、一般的な誤解が修正されます:STUNサーバはピア間でアドレスを交換しません。 別のランデブー(シグナリング)チャネル(通常はコントロールプレーン)が、各側の観測されたパブリックエンドポイントを相手に伝えます。
- 両エンドポイントはSTUNバインディングリクエストを送信し、それぞれのパブリック
ip:port(例:198.51.100.1:10050と203.0.113.8:20080)を学習します。 - 各エンドポイントは、その情報をランデブーサービスに報告し、相手のエンドポイントを受け取ります。
- 両者はほぼ同時にUDPパケットを相手のパブリックエンドポイントに送信します。これにより、NATの状態が作られ、相手側のパケットを通過させることが可能になります。最初のパケットはドロップされることもありますが、これは想定内です。
- パケットが流れ始めたら、マッピングが期限切れにならないようにキープアライブを送信し続けます。
[ UDPホールパンチング ] クライアントA STUNサーバ ランデブーサービス クライアントB |--- バインディングリクエスト --->| | | |<-- A_pub=198.51.100.1:10050 --------|<--- バインディングリクエスト ---| | | | B_pub=203.0.113.8:20080 |--- A_pub登録 ----------------------->|<--- B_pub登録 -----------| |<-- B_pub ----------------------------|--- A_pub ----------------->| | | |=== AからBへのパンチ(BのNATによりドロップされる可能性あり) ===> |<================ BからAへのパンチ ===============================| |<================== 直接UDPパス ================================|
ハードNATの対処:誕生日パラドックスのトリックと実数
片側が難しいNAT、もう片側が容易なNATの場合、容易側はどのポートに送るべきか分かりません。Tailscaleの解説では、誕生日パラドックスが役立つと示しています:難しい側は多くのソケット(例:256)を開き、それぞれにランダムな外部ポートを割り当て、容易側はランダムな宛先ポートを試します。投稿の数字によると、難しい側に256ポートを開き、約174のランダムプローブで約50%、256 probesで64%、1024 probesで98%、2048 probesで99.9%の確率で一致します。
両側ともに難しい場合、探索空間はポートのペアとなり、確率は崩壊します。同じ分析では、20秒間の成功確率は約0.01%、99.9%に到達するには約170,000のプローブ(約28分、100パケット/秒)が必要です。著者は、積極的なプロービングは小さなNATデバイスのセッションテーブルを枯渇させる可能性も警告しています。
実用的な結論は、ハードNAT対ハードNATのパスにはリレーが必要ということです。
ポートマッピングプロトコル:有用だが最も近いNATでのみ有効
UPnP IGD、NAT-PMP、PCPはすべて、ゲートウェイにポートをフォワードさせることができます。これらはリクエスト/レスポンスパターンを共有し、WAN側のポートをLANアドレスにフォワードし、割り当てられたパブリックアドレスとポートを受け取ります。重要な制限は、これらがクライアントに最も近いNATのみを操作する点です。二重NAT構成では、外側のNATには到達できません。家庭用ルータのUPnPやNAT-PMPは、100.64.x.xのWAN側のポートを開くだけであり、インターネットからの到達性は得られません。
PCP(RFC 6887)はNAT-PMPの後継で、IPv4とIPv6の両方をサポートします。トンネルクライアントにとっての有用な特徴は:
- サーバはUDPポート5351で待ち受け、クライアントはUDP 5350でマルチキャストANNOUNCEメッセージを待ち受けます。
- MAP命令はインバウンドマッピングを要求します。推奨される外部ポートとアドレスはヒントであり、サーバが別のものを割り当てることもあります。
PREFER_FAILUREオプションは、推奨されたマッピングが利用できない場合に代替を作成しないようサーバに指示します。サーバがこのリクエストを満たせない場合はCANNOT_PROVIDE_EXTERNAL(結果コード11)を返します。UNSUPP_VERSION(結果コード1)は、サーバが使用したPCPバージョンに対応していないことを示します。
PCPが*CGN*に到達できるかどうかは、ISPが加入者向けにPCPサーバを運用しているかに依存します。これは事前にわかりません。PCPは最初の試みとして扱い、確実なフォールバックではありません。
PCP MAPリクエストのレイアウト(RFC 6887セクション7.1と11.1)
MAPリクエストは60バイトです:24バイトの共通ヘッダと36バイトのMAP本文。
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| バージョン=2 |R| Opcode=1 | 予約 |
+---------------+---------------+------------------------------+
| 要求された存続時間(32ビット) |
+--------------------------------------------------------------+
| |
| PCPクライアントのIPアドレス(128ビット) |
| |
| |
+--------------------------------------------------------------+
| |
| マッピングノンス(96ビット) |
| |
+---------------+----------------------------------------------+
| プロトコル | 予約(24ビット) |
+------------------------------+------------------------------+
| 内部ポート | 推奨外部ポート |
+------------------------------+------------------------------+
| |
| 推奨外部IPアドレス(128ビット) |
| |
| |
+--------------------------------------------------------------+
IPv4アドレスはIPv4-mapped IPv6 (::ffff:a.b.c.d)として運ばれます。応答では、「R」ビットが設定され、ヘッダに結果コードとエポック時間が追加され、MAP本文には割り当てられた外部ポートとアドレスが提案の代わりに記載されます。
Go:PCP MAPクライアント例
package main
import (
"bytes"
"crypto/rand"
"encoding/binary"
"fmt"
"net"
"time"
)
const (
pcpServerPort = 5351
pcpVersion = 2
opMap = 1
protoUDP = 17
pcpReqLen = 60 // 24バイトの共通ヘッダ + 36バイトのMAP本文
)
// PCPMappingは、PCPサーバが付与した情報です。
type PCPMapping struct {
ExternalIP net.IP
ExternalPort uint16
Lifetime time.Duration
}
// RequestMappingは、ゲートウェイのPCPサーバに外部UDPトラフィックをローカルの`localIP:internalPort`にフォワードさせるよう要求します(RFC 6887, MAP opcode)。
func RequestMapping(gateway, localIP net.IP, internalPort uint16, lifetime uint32) (*PCPMapping, error) {
// localIPにバインド:PCPクライアントのIPはパケットの送信元と一致する必要があります。
conn, err := net.DialUDP("udp",
&net.UDPAddr{IP: localIP},
&net.UDPAddr{IP: gateway, Port: pcpServerPort})
if err != nil {
return nil, err
}
defer conn.Close()
var nonce [12]byte
if _, err := rand.Read(nonce[:]); err != nil {
return nil, err
}
req := make([]byte, pcpReqLen)
req[0] = pcpVersion // バージョン
req[1] = opMap // Rビット=0(リクエスト)、opcode=1
binary.BigEndian.PutUint32(req[4:8], lifetime)
copy(req[8:24], localIP.To16()) // クライアントIP(IPv4-mapped for IPv4)
copy(req[24:36], nonce[:]) // マッピングノンス
req[36] = protoUDP // 予約済みバイト37-39
binary.BigEndian.PutUint16(req[40:42], internalPort)
binary.BigEndian.PutUint16(req[42:44], internalPort) // 推奨外部ポート(ヒント)
// 44-59バイトは推奨外部IP(空は未指定)
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := conn.Write(req); err != nil {
return nil, err
}
resp := make([]byte, 1100) // PCPメッセージは最大1100バイト
n, err := conn.Read(resp)
if err != nil {
return nil, fmt.Errorf("PCPレスポンスなし: %w", err)
}
return parseMapResponse(resp[:n], nonce)
}
func parseMapResponse(b []byte, nonce [12]byte) (*PCPMapping, error) {
if len(b) < pcpReqLen {
return nil, fmt.Errorf("短いPCPレスポンス: %dバイト", len(b))
}
if b[0] != pcpVersion {
return nil, fmt.Errorf("予期しないPCPバージョン %d", b[0])
}
if b[1] != 0x80|opMap { // Rビットセット + MAP opcode
return nil, fmt.Errorf("MAPレスポンスではありません(バイト1=%#x)", b[1])
}
if code := b[3]; code != 0 {
return nil, fmt.Errorf("PCP結果コード %d", code)
}
if !bytes.Equal(b[24:36], nonce[:]) {
return nil, fmt.Errorf("マッピングノンス不一致")
}
return &PCPMapping{
Lifetime: time.Duration(binary.BigEndian.Uint32(b[4:8])) * time.Second,
ExternalPort: binary.BigEndian.Uint16(b[42:44]),
ExternalIP: net.IP(append([]byte(nil), b[44:60]...)).To16(),
}, nil
}
クライアントはレスポンスのバージョン、レスポンスビット、結果コード、ノンスを検証し、割り当てられたアドレスを信用します。モックサーバーに対して動作確認済みで、実際のCGNには未検証です。マッピングの有効期限前に更新し、後のレスポンスでエポック時間が変わっている場合は、サーバの状態喪失の兆候とみなします。
層別の接続戦略
現実的なトンネルクライアントは、コストの高い順にパスを試し、常にリレーを最終手段とします:
- 両側がIPv6を持つ場合の直接接続。
- 最も近いゲートウェイでのポートマッピング(PCP、NAT-PMP、UPnP)。
- コントロールプレーンを介したSTUN支援のホールパンチング。
- 一方だけがハードNATの場合の誕生日パラドックス方式のプロービング。
- 両側がハードNATまたはそれ以上のステップに失敗した場合のリレー。
ホステッドトンネルサービスの場合、リレーパスがデフォルトで、それ以外はリレーコストと遅延を削減するための最適化です。
パート3:QUIC 0-RTTによるトンネル再接続
ハンドシェイクのコスト
新しいTCP接続はTLS開始前にラウンドトリップ1を必要とし、TLS 1.3はさらにもう1つ必要です。QUICはトランスポートと暗号ハンドシェイクを一体化し、新しい接続は1ラウンドトリップで済みます。セッション再開を使えば、QUICはクライアントが最初のフライトでアプリケーションデータを送れるようにします:0-RTT。
[ 最初のリクエストバイトまでの時間 ]
TCP + TLS 1.3 QUIC(新規) QUIC 0-RTT(再開)
SYN ------------> 初期 -------e 初期 + 0-RTTデータ -e
<-- SYN-ACK <--- ハンドシェイク <--- ハンドシェイク完了
ClientHello ----> データ -------->
<-- ServerHello..Fin
データ --------->
(2ラウンドトリップ) (1ラウンドトリップ) (0ラウンドトリップ)
RFC 8470は、HTTPの早期データの利点をTLSハンドシェイクの遅延回避にありますが、主なリスクも明記しています:攻撃者がリクエストを捕捉・リプレイする可能性です。
QUICにおける再開の仕組み
RFC 9001セクション4.6によると:
- クライアントはTLSの状態、QUICのトランスポートパラメータ、選択したアプリケーションプロトコル、0-RTTパケットを構築するための情報を、同じ前回の接続から保持します。TLS 1.3はこれらの間隔を7日以内に制限します。
- サーバのセッションチケットは、QUIC CRYPTOフレーム内の
NewSessionTicketハンドシェイクメッセージとして到着します(別のQUICフレームタイプではありません)。 max_early_data_sizeはセンサーとしてのみ使用され、サーバは0xffffffffを送信し、クライアントは他の値をプロトコル違反とみなします。0-RTTデータの量は、サーバのinitial_max_dataトランスポートパラメータによって制御されます(チケット内で交渉される値ではありません)。- サーバは
EncryptedExtensionsにearly_dataを含めることで受け入れ、含めない場合は拒否します。拒否された場合、クライアントは0-RTTを使用したすべてのストリームの状態をリセットします。 - アプリケーションは、0-RTTの使用に関するプロファイルを定義する必要があります。なければ、0-RTTはアプリケーションデータを含まないQUICフレームのみを運び、クライアントはアプリケーションが要求しない限り0-RTTでのアプリケーションデータ送信を行わないでください。
0-RTTが*実際に*役立つ場面
RFC 8446のセクション2.3は、0-RTTデータの弱い保証を次のように説明しています:
- フォワードシークレットではない:事前共有鍵から導出されたキーのみで保護されるため。
- 再プレイ保護はない:サーバのランダム値に依存しないため。単一の接続内では重複しません。
再プレイ対策:層別のアプローチ
トランスポートとTLS層。 TLS 1.3は、セクション8で3つのサーバ側技術を記述しています:使い捨てチケット、ClientHelloメッセージの記録、鮮度チェック(クライアントのチケット経過時間とサーバの経過時間の比較)。これらは状態管理やクロック許容範囲とトレードオフであり、完全な再プレイ防止にはなりません:それらは減少させ、再プレイの数を制限します。複数サーバ展開では、使い捨てチケットとClientHello記録は共有状態を必要とします。
QUIC層。 RFC 9001のセクション9.2は、QUICのトランスポート状態は再プレイによって害されないと述べています:フレーム処理は冪等であり、しかし、アプリケーションがデータとともに行うことには影響します。セッションチケットやアドレス検証トークンはアプリケーションの意味を持たせてはいけません。サーバが0-RTTを受け入れる場合、追加の処理コストが発生します。最も効果的な防御は、0-RTTを無効にすることです。
HTTP層。 RFC 8470はHTTPレベルのツールを提供しています:
- 早期リクエストを受け取った中継点は、転送時に
Early-Data: 1ヘッダを付与します。 - リプレイリスクが高いと判断したオリジンは
425 Too Earlyを返します。 - クライアントは425を受け取ったリクエストを再送し、中継点は
Early-Dataヘッダを持つリクエストに対しても425を転送します。 - 処理に状態変更が伴わない場合、リプレイの影響は一般的に大きくありません。
Cloudflareのアプローチは参考になります: 0-RTTサポートはGET、HEAD、OPTIONSリクエストをカバーし、POSTは除外しています。Early-Data: 1ヘッダを付与し、オリジンが早期リクエストを識別できるようにします。0-RTTリクエストはリプレイされる可能性があるため、そのリスクが要件に合うかどうかを評価してください。
サーバ側の意思決定フロー
受信したClientHello(PSK + 0-RTTデータ)
|
v
チケットが有効かつ未期限? ----- いいえ ---e 0-RTTを拒否し、通常の1-RTTハンドシェイクを実行
| はい
v
リプレイチェック(使い捨てチケットストア、ClientHello記録、または経過時間の新鮮さ)
| パスする e 失敗
v ---e 0-RTTを拒否し、通常の1-RTTハンドシェイクを実行
EncryptedExtensionsでearly_dataを受け入れる
|
v
アプリケーション/HTTP層でのリクエストごとのポリシー
安全なメソッド(GET、HEAD、OPTIONS) -----e すぐに処理
状態変更を伴うもの -----e 425 Too Early、クライアントはハンドシェイク後に再送
425 Too Early用のGoミドルウェア
リレーがEarly-Data: 1を付与した早期データリクエストをフォワードする場合、アプリケーションは状態変更メソッドを拒否できます:
package main
import "net/http"
// rejectUnsafeEarlyDataは、リレーが受信した早期データのリクエストに対し、状態変更メソッドの場合は425 Too Earlyを返します。
func rejectUnsafeEarlyData(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Early-Data") == "1" && !isSafeMethod(r.Method) {
w.Header().Set("Retry-After", "0")
http.Error(w, "ハンドシェイク完了後に再試行してください", http.StatusTooEarly)
return
}
next.ServeHTTP(w, r)
})
}
func isSafeMethod(m string) bool {
switch m {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
return false
}
このテストはGET、HEAD、POST、PUTをヘッダ有無でカバーし、意図的に安全なメソッドに限定しています。PUTは冪等ですが、安全ではない場合もあります(例:古い書き込みのリプレイ)。
0-RTTがトンネルに実際に役立つ場面
0-RTTは再開される接続に適用され、次の場面で効果的です:
- エージェントとリレーの再接続。 スリープやネットワーク変更後のトンネルエージェントは、セッションを完全なハンドシェイクを待たずに再開できます。
resume session Xのような冪等制御フレームだけを0-RTTに入れ、状態変更はハンドシェイク完了後に行います。 - HTTP/3を使ったパブリッククライアントのエッジ接続。 上記のHTTP層ルールを適用します。WebhookのPOSTリクエストはCloudflareの0-RTTサポートの対象外です。
- アドレスの検証前に0-RTTリクエストを処理できる点に注意。 RFC 9001のセクション9.3は、アドレス検証前に0-RTTリクエストを処理できることと、その増幅攻撃リスクについて述べています。サーバは送信量を受信量の3倍に制限します(RFC 9001セクション9.3)。
接続の移行は補完的な機能です。QUICは接続IDを用いて接続を識別し、NATバインディングやIPアドレスの変更後も、新たなハンドシェイクなしで同じ接続を継続できます(RFC 9000セクション9)。
UDP/IP over HTTP/3の標準的な構成要素
UDPやIPパケットを運ぶ場合、フレーミング層を自作する必要はありません。RFC 9298はHTTPでのUDPプロキシを定義し、RFC 9484はIPのHTTPプロキシを定義しています(2023年10月版)。これらはQUICの信頼性の低いデータグラム拡張(RFC 9221)を利用しています。標準的な形状を使うことで、市販のHTTP/3スタックやミドルボックスがトラフィックを理解できます。
展開チェックリスト
- デフォルトは0-RTTをオフにし、名前付きの冪等コントロールプレーン操作にのみ有効化します。
- 状態を共有できる場合は使い捨てチケットやClientHello記録を使用し、できない場合は鮮度チェックを適用します。
- 早期データリクエストには
Early-Data: 1を付与し、425をエンドツーエンドで尊重します。 - 資格情報や支払い、状態変更は0-RTTに含めないでください。
- チケット暗号化キーを定期的にローテーションし、TLS 1.3の7日制限より短く設定します。
- UDPをフィルタリングするネットワーク向けにTCP/TLSのフォールバックを計画します。
参考資料
- splice(2), Linuxマニュアル
- vmsplice(2)
- Go
net/splice_linux.go - LinuxカーネルのTLSドキュメント
- QUICHE Linuxソケットユーティリティ(UDP GSO、sendmmsg)
- RFC 6598:共有アドレス空間用のIANA予約IPv4プレフィックス
- RFC 8489:NATトラバーサル用セッションユーティリティ(STUN)
- NATトラバーサルの仕組み(Tailscale、APNIC経由)
- RFC 6887:ポート制御プロトコル(NAT-PMP)とIANA PCPパラメータ
- RFC 8446:TLS 1.3
- RFC 9001:QUICのTLS利用
- RFC 8470:HTTPでの早期データ利用
- RFC 9298:HTTPでのUDPプロキシとRFC 9484:IPのHTTPプロキシ
- Cloudflare:0-RTT接続再開
Related InstaTunnel pages
Continue from this article into the most relevant product guides and workflows.
Related Topics
Keep building with InstaTunnel
Read the docs for implementation details or compare plans before you ship.