TT Lab
ブログ

한국어 English 日本語 中文

はじめる
ブログ

ブログ

HTTP/2上のgRPC:擬似ヘッダー、5バイトのプレフィックス、トレーラーのステータスコード

ステータス:ドラフト(2026-10-17)、一部実測。 基準バージョン grpc/grpc v1.84.0 doc/PROTOCOL-HTTP2.md·doc/statuscodes.md、grpc/grpc-go v1.83.2。 ステータスコードの分布は2026-10-17 03時台(KST)の小規模クラスターにおいて、PrometheusとコントロールノードAのetcd /metrics を読み取るだけで取得しました。 実際のフレームをキャプチャしたわけではありません(未実測、必要なもの:grpcioまたはgrpc-goのサンプルサーバー、Wiresharkなどのキャプチャツール)。そのため、第3・4・5節のバイト列は 仕様とコードをもとに構成および計算した値です。

目次

1. 直感:封筒、手紙、結果のスタンプ

書留郵便を思い浮かべると分かりやすいです。 封筒(リクエストの擬似ヘッダー)には、宛先の部署と業務名、処理期限が記載されています。中には 複数枚の手紙(メッセージ)が入っており、枚ごとに「この手紙は何バイト」という帯が付いています。処理結果は一番最後に押される 結果のスタンプ(トレーラー)です。郵便局(HTTP)は配達に成功したとして200を返しますが、業務が成功したかどうかはスタンプを見ないと分かりません。

この構造のおかげで、gRPCはレスポンスを1枚ずつ流しつつ(ストリーミング)、最後にステータスを付加することができます。HTTP/1.1のステータス行は先頭になければならないため、これが困難です。

2. 図

단항 호출 한 번이 HTTP/2 프레임으로 클라이언트 서버 HEADERS stream=1 :path /tt.v1.Scoreboard/Hit content-type: application/grpc · te: trailers DATA END_STREAM 00 00000015 + Rally 21바이트 HEADERS :status 200 · content-type DATA 00 + 길이 4바이트 + 응답 메시지 HEADERS END_STREAM 트레일러 grpc-status: 0 오류가 바로 나면 트레일러만 보낸다(Trailers-Only): :status 200 · grpc-status: 5 · grpc-message: %EB%9E%A0… HTTP 상태는 성공(200)이어도 호출의 성패는 grpc-status 가 정한다.
図:単項呼び出し1回あたりのHTTP/2フレームの順序とTrailers-Onlyレスポンス

3. リクエストの擬似ヘッダー

仕様書のCall-Definitionを整理すると以下のようになります(PROTOCOL-HTTP2.md のRequestsセクション)。

ヘッダー 値 理由
:method POST 常にPOST
:path /tt.v1.Scoreboard/Hit メソッドの識別。大文字小文字を区別
:authority 対象名 仮想ホスト
te trailers トレーラーを扱えないプロキシを弾くための表示
content-type application/grpc(+proto·json…) さもないとサーバーは415で拒否しなければならない
grpc-timeout 8桁以下の整数 + 単位(H・M・S・m・u・n) 残り時間。なければ無限
grpc-encoding, grpc-accept-encoding gzip 等 メッセージの圧縮方式
ユーザーメタデータ 小文字の名前。 -bin で終わる場合はbase64 grpc- で始まる名前は予約済み

grpc-goでは http2Client.createHeaderFields がこのリストを作成します。

headerFields = append(headerFields, hpack.HeaderField{Name: ":method", Value: "POST"})
headerFields = append(headerFields, hpack.HeaderField{Name: ":scheme", Value: t.scheme})
headerFields = append(headerFields, hpack.HeaderField{Name: ":path", Value: callHdr.Method})
headerFields = append(headerFields, hpack.HeaderField{Name: ":authority", Value: callHdr.Host})
headerFields = append(headerFields, hpack.HeaderField{Name: "content-type", Value: grpcutil.ContentType(callHdr.ContentSubtype)})
headerFields = append(headerFields, hpack.HeaderField{Name: "user-agent", Value: t.userAgent})
headerFields = append(headerFields, hpack.HeaderField{Name: "te", Value: "trailers"})
...
if dl, ok := ctx.Deadline(); ok {
	timeout := time.Until(dl)
	if timeout <= 0 {
		return nil, status.Error(codes.DeadlineExceeded, context.DeadlineExceeded.Error())
	}
	headerFields = append(headerFields, hpack.HeaderField{Name: "grpc-timeout", Value: grpcutil.EncodeDuration(timeout)})
}

