Go

cmux(Go)の使い方:gRPCとHTTPを1ポートで共存させる実装と最新grpc-goで詰まる設定

cmux(Go)の使い方:gRPCとHTTPを1ポートで共存させる実装と最新grpc-goで詰まる設定

cmuxは、1つのTCPリスナーに届いた接続を先頭のバイト列で見分け、gRPC・HTTP・独自プロトコルのサーバーへ振り分けるGoのライブラリです(soheilhy/cmux)。READMEの例をそのまま写すと、現在のgrpc-goクライアントからは接続できません。この記事では、2026年10月8日にGo 1.26.5・cmux v0.1.5・grpc-go v1.84.0で動かした結果をもとに、動く構成と本番で必要な設定をまとめます。

なお「cmux」は、AIコーディングエージェント向けのmacOSターミナル(manaflow-ai/cmux)の名前でもあります。背景の透過・補完・内蔵ブラウザの設定を探している場合は別の製品です。

まとめ:cmuxでgRPCとHTTPを1ポートにする要点

  • gRPCの判定はSETTINGSを返すMatcherにする:READMEのHTTP2HeaderFieldでは、grpc-go v1.84.0のクライアントがタイムアウトしました。MatchWithWritersとHTTP2MatchHeaderFieldPrefixSendSettingsの組み合わせなら接続できます。
  • HTTP1FastはPATCHを通さない:既定のメソッドは8種で、PATCHは含まれません。HTTP1Fast("PATCH")と明示します。
  • TLSはcmuxの後ろで復号する:cmux.TLS()で振り分けてからtls.NewListenerで包むと、r.TLSもHTTP/2も正しく動きます。
  • SetReadTimeoutは必ず設定する:既定はタイムアウト無しで、何も送らない接続は判定待ちのまま残ります。
  • 停止は元リスナーまで閉じる:m.Close()だけではServe()が戻りません。逆に、子リスナーを1つ閉じるだけで、すべてのプロトコルが新しい接続を受け付けなくなります。
  • gRPCとHTTPだけならcmuxは不要:Go 1.24以降はnet/httpが平文HTTP/2を受けられるので、grpc.Server.ServeHTTPで振り分けられます。

cmuxの仕組み:接続の先頭バイトで振り分けるTCPマルチプレクサ

cmux.New(l)で元のリスナーを包み、Matchを呼ぶたびに子リスナー(net.Listener)が1つ返ります。接続が来ると、cmuxは先頭のバイト列をバッファに読み込み、Matchを呼んだ順に判定します。一致した子リスナーへ接続を渡すとき、読んだバイトは巻き戻されるので、gRPCサーバーやhttp.Serverは何も変えずにServe(子リスナー)で動きます。

判定は接続を受け付けた時点の1回だけです。READMEも、1本の接続はgRPCかRESTのどちらかで、両方には使えないと明記しています。前段のL7ロードバランサーが複数の利用者のリクエストを1本の接続にまとめて転送する構成では、この前提が崩れます。その場合は、ポートを分けるか、HTTPリクエスト単位で振り分ける方式を選びます。

バージョンと保守状況:最新タグは2021年のv0.1.5

項目 内容(2026年10月8日時点)
モジュール github.com/soheilhy/cmux
最新タグ v0.1.5(2021年3月26日リリース・go.modはgo 1.11)
master 2026年6月8日更新・go 1.23.0・タグなし
ライセンス Apache-2.0
GitHub スター約2,770・未解決のissueとPR 37件・アーカイブなし

go get github.com/soheilhy/cmux@latestで入るのはv0.1.5です。2026年6月のmasterの更新は、go.modをgo 1.23.0に上げ、golang.org/x/netをv0.42.0に更新し、CIを入れ直したものです。コミットメッセージには、公開されている挙動は変えていないと書かれています。そのため、v0.1.5のまま使っても機能面の差はありません。

Matcherの一覧と判定ロジック

Matcherはfunc(io.Reader) boolで、接続の先頭を読んで一致を返す関数です。Matchを呼んだ順が優先順位になるので、条件の厳しいものを先に、Any()を最後に置きます。Any()を先頭に置くと、すべての接続がそこに吸い込まれます。

