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クライアントも同じ待ち方をするので、言語を問わずこの書き方にしておくのが安全です。