出所:grpc/grpc-go v1.83.2 internal/transport/http2_client.go 578〜614行目抜粋、Apache-2.0

3.1 grpc-timeoutの単位の選択

EncodeDuration はナノ秒から始めて 8桁以内に収まる最小の単位を選び、切り上げます。同じルールをPythonに移植して計算した値です(pb_by_hand.py).

残り時間 grpc-timeout
50ms 50000000n
4.999987秒 4999987u
2分(kubelet CRIデフォルト) 120000m
4分(RunPodSandbox) 240000m
1時間 3600000m

5秒のdeadlineを設定しても、擬似ヘッダーを作成するまでに何マイクロ秒か経過しているため 5S ではなく 4999987u のように送信されます。サーバーは decodeTimeout として読み戻し、自身のコンテキストにtimeoutを設定します。値が壊れている場合はINTERNALとして拒否します(http2_server.go:453 付近)。

4. メッセージのプレフィックス5バイト

DATAフレームに積まれるのは Length-Prefixed-Message の連続です。圧縮フラグ1バイト(0または1) + 長さ4バイト(ビッグエンディアン) + メッセージ。

内容 バイト(計算値)
第2部のRally 21バイト 00 00 00 00 15 + 08 96 01 … 28 03 = 26バイト
ヘルスチェックリクエスト(空メッセージ) 00 00 00 00 00
ヘルスチェックレスポンス SERVING 00 00 00 00 02 08 01

grpc-goの受信側は以下のようになっています。

