ステータス:ドラフト(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. 直感:封筒、手紙、結果のスタンプ
- 2. 図
- 3. リクエストの擬似ヘッダー
- 4. メッセージのプレフィックス5バイト
- 5. レスポンスとトレーラー
- 6. 17個のステータスコード
- 7. 実測:同じ呼び出しを双方でカウントした場合
- 8. 制限事項
- 9. 小規模クラスターとのつながり
- 参考
1. 直感:封筒、手紙、結果のスタンプ
書留郵便を思い浮かべると分かりやすいです。 封筒(リクエストの擬似ヘッダー)には、宛先の部署と業務名、処理期限が記載されています。中には 複数枚の手紙(メッセージ)が入っており、枚ごとに「この手紙は何バイト」という帯が付いています。処理結果は一番最後に押される 結果のスタンプ(トレーラー)です。郵便局(HTTP)は配達に成功したとして200を返しますが、業務が成功したかどうかはスタンプを見ないと分かりません。
この構造のおかげで、gRPCはレスポンスを1枚ずつ流しつつ(ストリーミング)、最後にステータスを付加することができます。HTTP/1.1のステータス行は先頭になければならないため、これが困難です。
2. 図
- ストリーム番号(stream=1)が呼び出し1つ分です。1つのコネクションにおいてクライアントがオープンするストリームは奇数番号で増加します。
- リクエストの最後のDATAにEND_STREAMが付いていれば「これ以上送るものなし」という意味です。レスポンスはHEADERS → DATA → HEADERS(トレーラー、END_STREAM)の順序です。
- エラーを即座に返す場合はHEADERSが1回(Trailers-Only)で終了します。第4部のリトライはこの違いを見て判断します。
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
- deadlineは 絶対時刻ではなく残り時間として送信します。マシンごとに時計が異なっていても動作するようにするための選択です。すでに経過している場合は送信すらせず、DEADLINE_EXCEEDEDで終了させます。
- リトライした呼び出しであれば
grpc-previous-rpc-attemptsが追加されます(第4部)。
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
- メッセージの境界とDATAフレームの境界は 関係ありません。大きなメッセージは複数のDATAに分割され、小さな複数のメッセージが1つのDATAに入ることもあります(仕様書のData Frames)。そのため受信側はフレームではなく5バイトのヘッダーを基準にして区切って読み取ります。
- 長さを最初に読み込み、 上限から検証します。デフォルトの受信上限はクライアント・サーバーともに4MiBです(
clientconn.go:139,server.go:61)。超過した場合はRESOURCE_EXHAUSTEDとなります。送信上限はデフォルトで無制限(int32の最大値)です。 - 圧縮フラグ1は
grpc-encodingに記載された方式で圧縮したことを意味します。圧縮コンテキストはメッセージごとに新しく始まります。送信側のmsgHeaderがこの5バイトを使用します。
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万回であるため、エラーの割合は非常に小さく抑えられています。読みどころは割合よりも 名前が食い違う仕組みです。
- OUT_OF_RANGE 94 = 94。 ライブラリが生成しないコードであるため、双方が完全に一致しています。etcdが「すでに圧縮(compaction)されたリビジョンを読み込もうとした」ことを意味して直接返却したものです。etcdは「圧縮されたリビジョン」と「まだ存在しない未来のリビジョン」の2つのエラーをOUT_OF_RANGEとして定義しています(
api/v3rpc/rpctypes/error.go:32)。94件がどちらの側であるかはメトリクスからは区別できません(未確認)。 - WatchのCANCELLEDは正常終了ですです。apiserver が watch を再設定するためにストリームを閉じるときに1つずつ増加します。指標だけを見てエラーアラートを設定してはならない代表的な事例です。
- CANCELLED·DEADLINE_EXCEEDED はクライアント側にのみ存在します。 これらはおのおの自身の時計とコンテキストで判定します。クライアントが期限切れで放棄した時点で、サーバーはまだ処理中であるか、すでに終了している可能性があります。サーバー側の Txn UNKNOWN 4 とクライアント側の Txn DEADLINE_EXCEEDED 4 が同じ事象である可能性はありますが、確認できていません(未確認)。
- Txn UNAVAILABLE はサーバーが 14、クライアントが 9 です。etcd クライアント(clientv3)は、接続がまだない場合の UNAVAILABLE のように安全な場合のみリトライするポリシーを持っており(
client/v3/retry.go)、そのリトライ後の最終結果のみがクライアントの指標に残った可能性があります(推定、インターセプターの順序は未確認)。
一言でまとめると、 ステータスコードは「誰が判定したか」によって異なる形で残るです。アラートは呼び出し側の指標で行い、原因分析は両者を並べて行います。
おまけにもう一つ。Cilium エージェント内で hubble list nodes を実行すると rpc error: code = Unimplemented desc = GetNodes not implemented が出ます。1つのノードの Hubble サーバーは GetNodes をあえて実装しておらず(local_observer.go:250)、その質問は複数のノードを把握している hubble-relay に行えという意味です。UNIMPLEMENTED が「メソッドが全く存在しない」だけでなく「ここではサポートしていない」という意味にも使われる例です。
8. 制限事項
- HPACK(ヘッダー圧縮)の実際のバイト、SETTINGS·WINDOW_UPDATE·PING フレームのフローはキャプチャしていません(未測定)。
grpc-status-details-binに載せて送る豊富なエラー情報(google.rpc.ErrorInfo·RetryInfoなど)は、第6篇のOTLPリトライでのみ簡単に見ます。- 第7節の表は累積カウンターの1回分の読み取りです。事象ごとに時刻を合わせて比較したものではないため、「同じ事象」の紐付けは推測です。
9. 小規模なクラスターとの接続
apiserver→etcdのアラートを作るなら grpc_code!="OK" をそのまま使わず、WatchのCANCELLEDとOUT_OF_RANGEを分けて見る必要があります。前者は正常で、
後者はwatchが古すぎるリビジョンを要求したというシグナルであり、圧縮周期やwatchキャッシュを確認する理由になります。
TTの一言。 郵便局が「配達完了(200)」と言っても安心しないでください。手紙の最後のスタンプ(grpc-status)を必ず確認してください!
参考
すべての資料は2026-10-17に閲覧しました。
- grpc/grpc v1.84.0:
doc/PROTOCOL-HTTP2.md,doc/statuscodes.md - grpc/grpc-go v1.83.2:
internal/transport/http2_client.go,internal/transport/http2_server.go,internal/transport/http_util.go,internal/grpcutil/encode_duration.go,rpc_util.go - etcd v3.6.5:
api/v3rpc/rpctypes/error.go,client/v3/retry.go,api/etcdserverpb/rpc.proto - cilium v1.20.1:
pkg/hubble/observer/local_observer.go - 計算コード: 作業ディレクトリ
measure/grpc/pb_by_hand.py - 引用コード: grpc-go v1.83.2, Apache License 2.0.