OpenAICloudflare2026/05/12 13:00

When "idle" isn't idle: how a Linux kernel optimization became a QUIC bug

要点だけを先に読めるように短く再構成したセクションです。

元記事

Quick Digest

要約

要点だけを先に読めるように短く再構成したセクションです。

openaijamodel: gpt-5-mini-2025-08-07

「idle」がアイドルでないとき:Linuxカーネル最適化がQUICバグになった話

Key Points

  • cwndが最小で固定される
  • 送信時のepoch補正が原因
  • epochを未来にしない修正

Summary

この記事は、Cloudflare の quiche 実装で発生した CUBIC のバグ(cwnd が最小値に固定され復旧できない)を追跡した調査の要約です。原因は「送信時にアイドル時間を epoch に加算してしまう」実装差異で、ACK 処理時基準であるべき epoch(または congestion_recovery_start_time)が将来時刻に進められ、ACK クロックごとに recovery と congestion_avoidance を反復してしまう点にあります。最終的な対処は epoch を未来に設定しない(=将来時刻へ進める調整を行わない/now でクランプする)シンプルな変更で解決しました。

Key Points

  • 再現条件
    • quiche のサーバ→クライアントダウンロード
    • 初期2秒に 30% ランダムロス、その後ロス停止
    • cwnd が最小(2パケット)に落ち、bytes_in_flight が ACK 後に毎回 0 になる状況
  • 問題の本質
    • quiche は送信時(on_packet_sent)に last_sent_time を基に idle 補正を行い、congestion_recovery_start_time を前進させていた
    • その結果、epoch(復帰基準)が将来に設定され、ACK ごとに recovery/avoidance を往復して cwnd が増えない
    • 同条件で Reno は影響を受けず、CUBIC 固有の epoch ベースの曲線が原因であることが確認された
  • 修正と実務的対策(エンジニア向け)
    • 根本修正:epoch(または congestion_recovery_start_time)を将来時刻に設定しない(進める場合は now でクランプする、あるいは ACK 時に調整する)
    • 回避策:送信時に idle 補正を行う実装は ACK ベースの処理に移すか、補正値を検査して future を禁止する
    • テスト:最小 cwnd 環境で「ACK が bytes_in_flight を 0 にする」ケースを含む耐久テストを追加する(短 RTT、バースト送信、損失フェーズ→回復をシミュレート)
    • 検証ポイント:qlog で recovery/cwnd の状態遷移を可視化し、ACK 周期(RTT)と一致する過剰な状態遷移が無いことを確認する

Takeaway

  • ユーザー空間実装は TCP カーネル実装とイベントタイミングが異なるため、kernel 側の最適化をそのまま移植すると副作用を生むことがある。
  • シンプルなガード(epoch を未来にしない、ACK 時点での補正)で壊滅的な recovery ループを防げる。

Full Translation

翻訳

原文の流れを保ったまま読める翻訳セクションです。

openaijamodel: gpt-5-mini-2025-08-07

「idle」がアイドルでないとき:Linuxカーネルの最適化がQUICのバグになった経緯

「idle」がアイドルでないとき:Linuxカーネルの最適化がQUICのバグになった経緯

2026-05-12 — Esteban Carisimo, Antonio Vicente — 読了目安 10分

CUBIC(RFC 9438で標準化済み)はLinuxでのデフォルトの輻輳制御器(congestion controller)であり、そのためパブリックインターネット上の多くのTCPおよびQUIC接続が利用可能な帯域をどのように探り、損失を検出したときにどのように後退(back off)し、その後どのように回復するかを決定します。Cloudflareでは、オープンソースのQUIC実装である quiche がデフォルトでCUBICを使っているため、このコードは当社が扱うトラフィックの重要な経路にあります。

本稿では、CUBICの輻輳ウィンドウ(cwnd)が最小値に永続的に固定され、輻輳崩壊から決して回復しないバグの経緯を説明します。物語は、CUBICをRFC 9438 §4.2-12に記載されたアプリ限定(app-limited)除外に合わせることを目的としたLinuxカーネルの変更から始まります。この変更はTCPの実際の問題に対する修正でしたが、quicheに移植した際に予期しない振る舞いを浮き彫りにしました。結末は幸いにも美しく、ほぼ1行の修正で循環が断たれました。

CUBICのロジックを一言で

本題に入る前に、輻輳制御アルゴリズム(CCA)の簡単な復習をします。CCAが操作する中心的なダイヤルは cwnd(輻輳ウィンドウ)で、送信側でどれだけのバイトが「飛行中」(送信済みだがまだackされていない)で良いかを制限します。cwndが大きければ送信側は1ラウンドトリップあたり多くのデータを押し出せますし、小さければ抑制されます。