Matcher 判定に使う情報 注意点
Any() 何も読まない 必ず最後に置く
PrefixMatcher(...) 先頭の文字列 SSH-など独自プロトコル向け
HTTP1Fast(...) 先頭のメソッド名 既定の8種にPATCHが無い
HTTP1() 最初の1行(最大4,096バイト) HTTP/1.xの版まで確認する
HTTP1HeaderField(名前, 値) 最初のリクエストのヘッダー 値は完全一致
HTTP2() HTTP/2のクライアントプリフェイス gRPCと他のh2cを区別しない
HTTP2HeaderField(名前, 値) 最初のHEADERSフレーム サーバー側のSETTINGSを返さない
HTTP2MatchHeaderFieldSendSettings 同上+SETTINGSを送信 MatchWithWritersで使う
TLS(版...) TLSレコードの先頭3バイト 既定でTLS 1.3も一致する

ヘッダー系には値の前方一致版(HTTP1HeaderFieldPrefix・HTTP2HeaderFieldPrefix・HTTP2MatchHeaderFieldPrefixSendSettings)もあります。gRPCのcontent-typeはapplication/grpc+protoのように接尾辞が付くことがあるので、前方一致版が安全です。cmuxの公式ExampleもHTTP2HeaderFieldPrefixを使っています。

TLS()は版を指定しないとSSL 3.0〜TLS 1.2の版番号で照合します。TLS 1.3のClientHelloはレコード層の版番号に0x0301か0x0303を入れるため(RFC 8446 5.1節)、既定のままで一致します。curl 8.7.1のTLS 1.3接続でも一致を確認しました。

HTTP1FastがPATCHを通さない理由

HTTP1Fastは先頭がOPTIONS・GET・HEAD・POST・PUT・DELETE・TRACE・CONNECTのどれかで始まるかだけを見ます。PATCHはこの一覧に無いため、REST APIの部分更新が別のMatcherへ流れます。実測では、Any()で受けたTCPフォールバックにPATCHが届き、curlは応答をHTTP/0.9とみなして終了コード1で落ちました。HTTP1Fast("PATCH")のように引数で足すか、リクエスト行を解析するHTTP1()に替えます。

gRPCとHTTPを1ポートで動かす実装(grpc-go v1.84.0で検証)

gRPCサーバーの実装そのものはprotoc-gen-go-grpcでのGo実装手順と同じで、cmuxが変えるのはServeに渡すリスナーだけです。違いが出るのは、gRPCをどのMatcherで判定するかです。

READMEのHTTP2HeaderFieldで接続できない理由

同じサーバーで、gRPCの判定方法だけを替えて、grpc-go v1.84.0のクライアントからヘルスチェックを呼びました。

gRPCの判定 gRPC(3秒期限) HTTP/1.1
Match(HTTP2HeaderField(...)) DeadlineExceeded 応答あり
MatchWithWriters(HTTP2MatchHeaderFieldSendSettings(...)) SERVING 応答あり
Match(HTTP2()) SERVING 応答あり

失敗の原因は待ち合いです。HTTP2HeaderFieldは、クライアントが送ってくる最初のHEADERSフレームを読むまで判定を終えません。一方、grpc-goのクライアントは、サーバーのSETTINGSフレームを受け取るまで接続を準備完了とみなさず、リクエストを送りません。grpc-goはv1.18.0でこの挙動を既定にしました(リリースノートの表記はmake handshake required 'on' by default)。2018年10月26日にはgrpc-go側の開発者がcmuxにissue #64を立てて影響を告知していますが、2026年10月時点でもissueは未解決で、READMEの例も古いままです。READMEは同じ問題をJavaクライアントの制約として説明していますが、現在はGoのクライアントでも起きます。

HTTP2()はクライアントプリフェイス(24バイト)だけで判定するので、待ち合いが起きません。ただし、gRPC以外のh2cの接続もgRPCサーバーへ流れます。HTTP/2で通常のHTTPも受けるなら、ヘッダーを見るSendSettings系を使います。

