目次を表示する

ngrokの魔法を解き直す ── 仕組みを理解し、Goで自作してみる

付録 B:同じトンネルを yamux と HTTP/2 で書き直す

付録 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.1github.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) Sessionyamux.Server(conn, nil) (*Session, error)http2.Server{}.ServeConn(conn, opts)
クライアント初期化muxado.Client(conn, nil) Sessionyamux.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.HandlerServeHTTP に振られる
ストリーム種別の付与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 では *Streamnet.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.ServerSettingEnableConnectProtocol を有効にし、: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 の比較に実装観点を足したもの)。

muxadoyamuxHTTP/2
フレームタイプ数44 + フラグ多種(実質 7〜8)10
HTTP semanticsなしなしHEADERS / HPACK / Path / Method / Status を全部持つ
仕様書doc.go + コード独立 spec.mdRFC 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-goquic.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 の選択は妥当だった

参考文献