TT Lab
ブログ

한국어 English 日本語 中文

はじめる
ブログ

ブログ

ストリーミング、deadline、リトライ、keepalive:呼び出しが長引く、または失敗したとき

ステータス:ドラフト(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. 直感:電話とラジオ

どの方式もワイヤー上では同じHTTP/2ストリームであり、違いは メッセージがいくつやり取りされるかと どちらが先に終了を通知するかだけです。

2. 図解:4つの方式

네 가지 호출 방식: 화살표 하나가 메시지 하나 단항 rpc Hit(Rally) returns (Score) C S 서버 스트리밍 returns (stream Flow) C S 클라이언트 스트리밍 rpc Upload(stream Chunk) C S 양방향 rpc Watch(stream) returns (stream) C S 진한 실선: 클라이언트→서버, 회색 점선: 서버→클라이언트
図: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つの特徴:

4. deadlineとキャンセルの伝播

deadline 은 시각이 아니라 남은 시간으로 건너간다 게이트웨이 점수 서비스 저장소 grpc-timeout 500m grpc-timeout 380m 남은 시간 막대 (0.5초 예산) 출발할 때 500ms 남음 120ms 처리 뒤 380ms 남음 저장소 도착 380ms 남음 시간이 다 되면: 클라이언트는 DEADLINE_EXCEEDED 를 받고, RST_STREAM(CANCEL) 이 서버로 가서 그쪽 ctx 도 취소된다. 서버가 다시 부른 다음 호출에도 같은 ctx 를 넘겨야 연쇄로 멈춘다.
図:deadlineが残り時間として伝播する様子
  1. クライアントが0.5秒のdeadlineを指定して呼び出すと、 grpc-timeout: 500m が送信されます(第3部 3.1節)。
  2. サーバーはその値を使って自身のコンテキストに期限を設定します。120msを消費した後に次のサービスを 同じコンテキストで 呼び出すと、grpc-goは残りの380msを再び grpc-timeout として計算して送信します。期限が連鎖に沿って短くなりながら伝わっていく仕組みです。
  3. 期限が切れると、クライアントは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起動)

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 です。コードを読むとルールが明確にわかります。

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 はどのように合わせたのか

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. 制限事項

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

Titi の一言。 ラリーが長くなると審判が「残り時間 380ms!」と言って次の審判に引き継ぎます。時計を新しく合わせると(Background)、試合が終わっているのに一人で打ち続けることになってしまいます!

参考資料

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

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

コメント

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

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