func (p *parser) recvMsg(maxReceiveMessageSize int) (payloadFormat, mem.BufferSlice, error) {
	err := p.r.ReadMessageHeader(p.header[:])
	if err != nil {
		return 0, nil, err
	}
	pf := payloadFormat(p.header[0])
	length := binary.BigEndian.Uint32(p.header[1:])
	...
	if int(length) > maxReceiveMessageSize {
		return 0, nil, status.Errorf(codes.ResourceExhausted, "grpc: received message larger than max (%d vs. %d)", length, maxReceiveMessageSize)
	}
	data, err := p.r.Read(int(length))

出所:grpc/grpc-go v1.83.2 rpc_util.go 785〜800行目抜粋、Apache-2.0

5. レスポンスとトレーラー

レスポンスは :status 200 と content-type が含まれるヘッダー、メッセージ、そして トレーラーです。トレーラーには grpc-status(10進数)と、オプションで grpc-message, grpc-status-details-bin(base64で格納された google.rpc.Status)が含まれます。成功(0)であっても、ステータスは必ずトレーラーに載せられます。

grpc-message はUTF-8を パーセントエンコーディングします。スペース〜チルダ(% を除く)のみそのままにし、残りは %XX に置き換えます。grpc-goの encodeGrpcMessage ルールを移植して計算した値です。

メッセージ ワイヤー上の grpc-message
rally not found rally not found
랠리 없음 %EB%9E%A0%EB%A6%AC %EC%97%86%EC%9D%8C
100% done 100%25 done

HTTPステータスが200ではないレスポンス(中間プロキシが作成した502など)を受け取ると、クライアントがステータスを でこしらえます。grpc-goの HTTPStatusConvTab は、400→INTERNAL、401→UNAUTHENTICATED、403→PERMISSION_DENIED、404→UNIMPLEMENTED、429・502・503・504→UNAVAILABLEとなります。プロキシの背後で理由のないUNAVAILABLEが見つかった場合、実際にはHTTP 503であった可能性があることを意味します。その他のHTTPエラーはUNKNOWNになります。

逆にストリームが RST_STREAM で切断された場合は、HTTP/2のエラーコードをステータスに変換します。REFUSED_STREAM→UNAVAILABLE(処理前であるためリトライ可能)、CANCEL→CANCELLED、ENHANCE_YOUR_CALM→RESOURCE_EXHAUSTED(第4部のkeepalive ping違反)、残りの大半はINTERNALとなります(http2ErrConvTab、仕様書Errorsセクション)。

6. 17個のステータスコード

statuscodes.md を運用の観点から要約しました。「ライブラリ」欄は、gRPCライブラリが自ら生成できる場合を示しています。

番号 名前 意味 ライブラリが生成する例
0 OK 成功 —
1 CANCELLED 通常、呼び出し側によるキャンセル クライアント側キャンセル(双方)
2 UNKNOWN 不明なエラー サーバーハンドラーがステータスのないエラーを返却、ステータスの解釈失敗
3 INVALID_ARGUMENT リクエスト自体が不正 ドキュメント上になし(第8部を参照)
4 DEADLINE_EXCEEDED 期限超過。サーバー側では成功している可能性あり 双方
5 NOT_FOUND 対象なし なし
6 ALREADY_EXISTS すでに存在 なし
7 PERMISSION_DENIED 権限なし —
8 RESOURCE_EXHAUSTED クォータ・リソース枯渇 メッセージサイズ超過、フロー制御制限
9 FAILED_PRECONDITION 前提条件不一致、修正するまでリトライ不可 なし
10 ABORTED 並行性衝突、上位レイヤーでリトライ なし
11 OUT_OF_RANGE 範囲外(ファイル末尾を超過など) なし
12 UNIMPLEMENTED メソッドなし・未サポート サーバーにメソッドなし、未サポートの圧縮
13 INTERNAL 不変条件の破壊 デシリアライズ失敗、フロー制御違反
14 UNAVAILABLE 一時的な障害。バックオフ後にリトライ サーバー終了中、接続切断、keepaliveタイムアウト
15 DATA_LOSS 回復不可能な損失 なし
16 UNAUTHENTICATED 認証情報なし・誤り 資格情報の失敗

ドキュメントには、INVALID_ARGUMENT・NOT_FOUND・ALREADY_EXISTS・FAILED_PRECONDITION・ABORTED・OUT_OF_RANGE・DATA_LOSSはライブラリが 絶対に生成しないと記載されています。そのため、これらのコードが見つかった場合は「アプリが意図して返却したもの」と読み取ることができます。ただし、grpc-goはcontent-typeが不正なリクエストに対してHTTP 415とともに grpc-status: 3(INVALID_ARGUMENT)を返送します(http2_server.go:504)。ドキュメントと食い違う点であるため、第8部のコントリビューション候補に記載しました。

7. 実測:同じ呼び出しを双方でカウントした場合

コントロールノードA上のkube-apiserverは、同一ノード上のetcd(127.0.0.1:2379)にのみ接続します。両プロセスは同じ秒(2026-08-19 07:42:56 UTC)に起動したため、累積カウンターは同じ期間をカウントしています。クライアント側はapiserverの grpc_client_handled_total(Prometheus)、サーバー側はetcdの grpc_server_handled_total(ノード上で curl -s http://127.0.0.1:2381/metrics、読み取りのみ)です。OKではないものだけを集めました。

sum by (instance, grpc_code, grpc_method) (grpc_client_handled_total{job="apiserver", grpc_code!="OK"})
メソッド コード apiserver(クライアント) etcd(サーバー)
Watch(双方向) CANCELLED 6,982 6,980
Range OUT_OF_RANGE 94 94
Range UNAVAILABLE 1 1
Range CANCELLED 7 3
Range DEADLINE_EXCEEDED 1 0
Txn UNAVAILABLE 9 14
Txn CANCELLED 12 0
Txn DEADLINE_EXCEEDED 4 0
Txn UNKNOWN 0 4

同一期間のOKはサーバー基準でRangeが8,333万回、Txnが3,867万回であるため、エラーの割合は非常に小さく抑えられています。読みどころは割合よりも 名前が食い違う仕組みです。

一言でまとめると、 ステータスコードは「誰が判定したか」によって異なる形で残るです。アラートは呼び出し側の指標で行い、原因分析は両者を並べて行います。

おまけにもう一つ。Cilium エージェント内で hubble list nodes を実行すると rpc error: code = Unimplemented desc = GetNodes not implemented が出ます。1つのノードの Hubble サーバーは GetNodes をあえて実装しておらず(local_observer.go:250)、その質問は複数のノードを把握している hubble-relay に行えという意味です。UNIMPLEMENTED が「メソッドが全く存在しない」だけでなく「ここではサポートしていない」という意味にも使われる例です。

8. 制限事項

9. 小規模なクラスターとの接続

apiserver→etcdのアラートを作るなら grpc_code!="OK" をそのまま使わず、WatchのCANCELLEDとOUT_OF_RANGEを分けて見る必要があります。前者は正常で、 後者はwatchが古すぎるリビジョンを要求したというシグナルであり、圧縮周期やwatchキャッシュを確認する理由になります。

TTの一言。 郵便局が「配達完了(200)」と言っても安心しないでください。手紙の最後のスタンプ(grpc-status)を必ず確認してください!

参考

すべての資料は2026-10-17に閲覧しました。

ログインするといいねできます

コメント

まだコメントはありません。

ログインするとコメントできます