動く構成:MatchWithWritersで判定する完全なコード

次のコードは、gRPC(ヘルスチェックとリフレクション)とHTTP/1.xを:8080の1ポートで受け、SIGINTで停止します。GracefulStopは終わらないストリームがあると待ち続けるので、10秒の期限を過ぎたらStopで打ち切ります。go vetを通し、下の手順で動作を確認しました。Health/Watchのストリームを開いたままSIGINTを送ると、10秒後に停止しました。

package main

import (
	"context"
	"errors"
	"log"
	"net"
	"net/http"
	"os"
	"os/signal"
	"time"

	"github.com/soheilhy/cmux"
	"google.golang.org/grpc"
	"google.golang.org/grpc/health"
	healthpb "google.golang.org/grpc/health/grpc_health_v1"
	"google.golang.org/grpc/reflection"
)

func main() {
	l, err := net.Listen("tcp", ":8080")
	if err != nil {
		log.Fatal(err)
	}
	m := cmux.New(l)
	m.SetReadTimeout(5 * time.Second) // 振り分けが決まるまでの読み取り上限

	// 1. gRPC:SETTINGS フレームを返しながら content-type を判定する
	grpcL := m.MatchWithWriters(
		cmux.HTTP2MatchHeaderFieldPrefixSendSettings("content-type", "application/grpc"))
	// 2. HTTP/1.x:既定のメソッド一覧に PATCH は無いので足す
	httpL := m.Match(cmux.HTTP1Fast("PATCH"))

	gs := grpc.NewServer()
	healthpb.RegisterHealthServer(gs, health.NewServer())
	reflection.Register(gs) // grpcurl で確認するため
	hs := &http.Server{
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Write([]byte(r.Method + " ok\n"))
		}),
		ReadHeaderTimeout: 10 * time.Second,
	}
	go gs.Serve(grpcL)
	go hs.Serve(httpL)

	// 停止:子リスナーを閉じると元リスナーも閉じ、m.Serve() が戻る
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stop()
	done := make(chan struct{})
	go func() {
		<-ctx.Done()
		sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		if err := hs.Shutdown(sctx); err != nil {
			hs.Close()
		}
		stopped := make(chan struct{})
		go func() { gs.GracefulStop(); close(stopped) }()
		select {
		case <-stopped:
		case <-sctx.Done():
			gs.Stop() // 期限を過ぎたら実行中の RPC を打ち切る
		}
		close(done)
	}()

	if err := m.Serve(); !errors.Is(err, net.ErrClosed) {
		log.Fatal(err)
	}
	<-done
	log.Println("stopped")
}

MatchWithWritersは判定の途中で接続にSETTINGSを書き込みます。HTTP/2で始まりgRPC以外だった接続にもSETTINGSが送られます。cmux.goのインターフェース説明も、MatchWriterは本来のハンドラーより先に接続へ書き込むことがあるので、通常のMatcherを優先するよう勧めています。

curlとgrpcurlによる動作確認

新しいディレクトリに上のコードをmain.goとして保存し、検証と同じ版を指定して起動します。

go mod init example.com/cmux-demo
go get github.com/soheilhy/[email protected] google.golang.org/[email protected]
go run .

サーバーを起動したまま、別のターミナルで次を実行します。

# HTTP/1.1(GET と PATCH)
curl -s http://127.0.0.1:8080/
curl -s -X PATCH http://127.0.0.1:8080/

# gRPC(標準のヘルスチェックサービス)
grpcurl -plaintext 127.0.0.1:8080 grpc.health.v1.Health/Check

上のコードでは、curlがGETとPATCHの両方にokを返し、grpcurl v1.9.4は"status": "SERVING"を返しました。grpcurlはサーバーリフレクションでサービス定義を取得するため、reflection.Registerを外すと-protoでprotoファイルを渡す必要があります。

TLSと組み合わせる構成:cmux.TLS()による分岐と分岐後の復号

