ステータス:ドラフト(2026-10-17)、一部実測値を含む。 基準バージョン grpc/grpc-go v1.83.2, gRFC A6 クライアントのリトライ、 grpc/grpc v1.84.0
doc/keepalive.md。実測値は2026-10-17 03時台(KST)に Prometheus・kubectl logs・ノードのetcd/metricsを読み取ることで取得しました。フロー制御ウィンドウが実際に大きくなる様子や、リトライ・keepaliveを意図的に引き起こす実験は行っていません(未計測、必要な作業: grpc-goのサンプルサーバー・クライアントをビルドするためのGoモジュールのダウンロード)。
目次
- 1. 直感:電話とラジオ
- 2. 図解:4つの方式
- 3. フロー制御:受信側が速度を決める
- 4. deadlineとキャンセルの伝播
- 5. リトライとhedging
- 6. keepalive:切断された接続を素早く検知する
- 7. 制限
- 8. 小規模クラスターとのつながり
- 参考
1. 直感:電話とラジオ
- 単項(Unary)は、1通のメッセージに対して1通の返信がある形式です。
- サーバー側ストリーミングはラジオです。一度周波数を合わせると(リクエスト)、放送が流れ続けます。Hubbleのフロー観測(
GetFlows --follow)がこれに該当します。 - クライアント側ストリーミングは、写真を何枚も続けて送り、最後に「受け取りました」という返信を1通受け取る形式です。
- 双方向ストリーミングは電話の会話です。双方がそれぞれ話し、聞きます。etcdのWatchやLeaseKeepAliveがこれに該当します。
どの方式もワイヤー上では同じHTTP/2ストリームであり、違いは メッセージがいくつやり取りされるかと どちらが先に終了を通知するかだけです。
2. 図解:4つの方式
| 方式 | proto宣言 | 小規模クラスターでの確認例 |
|---|---|---|
| 単項(Unary) | rpc Range(RangeRequest) returns (RangeResponse) |
etcd Range・Txn、OTLP Export、CRIのすべて |
| サーバー側ストリーミング | returns (stream …) |
Hubble Observer/GetFlows, Peer/Notify、etcd Maintenance/Snapshot |
| クライアント側ストリーミング | rpc …(stream …) |
メトリクスから検出できず |
| 双方向ストリーミング | rpc Watch(stream WatchRequest) returns (stream WatchResponse) |
etcd Watch・LeaseKeepAlive |
(etcdの宣言は rpc.proto、Hubbleは
observer.proto·peer.protoで確認)
ストリームは無料ではありません。公式のパフォーマンスガイドでは、一度開いたストリームは負荷分散先を変更できずデバッグも困難であるため、明確なメリットがある場合にのみ使用するよう推奨しています (Performance Best Practices)。これが、第5回で扱う偏りの問題がストリームにおいてより深刻になる理由です。
3. フロー制御:受信側が速度を決める
サーバー側ストリーミングでサーバーが秒間1万件送信しているのに対し、クライアントが1000件しか処理できない場合、どうなるでしょうか。HTTP/2では、 受信側が「これだけさらに送信してよい」と
ウィンドウを開く 方式でこれを防ぎます。ウィンドウはストリームごとに1つ、接続全体に1つ存在し、デフォルトサイズは65,535バイトです(RFC 9113)。受信側がデータを消費すると、WINDOW_UPDATEによってウィンドウが再び開かれます。ウィンドウが0の場合、送信側の Send が ブロックされて待機します。これがgRPCストリームにおけるバックプレッシャーです。
grpc-goの2つの特徴:
- ウィンドウサイズを 自動的に拡大する。 pingによって往復遅延時間と帯域幅を測定し、帯域幅×遅延(BDP)を推定してウィンドウを最大16MiBまで拡大します
(
bdp_estimator.go:30のbdpLimit)。遠距離にある大きなストリームが64KiBのウィンドウに制限されて低速化するのを防ぐためです。 WithInitialWindowSize·WithInitialConnWindowSizeでウィンドウを直接指定すると、 この自動調整が無効になります(オプションでStaticWindowSize = trueを同時に有効にする、dialoptions.go:211)。チューニングのために値を設定した結果、かえって遅くなる可能性があるポイントです。
4. deadlineとキャンセルの伝播
- クライアントが0.5秒のdeadlineを指定して呼び出すと、
grpc-timeout: 500mが送信されます(第3部 3.1節)。 - サーバーはその値を使って自身のコンテキストに期限を設定します。120msを消費した後に次のサービスを 同じコンテキストで 呼び出すと、grpc-goは残りの380msを再び
grpc-timeoutとして計算して送信します。期限が連鎖に沿って短くなりながら伝わっていく仕組みです。 - 期限が切れると、クライアントはDEADLINE_EXCEEDEDを受け取り、ストリームに
RST_STREAM(CANCEL)を送信します。サーバー側のコンテキストがキャンセルされるため、そのサーバーがコンテキストを引き継いで呼び出した下流の呼び出しも連鎖的に停止します。
落とし穴となるのは、2番目の「同じコンテキストで」という点です。Goにおいて、サーバーハンドラーが受け取った
イミディエイトなコンテキストの代わりに ctx を使って下流の呼び出しを行うと伝播が途切れ、 context.Background() 上位ではすでに諦めた処理を下流で最後まで実行し続けてしまいます。仕様では期限のないリクエストは無限に送信するものとされているため(PROTOCOL-HTTP2.mdのRequestsセクション)、デフォルト値に依存してはなりません。
4.1 実測:kubeletのCRIタイムアウトと実際のレイテンシ
kubeletのCRIクライアントは呼び出しごとに期限を設定します。デフォルトで runtimeRequestTimeout が2分、 RunPodSandbox がその2倍(4分)、 StopContainer が2分+終了猶予時間となっています(remote_runtime.go:204、379行目)。一方、 PullImage は期限なしでキャンセルのみを行います(
idremote_image.go の PullImage, context.WithCancel)。イメージの取得はサイズによって数分かかっても正常であるためです。
8台の合計、直近6時間のヒストグラムに基づいて計算しました。
histogram_quantile(0.99, sum by (operation_type, le) (rate(kubelet_runtime_operations_duration_seconds_bucket[6h])))
| CRI呼び出し | p50 | p99 | 設定された期限 |
|---|---|---|---|
| pull_image | 35.8秒 | 116.6秒 | なし(キャンセルのみ) |
| stop_container | 23.7秒 | 47.2秒 | 2分 + 猶予時間 |
| exec_sync(execプローブ) | 60ms | 2.2秒 | プローブのtimeoutSeconds |
| run_podsandbox | 365ms | 1.19秒 | 4分 |
| create_container | 12ms | 976ms | 2分 |
| start_container | 64ms | 464ms | 2分 |
| list_containers | 5ms以下 | 5ms以下 | 2分 |
stop_container のp50が24秒である理由は、ランタイムの動作が遅いためではなく、SIGTERM送信後の猶予期間(通常30秒)内にアプリが終了するのを待つためです(推測:終了処理が遅いポッドが混ざっている)。バケットの境界が0.005、0.0125…のように粗いため、一覧取得の「5ms以下」は最初のバケット内という意味になります。直近6時間にCRIの期限超過エラーが発生したかどうかは、 kubelet_runtime_operations_errors_total にエラー種別のラベルがないため判別できませんでした(未確認)。
4.2 実測:OTel Collectorの5秒タイムアウト
Collectorのエクスポートは、リクエストごとにデフォルトで5秒の期限を設定します(opentelemetry-collector v0.159.0
timeout_sender.go:31)。Collectorのログ(起動した2026-09-29以降のすべて、 kubectl logs を読み取り)から、Tempoへのエクスポート失敗をステータスコード別に集計しました。
| 日付(UTC) | DEADLINE_EXCEEDED | UNAVAILABLE | そのとき発生した事象 |
|---|---|---|---|
| 2026-10-05 14:39~16:05 | 2,049 | 27 | Tempoの応答遅延(原因不明) |
| 2026-10-09 18:25~18:27 | 34 | 32 | 一時的な切断(原因不明) |
| 2026-10-13 00:40~00:46 | 0 | 175 | Tempoポッドの再起動(00:42終了、00:44起動) |
- 再起動は UNAVAILABLE(接続拒否)、遅延は DEADLINE_EXCEEDED として記録されました。第3部の表にある「ライブラリによって引き起こされる場合」そのものです。
- 失敗したリクエストはCollector自身のリトライ(
retry_on_failure、最大5分)によって再送され、最終的に送信できなかった179件のバッチ(10-05に169件、10-13に10件)は破棄されました。メトリクスで見ると失敗スパンは11,624個であり、同期間に受信したスパン9,389万個の0.012%にあたります。 - このリトライは gRPCライブラリのリトライ(第5節)ではなく、アプリケーションレベルのリトライです。OTLP仕様では、どのコードをリトライすべきかが個別に規定されています(第6部)。
5. リトライとhedging
gRPCでは、 サービス設定(service config) のメソッドごとの retryPolicy により、ライブラリにリトライを行わせることができます。例を示します(A6のフォーマット)。
{"methodConfig": [{"name": [{"service": "tt.v1.Scoreboard"}],
"retryPolicy": {"maxAttempts": 4, "initialBackoff": "0.1s", "maxBackoff": "1s",
"backoffMultiplier": 2, "retryableStatusCodes": ["UNAVAILABLE"]}}]}
grpc-goにおいて実際にリトライを定義する関数は csAttempt.shouldRetry です。コードを読むとルールが明確にわかります。
- すでにコミットされた呼び出しはリトライされません。 サーバーがレスポンスヘッダーを送信していた場合(レスポンスがTrailers-Onlyではない場合)、サーバーアプリがすでに何らかの処理を開始していることを意味するため停止します。
- ステータスコードが
retryableStatusCodesに含まれている必要があります。どのコードが安全かはアプリによって異なるため、デフォルトのリストはありません(statuscodes.mdの末尾の段落)。 maxAttemptsは最初の呼び出しを含めた回数であり、5を超えると5に切り詰められます(dialoptions.go:43defaultMaxCallAttempts).- 待機時間は
initialBackoff × multiplier^nをmaxBackoffで制限し、 0.8〜1.2倍 のランダム値を掛け合わせます。サーバーがトレーラーにgrpc-retry-pushback-msを返した場合、その値がそのまま使用され、負の値の場合はリトライされません。 - リトライ制御(スロットリング)。 サーバー名ごとにトークンバケットが存在し、失敗ごとに1を減算し、成功ごとに
tokenRatioを加算します。値が半分以下に低下するとリトライが停止します (clientconn.go:1733retryThrottler)。サーバーに障害が発生しているときに、リトライによって負荷が何倍にも膨れ上がるのを防ぎます。 - 再送信を行うには、送信したメッセージを保持しておく必要があります。呼び出しあたり256KiBを超えると、その瞬間にコミットされ、それ以上リトライできなくなります
(
rpc_util.go:205,stream.go:940). - 透過的リトライ。 リクエストがサーバーアプリに届いていないことが確実な場合(送信前の失敗、REFUSED_STREAM、GOAWAYの最後のストリーム番号より大きいストリーム番号)、ポリシーに関わらず再送信されます(A6)。
if a.transportStream != nil {
if !a.transportStream.TrailersOnly() {
return false, err
}
sps := a.transportStream.Trailer()["grpc-retry-pushback-ms"]
...
}
rp := cs.methodConfig.RetryPolicy
if rp == nil || !rp.RetryableStatusCodes[code] {
return false, err
}
if cs.retryThrottler.throttle() {
return false, err
}
if cs.numRetries+1 >= rp.MaxAttempts {
return false, fmt.Errorf("max retries exhausted: failed after %d attempts: %w", cs.numRetries+1, err)
}
出典: grpc/grpc-go v1.83.2 stream.go 727〜768行目を抜粋、Apache-2.0
hedging は、レスポンスを待たずに一定の間隔(
idhedgingDelay)で同じリクエストを複数のサーバーに事前に送信し、最も早い応答を採用する方式です(A6)。テールレイテンシを削減できますが、冪等(idempotent)なメソッドにのみ使用すべきです。grpc-go 1.83.2には TODO(hedging) のコメントのみが存在し(
idstream.go:634)、サービス設定から
id hedgingPolicy を読み取るコードはありません。Goではまだ使用できないと考えるべきです(他言語の実装は未確認)。
6. keepalive:切断された接続を素早く検知する
TCP接続は、相手が消滅しても(電源断、NATテーブルからの削除など)、しばらくの間生きているように見えます。gRPCはHTTP/2の PING を送信し、応答がない場合に接続を切断します。
| 設定 | grpc-goのデフォルト値 | 根拠 |
|---|---|---|
クライアント Time(ping間隔) |
無効(無限)。有効にする場合最小10秒 | defaults.go:32, keepalive.go コメント |
クライアント Timeout |
20秒 | 同ファイルの33行目 |
サーバー Time |
2時間 | 38行目 |
サーバーが許可する最小ping間隔(
idEnforcementPolicy.MinTime) |
5分 | 40行目 |
ストリームがない場合のping許可(
idPermitWithoutStream) |
拒否 | keepalive.go |
| 違反許容回数 | 2回、3回目でGOAWAY | http2_server.go:881 maxPingStrikes |
接続の最大寿命(
idMaxConnectionAge) |
無限 | defaults.go:36 |
最も一般的なトラブルは両端の設定の不一致ですです。クライアントが30秒ごとにpingを送信する一方でサーバーがデフォルト値(5分)を固守する場合、サーバーはpingが「頻繁すぎる」とカウントし
GOAWAY(ENHANCE_YOUR_CALM, "too_many_pings") で接続を切断します(handlePing)。
クライアントにはこれが RESOURCE_EXHAUSTED や UNAVAILABLE として見え、grpc-go クライアントは次回からpingの間隔を2倍に増やします(keepalive.go のコメント)。
6.1 実測:apiserver と etcd はどのように合わせたのか
- kube-apiserver の etcd クライアントは 30秒ごとにping、10秒以内に応答がなければ切断に設定します
(
etcd3.go:63)。etcd の障害を 素早く検知するためのアグレッシブな値であるとコメントに記載されています。 - grpc-go サーバーのデフォルト値(最小5分)であれば、このpingは違反となります。etcd はサーバー側の最小間隔を 5秒に下げておきます(etcd v3.6.5
server/embed/config.go:69DefaultGRPCKeepAliveMinTime、サーバーのping間隔2時間・応答期限20秒)。 - そのため、両コンポーネントは 30秒のping > 5秒の下限という形で噛み合います。自作の gRPC サーバーにクライアントの keepalive を有効にする場合は、サーバーの
MinTimeも一緒に下げる必要があります。
6.2 実測:双方向ストリームはどれくらい長く生きるのか
sum by (instance) (grpc_client_started_total{job="apiserver",grpc_method="Watch"})
- sum by (instance) (grpc_client_handled_total{job="apiserver",grpc_method="Watch"})
開始されたもののまだ終了していない Watch ストリームが、apiserver の3台にそれぞれ 242・239・239個ありました。同時刻のコントロールノード A の etcd は
etcd_debugging_mvcc_watch_stream_total 242, etcd_debugging_mvcc_watcher_total 242 であり、クライアント側の数値と完全に一致していました。
直近1時間で終了した Watch は秒間 0.002個(1時間あたり約8個)だけであり、受信した Watch メッセージは3台の合計で秒間約181個でした。1つのストリームが
何日も生き続けながら変更イベントを流し込んでいるようです。ストリームがなぜピンポイントで約240個なのかについては、第6部でTCP接続数と一緒に確認します。
7. 制限事項
- フロー制御ウィンドウが BDP 推定によって拡大するプロセス、keepalive 違反による GOAWAY の発生シーン、リトライポリシーが実際に何回再送信を行うかについては再現していません(未測定)。
- セクション 4.2 の 10-05〜10-09 の遅延の原因(Tempo 側のディスク・メモリなど)は確認できていません(未確認)。
- etcd クライアント独自のリトライインターセプターと、gRPC サービス設定リトライの関係については扱っていません。
8. 小規模なクラスターとの接続
- 自作の gRPC サービスが他のサービスを呼び出す場合は、 受け取った ctx をそのまま渡しているかを確認することから始めます。それだけでセクション 4 の連鎖的キャンセルが自動的に付いてきます。
- クライアントの keepalive を有効にした場合は、サーバーの
EnforcementPolicy.MinTimeをそれよりも短く設定します。etcd はそのように構成されています。 - Collector のようにアプリレベルのリトライが存在する場合、gRPC サービス設定のリトライをさらに有効にすると試行回数が掛け算されてしまいます。どちらか一方のみを設定します。
Titi の一言。 ラリーが長くなると審判が「残り時間 380ms!」と言って次の審判に引き継ぎます。時計を新しく合わせると(Background)、試合が終わっているのに一人で打ち続けることになってしまいます!
参考資料
すべての資料は 2026-10-17 に閲覧しました。
- gRFC A6 Client Retries, A8 Client-side Keepalive
- grpc/grpc v1.84.0:
doc/keepalive.md,doc/PROTOCOL-HTTP2.md,doc/statuscodes.md - grpc/grpc-go v1.83.2:
stream.go,clientconn.go,dialoptions.go,rpc_util.go,keepalive/keepalive.go,internal/transport/defaults.go,internal/transport/bdp_estimator.go - RFC 9113 HTTP/2: https://www.rfc-editor.org/rfc/rfc9113 (フロー制御、初期ウィンドウ 65,535)
- kubernetes v1.34.10:
staging/src/k8s.io/cri-client/pkg/remote_runtime.go,remote_image.go,storagebackend/factory/etcd3.go - etcd v3.6.5:
server/embed/config.go,api/etcdserverpb/rpc.proto - opentelemetry-collector v0.159.0:
exporter/exporterhelper/internal/timeout_sender.go - 引用コード:grpc-go v1.83.2、Apache License 2.0。