Upgrade to Pro — share decks privately, control downloads, hide ads and more …

そのリトライ、 死んだコネクションを使い回していませんか ── 実プロダクト障害から考えるGo...

そのリトライ、 死んだコネクションを使い回していませんか ── 実プロダクト障害から考えるGo HTTPクライアントのタイムアウト設計

GREE Tech Conference 2026で発表された資料です。
https://techcon.gree.jp/2026/session/TrackC-5

Avatar for gree_tech

gree_tech PRO

October 16, 2026

More Decks by gree_tech

Other Decks in Technology

Transcript

  1. あるネットワーク上の障害 背景 • REALITYでは、親会社であるグリーグループで広く使われている課金・ 認証基盤を利用 • この課金認証基盤のAPIサーバに対して、REALITYのサーバから HTTP/2でAPIリクエストを投げる形で利用 起きた障害 •

    ある日突然、課金認証基盤へのAPIリクエストが、context.Contextを用 いてREALITY側で設定したタイムアウトにより大量に失敗する現象が 発生 • エラーメッセージは「context deadline exceeded」 11
  2. 前提:システムの構成図 REALITY 課金認証基盤 k8s Pod Pod API サーバ HTTP/2 ロード

    バランサ API サーバ API サーバ Pod ここ起因で障害が起きた! 13 13
  3. 問題が起きていたコード 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
  4. 問題が起きていたコード 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
  5. 問題が起きていたコード 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
  6. 問題が起きていたコード 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
  7. 問題が起きていたコード 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
  8. 問題が起きていたコード 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
  9. 対策:一時的な問題に見えるので、リトライする • 最初に思ったこと:一時的なネットワーク起因の問題なのでは? ◦ 障害時も課金認証基盤側のシステムに問題が起きていないことは確認 ◦ APIリクエストがタイムアウトする状態は一定時間経過すると自然回復 • そもそも ◦

    問題が起きていた箇所ではAPIリクエストを一度しか呼び出さないコード になっていた 仮説 一時的なネットワーク起因の問題のように見えるので、リトライするように 直せばOKなのでは? 22 22
  10. 対策:リトライを入れた擬似コード 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
  11. 対策:リトライを入れた擬似コード 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
  12. Podのログを観察してみると... • とりあえずで対策した案がうまくいかないのでPodのログを 眺めてみる • 以下のような現象が確認された • ⼀度課⾦認証基盤へのAPIアクセスが「context deadline exceeded」で

    タイムアウトするようになると、そのpodからのAPIアクセスは リトライしてもほぼ成功しない状態が続く • ただAPIリクエストの中に「http2: client connection lost」という エラーが出ると、そこからはAPIリクエストが成功するようになる 26
  13. 解決につながりそうな仮説 • 仮説:今のリトライ処理はネットワーク的に不調なコネクションを 使いまわしているのではないか? • ということは 使用しているコネクションがネットワーク不良の時、 「http2: client connection

    lost」が出る(つまり切断する)ように してから、APIリクエストを送ればうまくいくのではないか net/httpのコネクションの死活管理の仕組みについて深掘りしよう 28
  14. 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
  15. 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
  16. なぜこのヘルスチェック処理が必要なのか • TCPの切断は必ずしも相手に通知されるとは限らない ◦ 経路上の障害、ロードバランサの退役など • 上記のような場合、片方だけが「切れた」と認識している状態が発生する →「half-open connections」 ◦

    送信側: ローカルのバッファに書き込めてしまい、エラーにならない ◦ 受信: 何も届かないだけなのでレスポンスに時間がかかっているだけの場合と区別がつかない • したがってhalf-open connectionsの場合には能動的に検知をしなければ、 気づきようがない このヘルスチェック処理はhttp.DefaultClientでは動かないような 設定になっている 35
  17. net/httpの能動的なコネクション死活管理の仕組み • ヘルスチェック処理は以下のようなステップで行われる ◦ 受信ループのgoroutineで一定時間フレームを読み込んでいない場合に ヘルスチェック処理を発火 ◦ 受信ループが対応しているコネクション上でPingフレームという制御用のフレー ムを送る ◦

    Pingフレームに対し、一定時間以内に ▪ ACKが返された場合はコネクションを「Healthy」とする ▪ ACKが返されなかった場合はコネクションを「Unhealthy」とする ◦ 「Unhealthy」となったコネクションはコネクションプールから除外され、そのコネク ション上のストリームを使っていた処理は「http2: client connection lost」エラー で中断される 36
  18. 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
  19. 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
  20. 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
  21. 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
  22. 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
  23. 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
  24. 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
  25. net/httpの能動的なコネクション死活管理の仕組み • ヘルスチェック処理は以下のようなステップで行われる ◦ 受信ループのgoroutineで一定時間フレームを読み込んでいない場合に ヘルスチェック処理を発火 ◦ 受信ループが対応しているコネクション上でPingフレームという制御用のフレー ムを送る ◦

    Pingフレームに対し、一定時間以内に ▪ ACKが返された場合はコネクションを「Healthy」とする ▪ ACKが返されなかった場合はコネクションを「Unhealthy」とする ◦ 「Unhealthy」となったコネクションはコネクションプールから除外され、そのコネク ション上のストリームを使っていた処理は「http2: client connection lost」エラー で中断される 44
  26. net/httpの能動的なコネクション死活管理の仕組み • ヘルスチェック処理は以下のようなステップで行われる ◦ 受信ループのgoroutineで一定時間 フレームを読み込んでいない場合に ヘルスチェック処理を発火 ◦ 受信ループが対応しているコネクション上でPingフレームという制御用のフレー http.HTTP2Config.SendPingTimeout

    ムを送る ◦ Pingフレームに対し、一定時間 以内に ▪ ACKが返された場合はコネクションを「Healthy」とする ▪ ACKが返されなかった場合はコネクションを「Unhealthy」とする ◦ 「Unhealthy」となったコネクションはコネクションプールから除外され、そのコネク ション上のストリームを使っていた処理は「http2: client connection lost」エラー で中断される http.HTTP2Config.PingTimeout 45
  27. SendPingTimeoutとPingTimeoutでなんとかなりそう 元々の仮説 使⽤しているコネクションがネットワーク不良であるならば、「http2 client connection lost」 を出るようにしてから、APIリクエストを投げればうまくいくのではないか 導出したアプローチ • リクエストを投げてから終わるまでの間に、ヘルスチェックが終了することを保証すれば良

    い • そうすると⼀回⽬のリクエストのタイムアウト前までに 「http2: client connection lost」エラーが出る(コネクションが死んでいる場合) • その状態でリトライしたら、新しいコネクションを使ってくれるのでうまくいくようになる はず! 46
  28. 最終的な設定値: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
  29. 最終的なコード: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
  30. 結果 • うまくいった! ◦ 一回目のリクエストで失敗する際にエラーが 「context deadline exceeded」から 「http2: client

    connection lost」に変わった ◦ 二回目のリクエスト(リトライ)が成功するように! • 今回の障害に起因する補填作業の件数が0になった! • ここまできてようやく最初の設定でうまくいかない理由もわかった 52
  31. 問題が起きていたコード(再掲) 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
  32. 問題が起きていたコード(再掲) 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
  33. ここまでを振り返って:パラメータ設計は難しい • • HTTPのタイムアウトパラメータをいい感じに設計するのは難しい(HTTP に限らないが) ◦ 多少良くなかったとしても基本的に問題なく動いてしまう ◦ 今見た通り相互作用するものがあり、単独で決められるとは限らない ◦

    うまくいっている他の会社のパラメータをそのまま真似すればいいと いうものでもなさそう ◦ パラメータごとに負荷試験やカオスエンジニアリングをして、検証す るというのもハイカロリー 例えばk8sでもHTTP/2のタイムアウトパラメータ周りに関して、過去に 問題が起きていたりする ◦ https://github.com/kubernetes/kubernetes/issues/121793 ◦ ヘルスチェック時間間隔の兼ね合いで、 ノードが意図せずNotReady になってしまうケースがあった 56
  34. パラメータに関する不等式をたくさん見つける • パラメータに関する不等式をたくさん見つける ◦ • 自分一人で考えても限界があるので、他所からネットワーク関係の問題が 起きた事例をいっぱい引っ張ってくる ◦ ◦ •

    今回のSendPingTimeout + PingTimeout < コンテキストのTimeoutもその一つ https://blog.cybozu.io/entry/2024/08/08/183405 https://developersblog.dmm.com/entry/2024/12/06/110000 計測によって下界を定める不等式を作り上げることもできる 60
  35. パラメータに関する不等式をたくさん見つける • 例 ◦ ◦ ◦ ◦ ◦ • 実測した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
  36. 固定しやすいパラメータを固定する • • パラメータによって固定しやすさに差がある 例えば ◦ ◦ • リクエストのタイムアウトはアプリケーション要件なのでSLOなどにしたがって固定でき る(この辺は現場による)

    PingTimeoutやnet.Dialer.Timeoutなどは実測ベースで決められるので比較的固定しやす い 選択した不等式群を満たすように、固定しやすいパラメータを固定する 62
  37. 残りのパラメータを不等式に従い埋める • 不等式を満たすように残りのパラメータを埋めていく ◦ ◦ • • SendPingTimeout + PingTimeout

    < contextのタイムアウトなら、PingTimeoutを3 秒、contextのタイムアウトを10秒で設定しているので SendPingTimeoutは0秒以上7秒 未満のどれか 各パラメータについて許容範囲を狭めた状態での設定ができる 以上のプロセスを踏むことで、パラメータの設定、あるいはパラメータの 事後検査が可能 「最適」ではないかもしれないが、複数の明示的な意思決定のフィルター を通したパラメータが得られる ◦ 不等式一つ一つがパラメータを支える根拠となる 63