cmuxによるポートレベルのプロトコル振り分け
cmuxは単一ポートで受信したTCPトラフィックをペイロードの先頭バイト列に基づき解析し、異なるプロトコル用リスナーへ動的にルーティングするライブラリである。HTTP、gRPC、TLS、または独自バイナリプロトコルを同一ポートで共存させる場合に有効である。
baseLn, err := net.Listen("tcp", ":19876")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
portMux := cmux.New(baseLn)
// 優先順位に従ってマッチングルールを定義
grpcLn := portMux.Match(cmux.HTTP2HeaderField("content-type", "application/grpc"))
httpLn := portMux.Match(cmux.HTTP1Fast())
fallbackLn := portMux.Match(cmux.Any())
// 各プロトコルサーバーの初期化
grpcSrv := grpc.NewServer()
pb.RegisterDataServiceServer(grpcSrv, &dataServiceImpl{})
httpSrv := &http.Server{Handler: &restHandler{}}
legacySrv := rpc.NewServer()
legacySrv.Register(&legacyBackend{})
// 分離されたリスナーで並行稼働
go grpcSrv.Serve(grpcLn)
go httpSrv.Serve(httpLn)
go legacySrv.Accept(fallbackLn)
// ブロッキング実行でトラフィックを振り分け
if err := portMux.Serve(); err != nil {
log.Fatalf("mux terminated: %v", err)
}
カスタムマッチャーの実装
標準提供されるマッチャーに加え、io.Readerを受け取る関数を定義することで独自プロトコルの識別が可能である。内部バッファに対して順次評価が行われ、一致した時点で該当リスナーへコネクションが引き渡される。
const (
protoMagic = 0xCAFEBABE
authTag = "X-AUTH-V2"
)
func matchMagicHeader(r io.Reader) bool {
var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return false
}
return binary.BigEndian.Uint32(hdr[:]) == protoMagic
}
func matchAuthTag(r io.Reader) bool {
buf := make([]byte, len(authTag))
if _, err := io.ReadFull(r, buf); err != nil {
return false
}
return string(buf) == authTag
}
マッチング処理中に消費された先頭バイトはストリームから既に読み取り済みとなる。アプリケーション層でヘッダー情報が必要な場合は、マッチング前に別途キャプチャするか、プロトコル設計側でヘッダーの再送または状態保持を考慮する必要がある。
多段リスナー構成
HTTPとTLSを同一ポートで受け付け、TLSハンドシェイク完了後にさらに内部プロトコルを分岐させる場合、リスナーを多段に重ねる構成が有効である。
// 第一段階: 平文と暗号化トラフィックの分離
rootMux := cmux.New(tcpListener)
plainWeb := rootMux.Match(cmux.HTTP1Fast())
rawTLS := rootMux.Match(cmux.Any())
// TLS終端処理
tlsCfg := &tls.Config{Certificates: []tls.Certificate{hostCert}}
secureLn := tls.NewListener(rawTLS, tlsCfg)
// 第二段階: 確立されたTLS上でのプロトコル分岐
tlsMux := cmux.New(secureLn)
grpcOverTLS := tlsMux.Match(cmux.HTTP2())
customOverTLS := tlsMux.Match(matchAuthTag)
smuxによるコネクションレベルのストリーム多重化
smuxは単一のTCPコネクション上に複数の論理ストリームを多重化する。smux.Sessionがコネクションライフサイクルを管理し、smux.Streamがnet.Conn互換インターフェースを提供する。クライアントとサーバーの区別はストリームIDの割り当て方向に過ぎず、本質的に双方向通信をサポートする。
// クライアント側
func initClientSession(addr string) error {
raw, err := net.Dial("tcp", addr)
if err != nil {
return err
}
sess, err := smux.Client(raw, nil)
if err != nil {
return err
}
defer sess.Close()
ch, err := sess.OpenStream()
if err != nil {
return err
}
defer ch.Close()
_, err = ch.Write([]byte("multiplexed payload"))
return err
}
// サーバー側
func runSessionServer(ln net.Listener) error {
conn, err := ln.Accept()
if err != nil {
return err
}
sess, err := smux.Server(conn, nil)
if err != nil {
return err
}
defer sess.Close()
for {
stream, err := sess.AcceptStream()
if err != nil {
return err
}
go processStream(stream)
}
}
リバーストンネルパターンへの応用
インバウンド接続が制限された環境やNAT越えシナリオでは、内側から外側へTCPコネクションを確立し、そのチャネル上で外側から内側へgRPCやHTTPリクエストを流すリバースパターンが有効である。smux.Sessionをnet.Listenerとしてラップすることで、標準サーバーフレームワークをそのまま流用できる。
type tunnelListener struct {
sess *smux.Session
}
func (t *tunnelListener) Accept() (net.Conn, error) { return t.sess.AcceptStream() }
func (t *tunnelListener) Close() error { return t.sess.Close() }
func (t *tunnelListener) Addr() net.Addr { return t.sess.LocalAddr() }
// 内側ノード: 外部へ接続し、gRPCサーバーをバインド
func exposeInternalService(remote string, srv *grpc.Server) error {
conn, err := net.Dial("tcp", remote)
if err != nil {
return err
}
sess, err := smux.Server(conn, nil)
if err != nil {
return err
}
return srv.Serve(&tunnelListener{sess: sess})
}
// 外側ノード: トンネル経由でgRPCクライアントを構築
func setupTunnelClient(conn net.Conn) (*grpc.ClientConn, error) {
sess, err := smux.Client(conn, nil)
if err != nil {
return nil, err
}
return grpc.Dial(
"internal-target",
grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
return sess.OpenStream()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
}
cmuxとsmuxの統合アーキテクチャ
両ライブラリは抽象化レイヤーが異なるため、net.Listenerとnet.Connの境界で自然に結合できる。例えば、cmuxで特定トークンを検出したコネクションをsmuxセッションへ昇格させ、トンネル用チャネルとして再利用する構成が実現可能である。マッチングで消費されたヘッダーバイトを考慮し、プロトコルハンドシェイク前に適切なオフセット調整または状態同期を行うことで、透過的な多重化パイプラインを構築できる。
性能特性とベンチマーク考察
各手法のオーバーヘッドとスループット特性は以下の傾向を示す。
- cmux: 長寿命コネクションではほぼゼロオーバーヘッドで動作し、素のTCPと同等のスループットを維持する。短寿命コネクションでは、マッチング評価、バッファコピー、チャネル経由のリスナー引き渡しにより若干のレイテンシ増加とメモリ割り当てが発生する。
- smux: TCPベースでは素のTCP比で約50%、TLSベースでは約25〜30%のスループットとなる。フレームング処理とストリーム管理の同期コストが影響するが、コネクション確立コストを分散できるため安定したスループットを提供する。
- TLS短接続: ハンドシェイクの計算コストとラウンドトリップがボトルネックとなり、スループットが極端に低下する。頻繁な接続確立はCPUリソースを大きく消費する。
実装選択の指針として、cmuxはポート統合やプロトコル振り分けに軽量に適用でき、smuxはTLSハンドシェイクの頻発を回避し単一長接続上でストリームを再利用する用途に最適である。両者を組み合わせることで、セキュアかつ柔軟なネットワークアーキテクチャを構築可能である。