READMEは、net/httpが型アサーションで*tls.Connを見分けるため、cmuxがTLS接続を包むとhttp.Request.TLSが設定されないと説明しています。復号をcmuxの前に置くか後ろに置くかで、結果は次のように分かれました。

構成 r.TLS HTTP/2(ALPN) 平文HTTPの同居
tls.NewListenerの内側でcmux nil 失敗(curl終了コード16) 不可
cmux.TLS()で分けてからtls.NewListener 設定される 成功 可

復号をcmuxの前に置くと、NextProtosにh2を入れていればTLSハンドシェイクでh2が合意されます。しかしhttp.Serverからは*tls.Connに見えないため、HTTP/1.1として応答します。curlはHTTP/2のエラー(終了コード16)で失敗しました。h2を合意するクライアントは、ブラウザを含めて同じ状態になります。cmux.TLS()で振り分けてから復号すれば、tls.Connが一番外側になるので、この問題は起きません。

m := cmux.New(l)
m.SetReadTimeout(5 * time.Second)
tlsL := m.Match(cmux.TLS())        // TLS の ClientHello で始まる接続
httpL := m.Match(cmux.HTTP1Fast()) // 平文の HTTP/1.x

cfg := &tls.Config{
	Certificates: []tls.Certificate{cert},
	NextProtos:   []string{"h2", "http/1.1"},
}
go (&http.Server{Handler: h}).Serve(tls.NewListener(tlsL, cfg)) // 復号は cmux の後ろ
go (&http.Server{Handler: h}).Serve(httpL)
m.Serve()

この形では、TLS 1.3でHTTP/2とHTTP/1.1が、TLS 1.2でもHTTP/2が動き、同じポートで平文のHTTP/1.1も受けられました。TLSの内側でさらにgRPCとHTTPを分けたい場合は、後述のServeHTTP方式でHTTPリクエスト単位に振り分けます。復号後のリスナーにもう1つcmuxを重ねると、tls.Connが再びcmuxの接続に包まれ、r.TLSとHTTP/2の問題が戻ります。

本番運用の設定と停止設計:SetReadTimeout・HandleError・停止手順

READMEの例はこの3点に触れていません。読み取り期限は既定のままだと問題になり、エラーハンドラーと停止処理は書き方を誤ると全プロトコルが止まります。

SetReadTimeout未設定時に残る無送信の接続

cmux.Newの既定は読み取りタイムアウト無しです。接続して何も送らないクライアントを用意して測ると、タイムアウト無しでは5秒後もサーバーは接続を閉じず、SetReadTimeout(2 * time.Second)では2.00秒でEOFが返りました。判定待ちの接続ごとにゴルーチンが残るので、ポートスキャンや中途半端な接続が積もるとリソースを圧迫します。期限は振り分けが決まるまでにだけ効き、一致した後は解除されるので、長寿命のgRPCストリームは切れません。

同じ理由で、MySQLやSMTPのようにサーバーが先に挨拶を送るプロトコルはcmuxで識別できません。クライアントが何も送らずに待つため、受信データを読むMatcherは期限が切れるまで判定を終えられません。最後にAny()を置けば、期限切れの後でその接続を受けられます。ただしプロトコルを見分けたわけではなく、毎回期限の分だけ待たされます。CockroachDBは、フォーク版にある判定期限付きのコンストラクタNewWithTimeoutで1分を指定しています。

HandleErrorのfalseによる元リスナーの閉鎖

どのMatcherにも一致しなかった接続は閉じられ、ErrNotMatchedがエラーハンドラーへ渡されます。既定のハンドラーは常にtrueを返すので、処理は続きます。ログを取るつもりでfalseを返すハンドラーを登録すると、不一致の接続が1本来ただけでcmuxが元リスナーを閉じ、Serve()がuse of closed network connectionで終了しました。HandleErrorでfalseを返すのは、停止したいときだけに限ります。CockroachDBも、停止処理中に限ってfalseを返す書き方をしています。

m.Close()後も戻らないServeと元リスナーのClose

m.Close()を呼ぶと子リスナーのAcceptはmux: server closedを返しますが、m.Serve()は元リスナーのAcceptで待ったままで、2秒たっても戻りませんでした。元リスナーをClose()して初めて戻ります。

