Share
Embed
そのリトライ、 死んだコネクションを使い回していませんか ── 実プロダクト障害から考えるGo HTTPクライアントのタイムアウト設計
GREE Tech Conference 2026で発表された資料です。
https://techcon.gree.jp/2026/session/TrackC-5
Transcript
69 slides
-
松田祐佐(まつだ ゆうさ) REALITY株式会社 バックエンドエンジニア • 2020年グリー株式会社(現 グリーホール ディングス株式会社)新卒入社 • 2022年からグリーグループのREALITY株式
会社でVTuberの配信プラットフォームアプリ 「REALITY」の開発に従事 REALITY上での アバターです! 2 -
あるネットワーク上の障害 背景 • REALITYでは、親会社であるグリーグループで広く使われている課金・ 認証基盤を利用 • この課金認証基盤のAPIサーバに対して、REALITYのサーバから HTTP/2でAPIリクエストを投げる形で利用 起きた障害 •
ある日突然、課金認証基盤へのAPIリクエストが、context.Contextを用 いてREALITY側で設定したタイムアウトにより大量に失敗する現象が 発生 • エラーメッセージは「context deadline exceeded」 11 -
前提:システムの構成図 REALITY 課金認証基盤 k8s Pod Pod Pod API サーバ HTTP/2
ロード バランサ API サーバ API サーバ 12 12 -
前提:システムの構成図 REALITY 課金認証基盤 k8s Pod Pod API サーバ HTTP/2 ロード
バランサ API サーバ API サーバ Pod ここ起因で障害が起きた! 13 13 -
障害の特徴 今回の障害の特徴 1. 定常的ではなく、急にバースト的に発生して、しばらくすると 自然回復する 2. 一度発生したらほぼ全てのAPIリクエストについてタイムアウトする 3. 課金認証基盤側のロードバランサにそもそもリクエストが 到達していない
早期に解決したいが、なかなか原因も対策もつかめず、 毎日補填対応をしながら調査を並行して進めるという期間が続きました... 14 14 -
問題が起きていたコード var client = createHTTPClient() // デフォルトからカスタマイズした http.Client // コインを付与する処理
func issueCoin(coinAmount uint) error { ctx, cancel := context.WithTimeout(ctx, 10 * time.Second) ... req, err := http.NewRequestWithContext( ctx, "POST", "/api/v1/coin/issue", bytes.NewReader(data) ) ... res, err := client.Do(req) // コインを付与する APIを叩く if err != nil { return err } } 15 15 -
問題が起きていたコード var client = createHTTPClient() // デフォルトからカスタマイズした http.Client // コインを付与する処理
func issueCoin(coinAmount uint) error { ctx, cancel := context.WithTimeout(ctx, 10 * time.Second) ... req, err := http.NewRequestWithContext( ctx, "POST", "/api/v1/coin/issue", bytes.NewReader(data) ) ... res, err := client.Do(req) // コインを付与する APIを叩く if err != nil { return err } } 16 16 -
問題が起きていたコード var client = createHTTPClient() // デフォルトからカスタマイズした http.Client // コインを付与する処理
func issueCoin(coinAmount uint) error { ctx, cancel := context.WithTimeout(ctx, 10 * time.Second) ... req, err := http.NewRequestWithContext( ctx, "POST", "/api/v1/coin/issue", bytes.NewReader(data) ) ... res, err := client.Do(req) // コインを付与する APIを叩く if err != nil { return err } } 17 17 -
問題が起きていたコード var client = createHTTPClient() // デフォルトからカスタマイズした http.Client // コインを付与する処理
func issueCoin(coinAmount uint) error { ctx, cancel := context.WithTimeout(ctx, 10 * time.Second) ... req, err := http.NewRequestWithContext( ctx, "POST", "/api/v1/coin/issue", bytes.NewReader(data) ) ... res, err := client.Do(req) // コインを付与する APIを叩く if err != nil { return err } } 18 18 -
問題が起きていたコード func createHTTPClient() *http.Client { transport := http.DefaultTransport.(*http.Transport).Clone() … (DialContextだったり
IdleConnTimeoutだったりその他パラメータも調整 ) } transport.HTTP2 = &http.HTTP2Config{ SendPingTimeout: 10 * time.Second, PingTimeout: 10 * time.Second, } return &http.Client{ Transport: transport, } var client = createHTTPClient() 19 19 -
問題が起きていたコード func createHTTPClient() *http.Client { transport := http.DefaultTransport.(*http.Transport).Clone() … (DialContextだったり
IdleConnTimeoutだったりその他パラメータも調整 ) } transport.HTTP2 = &http.HTTP2Config{ SendPingTimeout: 10 * time.Second, PingTimeout: 10 * time.Second, } return &http.Client{ Transport: transport, } AIに推奨されたパラメータに推奨値をそのまま入れていた var client = createHTTPClient() 20 20 -
対策:一時的な問題に見えるので、リトライする • 最初に思ったこと:一時的なネットワーク起因の問題なのでは? ◦ 障害時も課金認証基盤側のシステムに問題が起きていないことは確認 ◦ APIリクエストがタイムアウトする状態は一定時間経過すると自然回復 • そもそも ◦
問題が起きていた箇所ではAPIリクエストを一度しか呼び出さないコード になっていた 仮説 一時的なネットワーク起因の問題のように見えるので、リトライするように 直せばOKなのでは? 22 22 -
対策:リトライを入れた擬似コード var client = createHTTPClient() // デフォルトからカスタマイズした http.Client // コインを付与する処理
func issueCoin(coinAmount uint) error { for i := range 2 { ctx, cancel := context.WithTimeout(ctx, 10 * time.Second) ... res, err := client.Do(req) // コインを付与する APIを叩く if err != nil { ... continue } } return errors.New("リトライしても失敗 ") } 23 23 -
対策:リトライを入れた擬似コード var client = createHTTPClient() // デフォルトからカスタマイズした http.Client // コインを付与する処理
func issueCoin(coinAmount uint) error { for i := range 2 { ctx, cancel := context.WithTimeout(ctx, 10 * time.Second) ... res, err := client.Do(req) // コインを付与する APIを叩く if err != nil { ... continue } } return errors.New("リトライしても失敗 ") } 24 24 -
Podのログを観察してみると... • とりあえずで対策した案がうまくいかないのでPodのログを 眺めてみる • 以下のような現象が確認された • ⼀度課⾦認証基盤へのAPIアクセスが「context deadline exceeded」で
タイムアウトするようになると、そのpodからのAPIアクセスは リトライしてもほぼ成功しない状態が続く • ただAPIリクエストの中に「http2: client connection lost」という エラーが出ると、そこからはAPIリクエストが成功するようになる 26 -
Podのログを観察してみると... • コネクションが切断されたログ「http2: client connection lost」がでたら、 再度うまくいくようになった • つまりコネクションが新規に接続され直したらうまくいくようになった 仮説
今のリトライ処理はネットワーク的に不調なコネクションを 使いまわしているからうまくいかないのではないか? 27 -
解決につながりそうな仮説 • 仮説:今のリトライ処理はネットワーク的に不調なコネクションを 使いまわしているのではないか? • ということは 使用しているコネクションがネットワーク不良の時、 「http2: client connection
lost」が出る(つまり切断する)ように してから、APIリクエストを送ればうまくいくのではないか net/httpのコネクションの死活管理の仕組みについて深掘りしよう 28 -
HTTP/2の基礎:ストリーム • HTTP/1では1本のTCPコネクションには同時に一つのリクエスト・レスポンスの やりとりしかできない • HTTP/2では1本のTCPコネクション上で同時に複数のリクエスト・レスポンスの やりとりをできる ◦ リクエスト/レスポンスのペアをストリームという単位で識別し、各ストリームごとのフレーム を混ぜて送っている
HTTP/2 HTTP/1 * HTTP/1に関して「パイプライン」もあるがここでは省略 31 -
HTTP/2におけるnet/httpのclient.Do概観 コネクションプール map[string][]*ClientConn 送信用 goroutine A client.Do A “example.com:443” コネクション1
送信用 goroutine B Aのフレーム Bのフレーム Aのフレーム Aのフレーム リクエスト Bのフレーム レスポンス ⋮ client.Do B 受信ループ用 goroutine The Go gopher was designed by Renée French. Gopher illustrations by Egon Elbre (CC0). ⋮ 32 -
HTTP/2におけるnet/httpのclient.Do概観 コネクションプール map[string][]*ClientConn 送信用 goroutine A client.Do A “example.com:443” コネクション1
送信用 goroutine B Aのフレーム Bのフレーム Aのフレーム リクエスト Bのフレーム レスポンス ⋮ http2: client connection lost client.Do B のキーマン 受信ループ用 goroutine The Go gopher was designed by Renée French. Gopher illustrations by Egon Elbre (CC0). Aのフレーム ⋮ 33 -
「http2: client connection lost」が出るタイミング • net/httpはHTTP/2コネクションごとに対応する受信ループ用の goroutineをもち、レスポンスや制御用のフレームを読み続けている • 受信ループごとにヘルスチェック処理を特定のタイミングで実行しており、 ヘルスチェックに失敗すると対応するコネクションが死んだと判定される
このとき処理中だったリクエストは「http2: client connection lost」で 失敗する コネクションはプールから切断‧破棄され、次のリクエストは新しい コネクションを確⽴する 34 -
なぜこのヘルスチェック処理が必要なのか • TCPの切断は必ずしも相手に通知されるとは限らない ◦ 経路上の障害、ロードバランサの退役など • 上記のような場合、片方だけが「切れた」と認識している状態が発生する →「half-open connections」 ◦
送信側: ローカルのバッファに書き込めてしまい、エラーにならない ◦ 受信: 何も届かないだけなのでレスポンスに時間がかかっているだけの場合と区別がつかない • したがってhalf-open connectionsの場合には能動的に検知をしなければ、 気づきようがない このヘルスチェック処理はhttp.DefaultClientでは動かないような 設定になっている 35 -
net/httpの能動的なコネクション死活管理の仕組み • ヘルスチェック処理は以下のようなステップで行われる ◦ 受信ループのgoroutineで一定時間フレームを読み込んでいない場合に ヘルスチェック処理を発火 ◦ 受信ループが対応しているコネクション上でPingフレームという制御用のフレー ムを送る ◦
Pingフレームに対し、一定時間以内に ▪ ACKが返された場合はコネクションを「Healthy」とする ▪ ACKが返されなかった場合はコネクションを「Unhealthy」とする ◦ 「Unhealthy」となったコネクションはコネクションプールから除外され、そのコネク ション上のストリームを使っていた処理は「http2: client connection lost」エラー で中断される 36 -
net/httpのヘルスチェック処理 client.Do A 送信⽤ goroutine A コネクション1 Aのフレーム Aのフレーム The
Go gopher was designed by Renée French. Gopher illustrations by Egon Elbre (CC0). Aのフレーム リクエスト レスポンス 受信ループ⽤ goroutine 37 -
net/httpのヘルスチェック処理 受信ループのgoroutineで一定時間フレームを読み込んでいない場合に ヘルスチェック処理を発火 client.Do A 送信⽤ goroutine A コネクション1 Aのフレーム
リクエスト レスポンス 最近フレーム読み込んでないな... The Go gopher was designed by Renée French. Gopher illustrations by Egon Elbre (CC0). 受信ループ⽤ goroutine 38 -
net/httpのヘルスチェック処理 受信ループのgoroutineで一定時間フレームを読み込んでいない場合に ヘルスチェック処理を発火 送信⽤ goroutine A client.Do A コネクション1 Aのフレーム
リクエスト レスポンス ヘルスチェック⽤ goroutine 起動 The Go gopher was designed by Renée French. Gopher illustrations by Egon Elbre (CC0). 念の為ヘルスチェックするか! 受信ループ⽤ goroutine 39 -
net/httpのヘルスチェック処理 受信ループが対応しているコネクション上でPingフレームという制御用の フレームを送る 送信⽤ goroutine A client.Do A コネクション1 Pingフレーム
Aのフレーム リクエスト レスポンス ヘルスチェック⽤ goroutine The Go gopher was designed by Renée French. Gopher illustrations by Egon Elbre (CC0). 受信ループ⽤ goroutine 40 -
net/httpのヘルスチェック処理 一定時間内にACKが返された場合はコネクションを「Healthy」とする 送信⽤ goroutine A client.Do A コネクション1 Pingフレーム Ping
ACK Aのフレーム リクエスト レスポンス ヘルスチェック⽤ goroutine 通知 ⼀定時間内にPing ACK 返ってきました! The Go gopher was designed by Renée French. Gopher illustrations by Egon Elbre (CC0). よし!コネクション⼤丈夫だな! 受信ループ⽤ goroutine 41 -
net/httpのヘルスチェック処理 一定時間内にACKが返されなかった場合はコネクションを「Unhealthy」とする 送信⽤ goroutine A client.Do A コネクション1 Pingフレーム Ping
ACK Aのフレーム リクエスト レスポンス ヘルスチェック⽤ goroutine 通知 ⼀定時間内にPing ACK 返ってきませんでした... The Go gopher was designed by Renée French. Gopher illustrations by Egon Elbre (CC0). え、このコネクションダメだ... 受信ループ⽤ goroutine 42 -
net/httpのヘルスチェック処理 「Unhealthy」となったコネクションはコネクションプールから除外され、 そのコネクション上のストリームを使っていた処理は「http2: client connection lost」 エラーで中断される client.Do A http2:
client connection lost 除外だ! このコネクション使っているやつに連 絡! The Go gopher was designed by Renée French. Gopher illustrations by Egon Elbre (CC0). 受信ループ⽤ goroutine 43 -
net/httpの能動的なコネクション死活管理の仕組み • ヘルスチェック処理は以下のようなステップで行われる ◦ 受信ループのgoroutineで一定時間フレームを読み込んでいない場合に ヘルスチェック処理を発火 ◦ 受信ループが対応しているコネクション上でPingフレームという制御用のフレー ムを送る ◦
Pingフレームに対し、一定時間以内に ▪ ACKが返された場合はコネクションを「Healthy」とする ▪ ACKが返されなかった場合はコネクションを「Unhealthy」とする ◦ 「Unhealthy」となったコネクションはコネクションプールから除外され、そのコネク ション上のストリームを使っていた処理は「http2: client connection lost」エラー で中断される 44 -
net/httpの能動的なコネクション死活管理の仕組み • ヘルスチェック処理は以下のようなステップで行われる ◦ 受信ループのgoroutineで一定時間 フレームを読み込んでいない場合に ヘルスチェック処理を発火 ◦ 受信ループが対応しているコネクション上でPingフレームという制御用のフレー http.HTTP2Config.SendPingTimeout
ムを送る ◦ Pingフレームに対し、一定時間 以内に ▪ ACKが返された場合はコネクションを「Healthy」とする ▪ ACKが返されなかった場合はコネクションを「Unhealthy」とする ◦ 「Unhealthy」となったコネクションはコネクションプールから除外され、そのコネク ション上のストリームを使っていた処理は「http2: client connection lost」エラー で中断される http.HTTP2Config.PingTimeout 45 -
SendPingTimeoutとPingTimeoutでなんとかなりそう 元々の仮説 使⽤しているコネクションがネットワーク不良であるならば、「http2 client connection lost」 を出るようにしてから、APIリクエストを投げればうまくいくのではないか 導出したアプローチ • リクエストを投げてから終わるまでの間に、ヘルスチェックが終了することを保証すれば良
い • そうすると⼀回⽬のリクエストのタイムアウト前までに 「http2: client connection lost」エラーが出る(コネクションが死んでいる場合) • その状態でリトライしたら、新しいコネクションを使ってくれるのでうまくいくようになる はず! 46 -
最終的な設定値:SendPingTimeoutとPingTimeout func createHTTPClient() *http.Client { transport := http.DefaultTransport.(*http.Transport).Clone() … (DialContextだったり
IdleConnTimeoutだったりその他パラメータも調整 ) } transport.HTTP2 = &http.HTTP2Config{ SendPingTimeout: 5 * time.Second, PingTimeout: 3 * time.Second, } return &http.Client{ Transport: transport, } SendPingTimeout: 10秒から5秒に PingTimeout: 10秒から3秒に var client = createHTTPClient() 50 -
最終的なコード:API呼び出し部分 var client = createHTTPClient() // デフォルトからカスタマイズした http.Client // コインを付与する処理
func issueCoin(coinAmount uint) error { for i := range 2 { ctx, cancel := context.WithTimeout(ctx, 10 * time.Second) ... res, err := client.Do(req) // コインを付与する APIを叩く if err != nil { ... continue } SendPingTimeout(5s) + PingTimeout(3s) } <Contextのタイムアウト(10s) が成⽴ return errors.New("リトライしても失敗 ") マージン⽤に2s確保されている } 51 -
結果 • うまくいった! ◦ 一回目のリクエストで失敗する際にエラーが 「context deadline exceeded」から 「http2: client
connection lost」に変わった ◦ 二回目のリクエスト(リトライ)が成功するように! • 今回の障害に起因する補填作業の件数が0になった! • ここまできてようやく最初の設定でうまくいかない理由もわかった 52 -
問題が起きていたコード(再掲) func createHTTPClient() *http.Client { transport := http.DefaultTransport.(*http.Transport).Clone() … (DialContextだったり
IdleConnTimeoutだったりその他パラメータも調整 ) } transport.HTTP2 = &http.HTTP2Config{ SendPingTimeout: 10 * time.Second, PingTimeout: 10 * time.Second, } return &http.Client{ Transport: transport, } var client = createHTTPClient() 53 -
問題が起きていたコード(再掲) func createHTTPClient() *http.Client { transport := http.DefaultTransport.(*http.Transport).Clone() … (DialContextだったり
IdleConnTimeoutだったりその他パラメータも調整 ) transport.HTTP2 = &http.HTTP2Config{ SendPingTimeout: 10 * time.Second, PingTimeout: 10 * time.Second, } return &http.Client{ Transport: transport, } SendPingTimeoutが PingTimeoutがともに10秒 var client = createHTTPClient() つまりContextのタイムアウト時間より⼤ } 54 -
ここまでを振り返って:パラメータ設計は難しい • • HTTPのタイムアウトパラメータをいい感じに設計するのは難しい(HTTP に限らないが) ◦ 多少良くなかったとしても基本的に問題なく動いてしまう ◦ 今見た通り相互作用するものがあり、単独で決められるとは限らない ◦
うまくいっている他の会社のパラメータをそのまま真似すればいいと いうものでもなさそう ◦ パラメータごとに負荷試験やカオスエンジニアリングをして、検証す るというのもハイカロリー 例えばk8sでもHTTP/2のタイムアウトパラメータ周りに関して、過去に 問題が起きていたりする ◦ https://github.com/kubernetes/kubernetes/issues/121793 ◦ ヘルスチェック時間間隔の兼ね合いで、 ノードが意図せずNotReady になってしまうケースがあった 56 -
パラメータに関する不等式をたくさん見つける • パラメータに関する不等式をたくさん見つける ◦ • 自分一人で考えても限界があるので、他所からネットワーク関係の問題が 起きた事例をいっぱい引っ張ってくる ◦ ◦ •
今回のSendPingTimeout + PingTimeout < コンテキストのTimeoutもその一つ https://blog.cybozu.io/entry/2024/08/08/183405 https://developersblog.dmm.com/entry/2024/12/06/110000 計測によって下界を定める不等式を作り上げることもできる 60 -
パラメータに関する不等式をたくさん見つける • 例 ◦ ◦ ◦ ◦ ◦ • 実測したPingのラウンドトリップ時間の99.99%tile
+ 実測したGoランタイムの待ち時間 の99.99%tile < PingTimeout Podの最大数 / SendPingTimeout < Ping flood判定をされる1秒あたりのPing回数の閾 値 クライアントのIdleConnTimeout < ロードバランサー側のIdleTimeoutの設定値 実測した値のnet.Dialの99.9%tile < net.Dialer.Timeout WriteByteTimeout < 16分(詳細はCybozuさんの事例参照) 各不等式の中からどれを採用するかは意思を持って決める 不等式一つ一つが、そのパラメータに関する意思決定と対応するようになる 61 -
固定しやすいパラメータを固定する • • パラメータによって固定しやすさに差がある 例えば ◦ ◦ • リクエストのタイムアウトはアプリケーション要件なのでSLOなどにしたがって固定でき る(この辺は現場による)
PingTimeoutやnet.Dialer.Timeoutなどは実測ベースで決められるので比較的固定しやす い 選択した不等式群を満たすように、固定しやすいパラメータを固定する 62 -
残りのパラメータを不等式に従い埋める • 不等式を満たすように残りのパラメータを埋めていく ◦ ◦ • • SendPingTimeout + PingTimeout
< contextのタイムアウトなら、PingTimeoutを3 秒、contextのタイムアウトを10秒で設定しているので SendPingTimeoutは0秒以上7秒 未満のどれか 各パラメータについて許容範囲を狭めた状態での設定ができる 以上のプロセスを踏むことで、パラメータの設定、あるいはパラメータの 事後検査が可能 「最適」ではないかもしれないが、複数の明示的な意思決定のフィルター を通したパラメータが得られる ◦ 不等式一つ一つがパラメータを支える根拠となる 63 -
まとめ • • • タイムアウトの設定値によっては、リトライしても死んだコネクションを 使いまわしてしまうと言った問題が起こり得る HTTPクライアントの各種パラメータは最適を目指すことが難しくても、 意思をもって根拠のある値を設定することが大事 根拠のあるタイムアウト設計の考え方の一つを紹介した 今一度自分たちの
HTTPClientのコードにどのようなパラメータ設定が されているか確認していただき、改めてそのパラメータにしている理由などに想 いを馳せるきっかけになれば幸いです。 66