「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回の状態遷移
我々は quiche の qlog をパケット損失イベントで計測し、輻輳制御器内部で何が起きているかを可視化しました。
- 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() の内部でアイドル条件をチェックしています。
fn on_packet_sent(&mut self, bytes_in_flight: usize, now: Instant, ...) {
if bytes_in_flight == 0 {
let delta = now - self.last_sent_time;
self.congestion_recovery_start_time += delta;
}
self.last_sent_time = now;
}
(上のコードは簡略化されていますが、重要な点は bytes_in_flight == 0 を検出したときに congestion_recovery_start_time を送信時間差だけ進めている点です。)
どこで壊れるか:QUICの違い
移植された修正には、元のカーネル変更にあったバグが混入しており、そのバグは1週間ほどでカーネル側の追加修正で直されました。セカンドコミットのメッセージは次のように説明しています:
tcp_cubic:epoch_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パケット)では接続の動的挙動が「デススパイラル」にシフトし、アイドル最適化が自己成就的に...