逆の落とし穴もあります。子リスナーのClose()は元リスナーをそのまま閉じる実装なので、http.Server.ShutdownでHTTPだけを止めたつもりでも、元リスナーが閉じてgRPCの新規接続も拒否されました。プロトコルを1つだけ止める運用はできないものとして、上のコードのように全サーバーを順に止め、Serve()のnet.ErrClosedを正常終了として扱います。

cmuxを使わない選択肢:Go 1.24のh2c標準対応とServeHTTP

同じポートに載せたいのがgRPCとHTTPだけなら、cmuxを使わずに済みます。Go 1.24でhttp.Protocolsが追加され、SetUnencryptedHTTP2(true)でnet/httpが平文のHTTP/2(h2c)を受けられるようになりました。HTTPリクエスト単位でcontent-typeを見て、gRPCだけをgrpc.Server.ServeHTTPへ渡します。

gs := grpc.NewServer()
// ...gRPC サービスを登録...

p := new(http.Protocols)
p.SetHTTP1(true)
p.SetUnencryptedHTTP2(true) // Go 1.24 以降。平文 HTTP/2(h2c)を受ける

srv := &http.Server{
	Addr:      ":8080",
	Protocols: p,
	Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.ProtoMajor == 2 && strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
			gs.ServeHTTP(w, r)
			return
		}
		mux.ServeHTTP(w, r) // 通常の HTTP ハンドラ
	}),
}
log.Fatal(srv.ListenAndServe())

この構成でも、grpc-go v1.84.0のクライアントからのヘルスチェックはSERVINGを返し、HTTP/1.1とh2cの通常リクエストも同じポートで処理できました。ただし、ServeHTTPはgrpc-goのドキュメントで実験的なAPIとされ、Go標準のHTTP/2実装を通るため、grpc-go独自のHTTP/2サーバーとは性能や使える機能が異なると書かれています。

REST APIをprotoから自動生成したいなら、gRPC-GatewayでRESTを生やす構成が別の選択肢になります。これはREST→gRPCの変換層で、ポートの共有とは役割が違います。cmuxやServeHTTPと組み合わせて同じポートに載せることもできます。

方式 振り分けの単位 向く場面
cmux TCP接続 gRPC・HTTP・独自TCPを1ポートに
net/http+ServeHTTP HTTPリクエスト gRPCとHTTPだけ・L7プロキシ配下
gRPC-Gateway HTTPリクエスト(変換) 同じAPIをRESTでも公開する
ポート分離 ポート gRPC本来の性能と機能が必要

独自のTCPプロトコルやSSHを同居させるなら、選択肢はcmuxです。gRPCとHTTPだけなら、新しく作るサービスではServeHTTP方式を先に検討し、ストリーミングの性能やgrpc-go固有の機能が要るならポートを分けます。

採用事例:etcdとCockroachDBのソースに見る使い方

Goで書かれたOSSのetcdと、ソースを公開してきたCockroachDBの実装から、cmuxの実際の使い方を確認できます。

etcdはserver/embed/serve.goでsoheilhy/cmuxを使っています。平文ではHTTP1()でHTTPを、HTTP2()でgRPCを分けます。TLSではm.Match(cmux.Any())の上でTLSを終端し、ProtoMajor == 2かつContent-Typeにapplication/grpcを含むリクエストをgrpcServer.ServeHTTPへ渡します。HTTPを別ポートに分けたい利用者向けに--listen-client-http-urlsも用意しています。

CockroachDBは2026年9月15日に、新しいバージョンのソースを公開リポジトリへ出さず、非公開で開発すると発表しました。以下は、公開リポジトリに残っているソース(2026年10月8日に確認)の内容です。CockroachDBはgithub.com/cockroachdb/cmuxというフォーク(2025年5月14日のコミット)を使っています。pkg/server/start_listen.goでは、SQL用の待ち受けを分けない構成のとき、PostgreSQLのワイヤプロトコル(pgwire)をRPCと同じリスナーから振り分けています。続けてDRPC用の判定を挟み、残りはAny()でgRPCへ渡します。判定期限はNewWithTimeout(ln, time.Minute)で付けています。

