第 4 章:1 本の TCP に複数の会話を流す —— muxado でストリーム多重化を導入する
ch03 のコードは、2 本目の curl で詰まる
第 3 章で書いたリバーストンネルは、確かに動いた。NAT の内側にあるはずの localhost:3000 に、外から HTTP リクエストが届いた。あの瞬間、世界の向こうから自分のラップトップに線が引かれたような感覚があった。
だが、あのコードを少しいじめてみよう。エージェントを起動した状態で、外向き URL に並列で curl を打つ。
# ch03 のサーバーが :8080 で待ち受けているとする
$ curl http://localhost:8080/slow & # 5 秒スリープするエンドポイント
$ curl http://localhost:8080/hello # すぐ返るはずのエンドポイント
期待した挙動は「/hello が先に返ってくる」だ。だが ch03 のコードでは、/hello は /slow が完了するまで待たされる。server.go の中を覗き直すと、理由はすぐ分かる。
// ch03 の server.go(抜粋):agent との TCP 接続は 1 本だけ
agentConn, _ := agentListener.Accept()
for {
publicConn, _ := publicListener.Accept()
// agentConn を共有しているので、ここはシリアル実行されるしかない
go pipeBothWays(publicConn, agentConn)
}
agentConn は 1 本の TCP 接続だ。そこに 2 本の HTTP リクエストを同時に流し込んだら、バイトが混ざる。だから ch03 では暗黙に「1 度に 1 リクエストずつ」というシリアル制約がかかっていた。
これを HoL ブロッキング(Head-of-Line blocking) と呼ぶ。先頭の処理が終わるまで後続が並ばされる、あの行列だ。本物の Web アプリでこの制約は致命的だ。本章のゴールは、この制約を muxado で解消することにある。
| 項目 | 値 |
|---|---|
| 対象読者 | ch03 を読了した、Go の net パッケージに馴染みのある開発者 |
| 難易度 | ★★★☆☆ |
| 読了時間 | 約 25 分 |
| 対象バージョン | Go 1.23+、golang.ngrok.com/muxado/v2 v2.0.1 |
| 新規追加コード | 約 50 行(ch03 累計 80 行 → ch04 累計 130 行) |
多重化(multiplexing)の小史
1 本の物理コネクションに複数の論理ストリームを乗せたい、というニーズは Web 黎明期からある。HTTP/1.1 は Keep-Alive で TCP の再利用までは行けたが、レスポンスはリクエスト順に返さねばならず、遅い 1 本が後続を詰まらせる HoL ブロッキングを抱えていた。
Google は 2009 年に SPDY を発表し「1 本の TCP に複数の論理ストリームを多重化する」アプローチを提示した。SPDY のフレーミングを下敷きに、2015 年 5 月に HTTP/2(RFC 7540) が標準化される。HTTP/2 は HEADERS / DATA / SETTINGS / PING / PUSH_PROMISE / PRIORITY / WINDOW_UPDATE / RST_STREAM / GOAWAY といった 10 種類前後のフレームを持つ、リッチな仕様だ。
ngrok が産声を上げたのは 2013 年。HTTP/2 はまだドラフト 1 版の段階で、Go の golang.org/x/net/http2 も実用には早すぎた。同じころ HashiCorp も Consul / Nomad のために独自多重化を欲しがっており、yamux を作る。ngrok 創業者の Alan Shreve が選んだのは、HTTP/2 のフレーム層から HTTP 固有のものを全部削ぎ落とした subset を自前で書く道だった。これが muxado だ。
なぜ HTTP/2 そのものを使わなかったのか
muxado の doc.go には、設計思想がはっきり書かれている。
muxado’s design is influenced heavily by the framing layer of HTTP2 and SPDY. However, instead of being specialized for a higher-level protocol, muxado is designed in a protocol agnostic way with simplicity and speed in mind. More advanced features are left to higher-level libraries and protocols.
つまり muxado は「HTTP/2 の framing 層が良くできているのは認める。でも HTTP 固有のもの(HEADERS / HPACK / PUSH_PROMISE / PRIORITY)は要らない。汎用の多重化プリミティブとして欲しいだけだ」という割り切りで生まれている。
ngrok のトンネルは HTTP も TLS も TCP も SSH も流す。そこに HTTP のための機構を抱え込むのは過剰だ。「ストリームを開く・閉じる・流す・流量制御する」だけが欲しい。この要求を一行で書けば、muxado の存在理由になる。
muxado のフレームは 4 種類しかない
golang.ngrok.com/muxado/v2/frame パッケージのフレームタイプは、たった 4 つだ。
| 値 | 名前 | HTTP/2 相当 | 役割 |
|---|---|---|---|
| 0x0 | TypeRst | RST_STREAM | ストリームの強制終了 |
| 0x1 | TypeData | DATA | ペイロード転送 |
| 0x2 | TypeWndInc | WINDOW_UPDATE | フロー制御ウィンドウ更新 |
| 0x3 | TypeGoAway | GOAWAY | セッション終了通知 |
HTTP/2 が持つ HEADERS・SETTINGS・PING・PUSH_PROMISE・PRIORITY は 全部消えている。「ヘッダ圧縮も優先度もプッシュも要らない、多重化と窓制御だけで充分」という、ngrok のユースケースに最適化された設計判断がここに現れる。
graph LR
subgraph "muxado Session(1 本の TCP)"
S1["Stream #1<br/>(net.Conn 実装)"]
S2["Stream #2<br/>(net.Conn 実装)"]
S3["Stream #3<br/>(net.Conn 実装)"]
FR["Framer<br/>TypeData / TypeRst<br/>TypeWndInc / TypeGoAway"]
S1 --> FR
S2 --> FR
S3 --> FR
end
FR -->|"4 種類のフレームで多重化"| TCP[TCP socket]
API の妙:Session は net.Listener、Stream は net.Conn
muxado を Go の世界に違和感なく溶け込ませている最大の鍵は、インターフェース設計にある。pkg.go.dev/golang.ngrok.com/muxado/v2 で実 API を見ると、こうなっている。
// muxado パッケージの主要 API(v2.0.1)
func Server(trans io.ReadWriteCloser, config *Config) Session
func Client(trans io.ReadWriteCloser, config *Config) Session
type Session interface {
Open() (net.Conn, error) // 新規ストリームを能動的に開く
Accept() (net.Conn, error) // 相手側が開いたストリームを受け取る
Close() error
LocalAddr() net.Addr
RemoteAddr() net.Addr
Wait() (error, error, []byte)
// ...
}
type Stream interface {
Read([]byte) (int, error)
Write([]byte) (int, error)
Close() error
CloseWrite() error // 半クローズ(送信側のみ閉じる)
Id() uint32 // ストリーム ID(31bit 有効)
Session() Session
// ...
}
注目すべきは 2 点。
Session.Open()/Session.Accept()の戻り値はnet.Conn。つまり Stream は普通の TCP コネクションのフリができる。Open()もAccept()も両側から呼べる。サーバーから NAT 内クライアントに対して新規ストリームを開けるという、リバーストンネルにとって決定的に重要な性質がここにある。
これが何を意味するか。ch03 で書いた io.Copy(publicConn, agentConn) のような既存コードが、agentConn を muxado.Stream に置き換えるだけで そのまま動く。Go のネットワークコード資産がすべて再利用できる、という設計判断だ。
アーキテクチャ差分:ch03 → ch04
graph TB
subgraph ch03["ch03: 1 接続 = 1 ストリーム(HoL block 発生)"]
A1[Agent] ---|"単一 TCP"| S1[Server]
S1 -.->|"シリアル処理しかできない"| S1
end
subgraph ch04["ch04: 1 接続 = N ストリーム(並列処理)"]
A2[Agent] ===|"muxado.Session<br/>(1 本の TCP)"| S2[Server]
S2 -->|"Stream A"| A2
S2 -->|"Stream B"| A2
S2 -->|"Stream C"| A2
end
実装:ch03 のコードを internal/ 配下に分離して muxado を導入する
ここからハンズオンだ。spec.md §1 のディレクトリ構成に沿って、ch03 で 2 ファイルにべた書きしていたロジックを internal/server と internal/agent に切り出す。新規追加は約 50 行、internal/proto は ch06 で本格定義するため今は空のままにしておく。
go.mod
module github.com/example/mintunnel
go 1.23
require golang.ngrok.com/muxado/v2 v2.0.1
inconshreveable/muxado(v1)ではなくgolang.ngrok.com/muxado/v2を使う。v1 は Alan Shreve 個人の namespace、v2 は ngrok 公式の namespace で 2024-10-09 にメンテされた現役版だ。
internal/server/server.go
package server
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"golang.ngrok.com/muxado/v2"
)
// Server は edge サーバー本体。public listener と agent listener を束ねる
type Server struct {
PublicAddr string // 例: ":8080"
AgentAddr string // 例: ":7000"
}
// Run はサーバーをブロッキング起動する
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()
// 1 個の agent が繋いでくる前提で受ける(ch07 で複数化)
agentConn, err := agentLn.Accept()
if err != nil {
return fmt.Errorf("agent accept: %w", err)
}
sess := muxado.Server(agentConn, nil)
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)
}
// ここが muxado の真価:リクエストごとに新規 stream を開く
go s.proxyOne(sess, publicConn)
}
}
func (s *Server) proxyOne(sess muxado.Session, publicConn net.Conn) {
defer publicConn.Close()
stream, err := sess.Open()
if err != nil {
slog.Error("open stream", "err", err)
return
}
defer stream.Close()
pipe(publicConn, stream)
}
func pipe(a, b net.Conn) {
done := make(chan struct{}, 2)
go func() { io.Copy(a, b); done <- struct{}{} }()
go func() { io.Copy(b, a); done <- struct{}{} }()
<-done
}
internal/agent/agent.go
package agent
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"golang.ngrok.com/muxado/v2"
)
// Agent は NAT 側のクライアント
type Agent struct {
ServerAddr string // 例: "edge.example.test:7000"
Local string // 例: "localhost:3000"
}
// Run はサーバーに接続して新規ストリームを待ち受ける
func (a *Agent) Run(ctx context.Context) error {
upConn, err := net.Dial("tcp", a.ServerAddr)
if err != nil {
return fmt.Errorf("dial server: %w", err)
}
sess := muxado.Client(upConn, nil)
defer sess.Close()
slog.Info("session up", "id", sess.LocalAddr())
for {
stream, err := sess.Accept()
if err != nil {
return fmt.Errorf("accept stream: %w", err)
}
go a.handle(stream)
}
}
func (a *Agent) handle(stream net.Conn) {
defer stream.Close()
localConn, err := net.Dial("tcp", a.Local)
if err != nil {
slog.Error("dial local", "err", err)
return
}
defer localConn.Close()
done := make(chan struct{}, 2)
go func() { io.Copy(localConn, stream); done <- struct{}{} }()
go func() { io.Copy(stream, localConn); done <- struct{}{} }()
<-done
}
cmd/mintunnel-server/main.go と cmd/mintunnel-agent/main.go はそれぞれ上の Run を呼ぶだけの数行になる(割愛)。
❌ と ✅ の対比
// ❌ ch03 の素朴な実装:agentConn を全リクエストで共有 → HoL block
go pipeBothWays(publicConn, agentConn) // 同じ agentConn が複数 goroutine で並走→バイトが混ざる
// ✅ ch04 の muxado 実装:リクエストごとに論理 stream を新規生成
stream, _ := sess.Open() // 同じ TCP の上に新しい net.Conn が生える
defer stream.Close()
pipe(publicConn, stream)
sess.Open() の戻り値が net.Conn だから、ch03 のヘルパー関数 pipe は 1 文字も書き換えなくていい。これが「Stream が net.Conn を実装している」設計の威力だ。
動かして並列化を確認する
# サーバー側
$ go run ./cmd/mintunnel-server &
# ローカル側で簡単な遅延サーバーを立てる
$ python3 -c "import http.server,time; \
class H(http.server.BaseHTTPRequestHandler):\
def do_GET(s): time.sleep(3 if s.path=='/slow' else 0); s.send_response(200); s.end_headers(); s.wfile.write(b'ok')\
http.server.HTTPServer(('',3000),H).serve_forever()" &
# エージェント側
$ go run ./cmd/mintunnel-agent &
# 並列実行
$ time (curl -s localhost:8080/slow & curl -s localhost:8080/hello & wait)
ok
ok
real 0m3.04s # ch03 では 6 秒台、ch04 では 3 秒(=並列化された)
ch03 では 2 本のリクエストの所要時間が足し算になっていたが、ch04 では遅い方に律速されるだけになる。1 本の TCP の上で 2 本のストリームが本当に並走している証拠だ。
いま 1 本のセッションに何が流れているか
ここまで動いて気持ちが良い、と感じるはずだ。だが、よく見ると問題がある。
いま、sess.Open() で開いたストリームには HTTP リクエスト本体しか流していない。 では「公開 URL の払い出し」「認証トークンの検証」「ハートビート」といったコントロールメッセージはどこを流すのか?
ch04 のコードはそれらをまだ実装していないが、本物の ngrok は当然これらを扱う。素朴に考えれば「コントロール用に別のストリームを立てよう」となる。だが muxado のストリームは見た目が全部 net.Conn なので、相手側はそのストリームが「コントロール用」なのか「データ用」なのか区別できない。Accept した側が「これは何のストリームか」を見分ける手段が要る。
これが第 6 章で導入する TypedStreamSession の出番だ。muxado には「このストリームは type=0x01(コントロール)、こっちは type=0x02(プロキシ)」とラベルを付けられる薄いラッパーが同梱されており、ngrok 本家もまさにこれを使っている。ch06 で internal/proto を本格定義し、コントロールプレーンとデータプレーンを 1 本のセッションの中で分離する。
ch05 への橋渡し:URL でルーティングする
その前に、ch05 で扱うのは「ホスト名でのリクエスト振り分け」だ。いま localhost:8080 に来たリクエストはすべて同じエージェントに流れている。abc123.example.test と def456.example.test が同じ edge を共有していたら、どう振り分けるのか。net/http.ReverseProxy と Host ヘッダーの書き換え、そして X-Forwarded-* 系ヘッダーの付与をやる。
付録 B への伏線:同じことを yamux や HTTP/2 で書いたら
muxado の 4 種類フレームという潔さは、見方によっては「機能不足」とも言える。同じ「TCP 1 本に多重化」を、HashiCorp の yamux や Go 標準の golang.org/x/net/http2 で書くと API はどう違うのか。yamux は独立した spec.md を持ち実戦投入歴が長い。HTTP/2 直接実装はリッチだが CONNECT 経由で TCP を流す必要がある。同じ問題に対する 3 通りの解を並べる比較は、付録 B で行う。
章末まとめ
- ch03 の「1 接続 = 1 ストリーム」は HoL ブロッキングを生み、並列リクエストを捌けない
- muxado は HTTP/2 のフレーム層から HTTP 固有要素を全削ぎ落とした汎用多重化ライブラリで、フレームは
TypeData / TypeRst / TypeWndInc / TypeGoAwayの 4 種類だけmuxado.Sessionはnet.Listener的に振る舞い、muxado.Streamはnet.Connを実装するため、既存の Go ネットワークコードがほぼそのまま使えるSession.Open()/Session.Accept()を両側から呼べる設計が、NAT 越しのリバーストンネルと相性が良い- 50 行の追加で「1 本の TCP で N 個の HTTP リクエストを並走させる」という核心機能が動いた
- ただし、いまは制御メッセージとデータ本体が同じ種類のストリームに混ざっている。ch06 の
TypedStreamSessionでここを分離する