損失ベースのCCA(CUBICを含む)は本質的に、ネットワークが健全に見えるときに cwnd をどう増やすか、そして健全でないときにどう縮めるかを決めるポリシーです。損失ベースのアルゴリズム群は次の前提に基づきます:

  • 損失がなければ送信レートを上げる(帯域利用率を増やす)。
  • 損失があればネットワークの容量が超過されたと見なし、送信側はバックオフする(帯域利用率を下げる)。

これらのロジックはいくつかの仮定の上に成り立っており、長年にわたり見直されてきましたが、その議論は別の機会に譲ります。

症状:61%の確率で失敗するテスト

調査は、ingress proxyの統合テストパイプラインでの予期しない失敗報告から始まりました。問題は、接続の初期に強いパケット損失があるシナリオでCUBICを評価したテストに現れていました。輻輳崩壊からの回復は稀な状態ですが、まさに輻輳制御器が対処すべき領域です。多くのテストは定常状態や増加フェーズを検証しますが、cwnd が最小になった後にどうなるかを調べるテストは少なく、この隅のバグはスループットダッシュボードでは見えず、静的レビューでも検出されず、CCAを意図的にその状態に追い込んで回復できるかを観察したときにのみ表面化します—今回のテストがまさにそれでした。

シミュレートされたテスト環境の詳細は次のとおりです:

  • Quiche HTTP/3 クライアントとサーバをローカルで実行(localhost)
  • RTT = 10ms(設定で指定)
  • HTTP/3での10 MBファイルダウンロード
  • CUBIC輻輳制御を使用
  • 接続の最初の2秒間に30%のランダムパケット損失を注入
  • 2秒経過後は損失は完全に停止

テストにはダウンロード完了までに余裕を持たせた10秒のタイムアウトがあります。ダウンロードは4〜5秒で完了する見込みです。期待される振る舞いは単純で、損失フェーズ中にCUBICが打撃を受けて cwnd を減らし、損失が止まれば徐々に再増加してタイムアウトの前にダウンロードを完了することです。

ところが、100回の実行を複数回行ったところ、約60%のテストが余裕のある10秒のタイムアウト内にダウンロードを完了できませんでした。

異常:ゼロ損失で999回の状態遷移

我々は quicheqlog をパケット損失イベントで計測し、輻輳制御器内部で何が起きているかを可視化しました。

  • T=2s(2000ms)でパケット損失は完全に停止する。
  • しかし cwnd は最小フロアに固定され続け、輻輳状態は約14msごとに recovery と congestion_avoidance を行ったり来たりする。
  • 約6.7秒間で999回の状態遷移(= 約14msごと)を記録した。
  • その間、cwnd は最小フロアの 2700 bytes(= フルサイズパケット2個分)にロックされている。

損失が無いにもかかわらずin-flightのバイト数が平坦のままであり、これはCUBICの核心的なロジックと矛盾します:損失が無ければ cwnd を増やす(より多くのバイトを飛行させる)べきです。ここでの手がかりは振動周期です:約14msはRTT(10ms)に近く、トリガーは1ラウンドトリップごとに発生しているように見えます。自己クロック化されたリズム、すなわち各ラウンドトリップでクライアントから返ってくるACKがサーバ側の次の送信を駆動する動作に同期しています。

今回のケースはダウンロード(サーバ→クライアント)なので、問題のACKはクライアント→サーバに向かい、CUBICの状態機械はサーバ側で動作します。ACKが到着するたびに bytes_in_flight がゼロになり、サーバが次の2パケットバーストを送る──これがバグをトリガーしていました。

CUBIC固有の問題であることを確認するために、同じテストを別の損失ベースのアルゴリズムである Reno でも実行しました。結果は決定的で、Renoでは100%成功し、損失フェーズ終了後に正常に回復してダウンロードを完了しました。これにより今回の不具合がCUBICに関連するものであることが明らかになりました。

原因追跡

損失ベースのアルゴリズムにはガスとブレーキの二つがあり、加速の仕方が異なります。CUBICはいくつかの余分な機能を備えていますが、本稿では bytes_in_flight == 0 に焦点を当てます。

TCP CUBICの idle 後(Linux, 2017)

このバグを理解するためには、それが由来した最適化をまず理解する必要があります。2017年にLinuxカーネルのCUBIC実装で問題が発見されました。コミットメッセージは要約すると次の懸念を述べています:

  • epoch は初期化時と損失を経験したときにのみ更新/リセットされる。
  • アプリのアイドル後には now - epoch_start(= delta t)が任意に大きくなり得る。
  • その結果、スロープ(ca->cnt の逆数)が非常に大きくなり、最終的に ca->cnt は最低値2に制限され、遅延ACKスロースタートのような振る舞いになる。
  • 特に slow_start_after_idle を無効にしている場合、数秒のアイドル後に危険な cwnd の膨張(1.5x RTT)が現れる。

