付録 B:同じトンネルを yamux と HTTP/2 で書き直す
ngrok は muxado を選んだ。だが、他の選択肢もあった
ここまでの本編で、私たちは ngrok のトンネルプロトコル muxado を主軸に据えて mintunnel を組み上げてきた。ch04 で多重化を導入し、ch06 で TypedStreamSession によって制御プレーンとデータプレーンを分けた。
その途中で、何度か別の選択肢を匂わせている。
- ch04 末尾:「同じことを HashiCorp の yamux や Go 標準の
golang.org/x/net/http2で書くと API はどう違うのか」 - spec.md §6:「ch06 → 付録 B:ch06 の muxado 版を、付録 B で yamux と HTTP/2 で書き直して比較」
この付録はその伏線回収だ。ch06 で完成した mintunnel の中核(サーバーがクライアントに対してストリームを開いて HTTP プロキシする)を、yamux 版と生 HTTP/2 版で書き直し、3 つを並べる。
| 項目 | 値 |
|---|---|
| 対象読者 | ch06 を読了し、別系統の多重化ライブラリの API も覗いてみたい開発者 |
| 難易度 | ★★★★☆(プロトコル設計の比較が中心) |
| 読了時間 | 約 25 分 |
| 対象バージョン | Go 1.23+、golang.ngrok.com/muxado/v2 v2.0.1、github.com/hashicorp/yamux v0.1.2(2024-09-24)、golang.org/x/net/http2 |
ngrok 公式が muxado を選び続けている理由は、4 章で見たとおり「HTTP/2 が間に合わなかった」という歴史的事情と「HTTP 以外も多重化したい」という用途要件の合わせ技だ。だが、それは本当に今でも妥当な選択なのか。同じものを 3 通り書くと、答えは自然に見えてくる。
3 つの API を最初に並べる
実コードに入る前に、3 ライブラリの主要 API を 1 つの表で並べておく。ch04 と ch06 で見た muxado の API(muxado.Server() / muxado.Client() / Session.Open() / Session.Accept())が、他 2 つでどう写像されるかが一目で分かる。
| 機能 | muxado (v2.0.1) | yamux (v0.1.2) | http2 (x/net/http2) |
|---|---|---|---|
| サーバー初期化 | muxado.Server(conn, nil) Session | yamux.Server(conn, nil) (*Session, error) | http2.Server{}.ServeConn(conn, opts) |
| クライアント初期化 | muxado.Client(conn, nil) Session | yamux.Client(conn, nil) (*Session, error) | (*http2.Transport).NewClientConn(conn) |
| 新規ストリームを能動的に開く | Session.Open() (net.Conn, error) | Session.Open() (net.Conn, error) | ClientConn.RoundTrip(req)(HTTP 要求が必須) |
| 相手側のストリームを受ける | Session.Accept() (net.Conn, error) | Session.Accept() (net.Conn, error) | http.Handler の ServeHTTP に振られる |
| ストリーム種別の付与 | TypedStreamSession.OpenTypedStream(t) | なし(自前で先頭バイトを規約化) | HTTP メソッド / Path / ヘッダー |
| Ping / 死活 | Heartbeat ラッパー | Session.Ping() (time.Duration, error) | Server.PingTimeout / ReadIdleTimeout |
| フレーム種別の数 | 4 種(DATA/RST/WND_INC/GOAWAY) | 4 種(DATA/WINDOW_UPDATE/PING/GO_AWAY) + フラグ多種 | 10 種(HEADERS/DATA/PRIORITY/RST_STREAM/SETTINGS/PUSH_PROMISE/PING/GOAWAY/WINDOW_UPDATE/CONTINUATION) |
| HTTP semantics | 持たない | 持たない | 全部持つ(HEADERS/HPACK/Path/Method/Status) |
| 仕様書 | 公式 doc.go + コード | 独立した spec.md(リポジトリ同梱) | RFC 7540(2015)/ RFC 9113(2022) |
muxado と yamux は Session.Open() / Session.Accept() という API がほぼ同型で、戻り値が net.Conn(または互換型)である点まで一致する。両者は「HTTP 固有のものを持たない汎用多重化」という同じカテゴリのライブラリだ。一方 HTTP/2 は「HTTP を喋る」前提が API の隅々まで染み込んでいる。
ここから、3 通りの実装を順に見ていく。
yamux 版:30 行の差分で muxado から移植できる
ch04 / ch06 で書いた internal/server/server.go を yamux 版に書き換える。変更箇所は import とセッション生成・エラーハンドリングだけで、Session.Open() 以降のコードはほぼそのまま残る。
package server
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"github.com/hashicorp/yamux"
)
// Server は yamux 版エッジサーバー。muxado 版と公開 API は同一。
type Server struct {
PublicAddr string
AgentAddr string
}
func (s *Server) Run(ctx context.Context) error {
agentLn, err := net.Listen("tcp", s.AgentAddr)
if err != nil {
return fmt.Errorf("agent listen: %w", err)
}
defer agentLn.Close()
agentConn, err := agentLn.Accept()
if err != nil {
return fmt.Errorf("agent accept: %w", err)
}
// ★ ここだけ違う:yamux はエラーを返す(muxado.Server は返さない)
sess, err := yamux.Server(agentConn, nil)
if err != nil {
return fmt.Errorf("yamux server: %w", err)
}
defer sess.Close()
slog.Info("agent connected", "remote", agentConn.RemoteAddr())
publicLn, err := net.Listen("tcp", s.PublicAddr)
if err != nil {
return fmt.Errorf("public listen: %w", err)
}
defer publicLn.Close()
for {
publicConn, err := publicLn.Accept()
if err != nil {
return fmt.Errorf("public accept: %w", err)
}
go func() {
defer publicConn.Close()
stream, err := sess.Open() // ← muxado と同じ呼び出し
if err != nil {
slog.Error("open stream", "err", err)
return
}
defer stream.Close()
done := make(chan struct{}, 2)
go func() { io.Copy(stream, publicConn); done <- struct{}{} }()
go func() { io.Copy(publicConn, stream); done <- struct{}{} }()
<-done
}()
}
}
muxado 版とのコード差分はわずか 3 点しかない。
// ❌ muxado 版(ch04):エラーを返さない
sess := muxado.Server(agentConn, nil)
// ✅ yamux 版:エラーを返すので必ず受ける
sess, err := yamux.Server(agentConn, nil)
if err != nil { /* ... */ }
エージェント側も同様に muxado.Client() を yamux.Client() に変えるだけで動く。Session.Open() の戻り値も net.Conn 互換(yamux では *Stream が net.Conn を実装)なので、io.Copy のロジックは無修正でよい。
yamux と muxado の本質的な違い
API が酷似している一方で、yamux と muxado は思想に明確な差がある。
1. 仕様書が独立している
yamux はリポジトリ直下に spec.md というプロトコル仕様書を独立して持っている。実装と仕様を分ける文化は HashiCorp が Consul・Nomad のような複数言語クライアントをメンテしてきた経験に由来する。muxado はコードと doc.go が事実上の仕様で、複数実装の互換性は意識されていない。
2. フレームに認証・Ping・多種の制御メッセージがある
muxado のフレームは TypeData / TypeRst / TypeWndInc / TypeGoAway の 4 種だけだったが、yamux のフレームヘッダーは Type フィールド + Flags フィールドの組み合わせで多彩な制御メッセージを表現する(SYN / ACK / FIN / RST フラグ、PING フレームの ping/pong 識別など)。Session.Ping() (time.Duration, error) のような RTT 計測がプロトコルレベルで標準搭載されているのも、muxado が外付け Heartbeat ラッパーで賄っていたのとの対照だ。
3. 「Open() を呼べるのは初期化時のロールに従う」
yamux は README に「There must be at most one client-side connection」と明記し、ロール(Server/Client)と「どちらが先に Open するか」を結びつける設計を採る。muxado はサーバ側から能動的に Open できることをむしろ売りにしている。ngrok 用途ではサーバから NAT 越しに開きたいので、muxado の自由度の高さが効く。
それでもユーザーから見える Go の API はほぼ同じ形をしている、というのは興味深い事実だ。「HTTP/2 がまだなかった時代に Go で多重化を欲しがった」という共通の出自が、結局よく似た形に収斂したことを示している。
HTTP/2 版:API の形が根本的に変わる
ここからが面白い。同じ「エージェント側にストリームを開いて HTTP を流す」を、Go 標準の golang.org/x/net/http2 で書くと、API の形が様変わりする。Session.Open() という発想自体が無くなるからだ。
HTTP/2 のクライアントは Transport.RoundTrip(req *http.Request) を呼ぶ。これは「HTTP リクエストを送る」API であって「ストリームを開く」API ではない。HTTP/2 のストリームは常に HTTP リクエスト に紐づいて生成される。
構造の組み換え
ngrok は「edge → agent 方向」にストリームを開きたい。つまりエージェント側を HTTP/2 サーバ に、エッジ側を HTTP/2 クライアントにするのが自然だ。muxado 版とは役割が逆転する。
// HTTP/2 版:エージェントが HTTP/2 サーバーになる
package agent
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
type Agent struct {
ServerAddr string
Local string
}
func (a *Agent) Run(ctx context.Context) error {
// 1. エッジに TCP で接続する(h2c なので素の TCP でよい)
upConn, err := net.Dial("tcp", a.ServerAddr)
if err != nil {
return fmt.Errorf("dial server: %w", err)
}
// 2. その TCP の上に「HTTP/2 サーバー」を立てる
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// エッジから来たリクエストをローカル HTTP サーバーに転送
localConn, err := net.Dial("tcp", a.Local)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer localConn.Close()
// HTTP リクエストを localConn にそのまま書き出す
if err := r.Write(localConn); err != nil {
slog.Error("forward request", "err", err)
return
}
// レスポンスをコピー
io.Copy(w, localConn)
})
srv := &http2.Server{}
srv.ServeConn(upConn, &http2.ServeConnOpts{
Handler: h2c.NewHandler(h, srv),
Context: ctx,
})
return nil
}
エッジ側(HTTP/2 クライアント)は、公開ポートに来た HTTP リクエストを http.Request に変換して Transport.RoundTrip() に流し込む。詳細は割愛するが、http2.Transport{AllowHTTP: true}.NewClientConn(upConn) で TCP 上に HTTP/2 クライアント接続を張り、cc.RoundTrip(req) で 1 リクエストずつ送る格好になる。
HTTP 以外を流そうとした瞬間に詰まる
ここで、ngrok のような「HTTP 以外(TLS pass-through、TCP、UDP)も流したい」要件をぶつけてみる。HTTP/2 でこれをやろうとすると、CONNECT メソッドで TCP トンネルを掘るという古典的な手法を使うことになる。
// HTTP/2 上で TCP トンネルを作る:CONNECT を発行する
req, _ := http.NewRequestWithContext(ctx, http.MethodConnect, "https://edge/", nil)
req.Host = "target.example.com:443" // CONNECT target
resp, err := cc.RoundTrip(req)
if err != nil || resp.StatusCode != http.StatusOK {
return fmt.Errorf("CONNECT failed: %v / %v", err, resp.Status)
}
// 以後 resp.Body と req.Body が双方向ストリームになる(hijacker 風)
tcpStream := struct {
io.Reader
io.WriteCloser
}{resp.Body, req.Body.(io.WriteCloser)}
io.Copy(localConn, tcpStream) // ← この時点で「HTTP のフリをした TCP」
さらに WebSocket のような「HTTP 以外のプロトコルをアップグレード経由で流す」用途には、Extended CONNECT(RFC 8441、SETTINGS_ENABLE_CONNECT_PROTOCOL の有効化が必要)まで踏み込む必要が出てくる。http2.Server の SettingEnableConnectProtocol を有効にし、:protocol 擬似ヘッダーを処理する独自の http.Handler を書き、というふうにコードがじわじわ膨らんでいく。
muxado 版と HTTP/2 版の構造差を図にする
graph TB
subgraph mux["muxado / yamux 版:対称的な多重化プリミティブ"]
direction LR
ME[Edge] ===|"sess.Open()"| MA[Agent]
MA ===|"sess.Open()"| ME
note1["どちらの方向にも開ける<br/>ストリームは net.Conn 互換<br/>HTTP 以外を流すのも自然"]
end
subgraph h2["HTTP/2 版:常に Request/Response の対"]
direction LR
HE[Edge - HTTP/2 Client] -->|"RoundTrip(req)"| HA[Agent - HTTP/2 Server]
HA -->|"http.ResponseWriter"| HE
note2["役割が固定<br/>HTTP 以外は CONNECT で擬装<br/>WebSocket は Extended CONNECT"]
end
「ストリームを開く」という対称的なプリミティブを持つ muxado / yamux と、「リクエストとレスポンスの対」が API の根っこに刻まれた HTTP/2。同じ TCP 上多重化を提供するのに、上層へ出る API の手触りがこれほど違う。
ベンチマーク的な対比
3 ライブラリの設計判断を表で並べておく(research §3.1 の比較に実装観点を足したもの)。
| 軸 | muxado | yamux | HTTP/2 |
|---|---|---|---|
| フレームタイプ数 | 4 | 4 + フラグ多種(実質 7〜8) | 10 |
| HTTP semantics | なし | なし | HEADERS / HPACK / Path / Method / Status を全部持つ |
| 仕様書 | doc.go + コード | 独立 spec.md | RFC 7540 / 9113 |
| Ping / RTT 計測 | 外付け Heartbeat | 組込み Session.Ping() | 組込み(Server.PingTimeout) |
| サーバ起点 Open | ◎(双方向対称) | ○(ロールに依存) | △(CONNECT で擬装) |
| 任意プロトコルの伝送 | ◎(net.Conn 互換) | ◎(net.Conn 互換) | △(CONNECT / Extended CONNECT が必要) |
| TCP 上の HoL ブロッキング | あり(TCP の制約) | あり(TCP の制約) | あり(TCP の制約) |
| コード量(mintunnel 移植) | 約 130 行(ch04 累計) | 約 130 行(差分 +3) | 約 200 行(HTTP ハンドラ + CONNECT 処理) |
3 つに共通する弱点として、TCP 上で動かす以上の head-of-line blocking が残る点は強調しておきたい。muxado の README も、yamux の spec も、HTTP/2 の RFC も、いずれも「TCP の損失で全ストリームが詰まる」問題からは逃げられない。これを根本的に解くには QUIC(HTTP/3)まで降りる必要があるが、それは別の記事の範囲だ(ch09 エピローグでも触れた)。
結局、どれを選ぶか
実装してみると、選択の判断軸は意外に単純だった。
HTTP を喋りたいなら HTTP/2
エッジとオリジンの間が HTTP に閉じる のなら、golang.org/x/net/http2 一択でいい。net/http がほぼ透過的に使ってくれる。実際 ngrok も 2024-01 にエッジ → オリジン間の end-to-end HTTP/2(h2c)サポートをアナウンスしており、これは「HTTP リクエストの転送」というレイヤーに HTTP/2 の HEADERS / HPACK / FLOW CONTROL を活用する話だ。
多くのプロトコルを多重化したいなら muxado / yamux
「TCP / TLS / SSH / WebSocket / 独自バイナリ」を 1 本のコネクションに乗せたい、しかも両端から能動的に開きたい、という要件が立った瞬間、HTTP/2 直接は重くなる。muxado か yamux のどちらかを選ぶことになる。
両者の選び分けは概ねこうだ。
- 複数言語クライアントが必要 / プロトコルを仕様レベルで議論したい:yamux(
spec.mdが独立) - NAT 越しにサーバ起点で開く /
TypedStreamSessionのような型付きストリームが要る:muxado - HashiCorp プロダクト群のエコシステムに乗りたい:yamux
「ngrok と同じものを作りたい」なら muxado 一択
このシリーズの読者の関心はこれだろう。ngrok 公式が golang.ngrok.com/muxado/v2 を 2024-10 にメンテし続けていて、ngrok-rust/muxado ドキュメントも「This is the stream multiplexing protocol that powers ngrok’s tunnels.」と明言している以上、本物の挙動を再現したいなら muxado を使うのが正解だ。ch06 の TypedStreamSession がプロトコルレベルで存在するのも muxado だけだ。
ch06 の選択は妥当だったか
そう、妥当だった。3 通り書いてみて初めて言える。
- 「HTTP 以外も多重化する」要件が立てば HTTP/2 は重い
- yamux は muxado の優れた代替だが、
TypedStreamSession相当の機能を自前で書く必要がある - そして本家 ngrok と同じプロトコルを使うこと自体が、教育目的では何にも代えがたい価値を持つ
ch06 で muxado を選んだ判断は、過去の歴史的事情(HTTP/2 の遅れ)ではなく、いま 2026 年の地点から見ても妥当だ、と確認できる。
この付録を読み終えて、次にやるべきこと
- 手元の mintunnel ディレクトリに
internal/server-yamux/というブランチを切り、yamux 版を 30 行差で動かしてみる - HTTP/2 版を書いてみて、
CONNECTで TCP を流す体験をする(ngrok の Cloudflare Tunnel 比較で見たCONNECT系設計の手触りが分かる、ch08 への伏線回収にもなる) - QUIC ベースの多重化(
quic-goのquic.Stream)に挑戦して、ch09 で触れた「TCP HoL ブロッキング問題のその先」を体感する
「muxado を選んだ」という ngrok の決断は、こうして横に並べてみると、思想的にも実装的にも筋が通っている。プロトコル設計の良し悪しは、同じ問題を異なる道具で解いてみると最も明確に立ち上がる、ということだ。
章末まとめ
muxado/yamuxの Go API は驚くほど似ており、移植は 3 行程度の差分で済む(Server()がエラーを返すかどうか、など)- 一方
golang.org/x/net/http2は API の根っこに「リクエストとレスポンスの対」が刻まれていて、Session.Open()という対称的なプリミティブを持たない- フレームタイプ数は muxado が 4、yamux が 4 + フラグ、HTTP/2 が 10。「持たないことの価値」を muxado が体現している
- HTTP/2 で HTTP 以外を流すには
CONNECT/ Extended CONNECT が必要で、WebSocket や TCP 透過まで考えると実装量が膨らむ- 3 つとも TCP 上で動くため head-of-line blocking は残る(解くなら QUIC まで降りる)
- 結論:HTTP に閉じるなら HTTP/2、汎用多重化なら muxado / yamux、本物の ngrok を真似るなら muxado 一択。ch06 の選択は妥当だった