etcdのTLS構成は、cmuxでの判定を1段にとどめ、gRPCとHTTPの振り分けをHTTPリクエスト単位に任せる形です。この記事のTLSの章と同じ考え方です。

cmuxのよくあるエラーと原因別の対処

症状 原因 対処
gRPCがDeadlineExceeded SETTINGSの待ち合い SendSettings系かHTTP2()
PATCHだけ失敗する HTTP1Fastの既定一覧 HTTP1Fast("PATCH")
ErrNotMatchedがログに出る どのMatcherにも不一致 順序の見直し・Any()の追加
HTTPSでr.TLSがnil 復号がcmuxの前にある cmux.TLS()の後ろで復号
HTTP/2でブラウザがエラー ALPNでh2合意後にHTTP/1応答 同上
判定待ちの接続が減らない 読み取り期限が無い SetReadTimeout
HTTPを止めたらgRPCも止まった 子リスナーのCloseが元を閉じる 全サーバーをまとめて停止
停止時にmux: server closed m.Close()後の正常な戻り値 エラー扱いしない

cmuxに関するよくある質問

cmuxとは何ですか?ターミナルのcmuxとは別物ですか?

この記事のcmuxは、1つのTCPポートで複数のプロトコルを受けるためのGoライブラリ(soheilhy/cmux)です。同じ名前のcmux(manaflow-ai/cmux)は、Ghosttyをベースにした、AIコーディングエージェント向けのmacOSターミナルで、まったく別の製品です。Ghostty自体のキー操作はGhosttyのショートカット一覧にまとめています。

cmuxのライセンスは何ですか?

Apache License 2.0です。商用製品に組み込めますが、再配布するときはLICENSEファイルの同梱など、Apache 2.0の条件に従います。

SetReadTimeoutはどのくらいに設定すればよいですか?

公式の推奨値はありません。期限は振り分けが決まるまでにだけ効くので、通常のクライアントが最初のリクエストを送るまでの時間より長ければ十分です。CockroachDBのフォーク版は1分を使っています。インターネットに公開するポートでは、正常なクライアントが接続してから最初のバイトを送るまでの時間を測り、それに余裕を持たせた値にします。Any()を置かない構成なら、期限で切られた接続はErrNotMatchedとしてエラーハンドラーに届くので、ログで件数を追えます。

1本の接続でgRPCとRESTを混在させられますか?

できません。cmuxは接続を受け付けた時点で1回だけ判定し、その接続は最後まで同じサーバーが処理します。1本のHTTP/2接続にgRPCと通常のHTTPリクエストを混ぜる必要があるなら、ServeHTTPで振り分ける方式を使います。

JavaのgRPCクライアントで接続できません。どうすればよいですか?

JavaのgRPCクライアントは、サーバーのSETTINGSフレームを受け取るまで待ちます。gRPCの判定をMatchWithWriters(cmux.HTTP2MatchHeaderFieldSendSettings(...))に替えてください。v1.18.0以降のgrpc-goクライアントも同じ待ち方をするので、言語を問わずこの書き方にしておくのが安全です。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.09 テックブログ IDCFクラウド(IDCフロンティア)不正アクセス・ランサムウェア:影響先・復旧・データは戻るか
  2. 2026.10.08 テックブログ 大阪公立大学のランサムウェア被害と仮想化基盤の停止|全授業休講に至った経緯とバックアップを守る設定
  3. 2024.11.08 テックブログ OpenAPI GeneratorでJavaコードを自動生成する方法|CLI導入からSpring・ライブラリ選択まで
  4. 2026.10.09 テックブログ ニッスイのサイバー攻撃で日水物流の入出荷停止|委託先クラウド障害に荷主が備える手順
  5. 2026.10.09 テックブログ 京王電鉄のランサムウェア被害とグループ共通基盤:決済・ポイント・予約が止まった範囲と遮断の初動

RELATED POSTS 関連記事

目次