ここでいう epoch はCUBICが成長曲線を固定するために使う参照時刻です。W_cubic(delta_t) は delta_t = now - epoch_start によってパラメータ化され、CUBICは成長関数を再開する際(最も顕著なのは損失で cwnd が減った後)に epoch をリセットします。リセットの間、delta_t は実時間とともに単調増加します。

アプリがしばらく送信を停止してから再開した場合、epoch がアイドル中に更新されていないと delta_t は巨大になり、W_cubic(delta_t) が極端に大きな目標ウィンドウを返してしまいます。Jana Iyengar の当初の修正案はアプリが送信を再開したときに epoch_start をリセットするものでした。しかし Neal Cardwell はそのアプローチに欠陥を指摘しました:

  • それはCUBICアルゴリズムに現在の cwnd から再び急峻に上昇し始めるように要求してしまう(損失直後と同様の挙動)。理想的には、成長曲線の形状を保ちつつ、アイドル期間だけ時間軸上でシフトさせたい。

Eric Dumazet、Yuchung Cheng、Neal Cardwell によるエレガントな解決は、epochをリセットするのではなくアイドル継続時間分だけ前方にシフトすることでした。これによりCUBICの成長曲線の形状は保持され、単に時間軸上でスライドしてアルゴリズムが中断したところから再開できます。

quicheへの移植(2020)

CUBICが quiche に最初に実装されたとき、このアイドル期間調整は移植されました。しかしQUICはユーザ空間で動作するため、TCPのカーネルレベルの CA_EVENT_TX_START コールバックを持ちません。代わりに quiche 実装は on_packet_sent() の内部でアイドル条件をチェックしています。

// cubic.rs — on_packet_sent() (simplified)
/// Updates the state when a packet is sent.
fn on_packet_sent(&mut self, bytes_in_flight: usize, now: Instant, ...) {
    // If the sending burst is restarting (i.e., bytes_in_flight was zero before this send),
    // adjust the congestion recovery start time to account for the gap in sending.
    if bytes_in_flight == 0 {
        let delta = now - self.last_sent_time;
        self.congestion_recovery_start_time += delta;
    }

    // Record the time of this send event.
    self.last_sent_time = now;
}

(上のコードは簡略化されていますが、重要な点は bytes_in_flight == 0 を検出したときに congestion_recovery_start_time を送信時間差だけ進めている点です。)

どこで壊れるか:QUICの違い

移植された修正には、元のカーネル変更にあったバグが混入しており、そのバグは1週間ほどでカーネル側の追加修正で直されました。セカンドコミットのメッセージは次のように説明しています:

  • tcp_cubicepoch_start を未来に設定しないこと
  • bictcp_cwnd_event() 内でのアイドル時間追跡は不正確であり、epoch_start は通常は送信時ではなくACK処理時に設定される。
  • もし epoch_start を未来に設定してしまうと、bictcp_update() がオーバーフローしてCUBICが再び cwnd を急速に成長させてしまう。

要点は、回復開始時刻(recovery start time)はACK処理中に設定され、送信時間に基づく調整が回復開始時刻を未来に押し上げてしまう可能性がある、ということです。これは今回テストで観察した recovery と congestion_avoidance の間の振動を説明します。

この罠が一貫して発動するのは、着信する各ACKが bytes_in_flight を完全に0にする場合だけです。実際にはこれは cwnd が最小(2パケット)に崩壊し、アプリケーションがACK到着の瞬間に別のフルウィンドウを送るデータを準備している場合に起きます。そうでない通常の状態では、bytes_in_flight == 0 が毎回成り立つことは稀であり、バグが発動しにくくなります。

なぜ接続開始時に起きないのか?

このバグが発動するためには三つの条件が同時に必要です:

  • 実際の損失イベントがあり回復境界(recovery boundary)が設定されていること
  • 接続がスロースタートを抜けて congestion_avoidance を実行していること(CUBIC曲線が効いていること)
  • cwnd が2パケットの最小フロアまで崩壊していること

接続開始時やスロースタート中は congestion_recovery_start_time が設定されていないため、on_packet_sent のバグのある分岐は進めるべき回復境界を持たず、発動しません。CUBICのキュービック曲線とそれが congestion_recovery_start_time に敏感になるのは congestion_avoidance に入ってからです。

自己増殖する回復トラップ

最小 cwnd の状態では、各ACKサイクルがアイドル期間調整(idle period adjustment)をトリガーし、過大に膨らんだ delta が回復開始時刻を未来へ進めてしまいます。最小 cwnd(2パケット)では接続の動的挙動が「デススパイラル」にシフトし、アイドル最適化が自己成就的に...