shiichan

「アイドルじゃないのにアイドル?」quiche の CUBIC を蝕んだデススパイラルの正体!

やっほー、しぃちゃんだよ! 今日はネットワークのいちばん奥、輻輳制御のバグ退治のおはなし。ゾクゾクするくらいカッコいい調査だから、いっしょに潜っていこう!

Cloudflare Blog blog.cloudflare.com

なにが発表されたの?

Cloudflare の Blog で、quiche(Cloudflare がオープンソースで開発している QUIC / HTTP3 の実装)に潜んでいた輻輳制御バグの調査記録が公開されたよ。タイトルは「アイドルじゃないのにアイドル」。ひどいパケットロスのあと、CUBIC の輻輳ウィンドウ(cwnd)が最小値にピタッと張り付いて、二度と回復しなくなる…そんな「デススパイラル」を追いかけた話なの。

きっかけは、社内の ingress プロキシの結合テストが 61% の確率で失敗するという報告。10 秒のタイムアウトがあるのに、約 6 割のテストがダウンロードを終えられなかったんだって。

背景:CUBIC と「アイドル調整」のはなし

CUBIC は Linux のデフォルトの輻輳制御アルゴリズムで、いまや公共インターネット上のほとんどの TCP と QUIC の通信を取り仕切っている存在なの。

物語のはじまりは 2017 年の Linux カーネル。アプリがデータを送り終えてしばらく黙り込む(アイドルになる)と、CUBIC の epoch(成長カーブの起点)が更新されないまま時間だけが過ぎて、再開したとたんに now - epoch_start がとんでもなく大きくなっちゃう。すると輻輳ウィンドウが一気に膨らんで、実態と合わない値になっちゃうの。

これを直したのが Eric Dumazet さんたち。epoch をリセットするんじゃなくて、アイドルだった時間ぶんだけ epoch を後ろへずらす、というエレガントな修正だよ。カーブの形はそのままに、時間軸だけスライドさせる作戦。カーネルでは CA_EVENT_TX_START コールバックで「本当のアイドル」と「一瞬の詰まり」をちゃんと見分けられていたのがポイントなの。

これで何が変わるの?

修正が入ったことで、ひどいパケットロスのあとでも CUBIC がちゃんと回復して、輻輳ウィンドウが本来の CUBIC カーブに沿って伸びるようになったよ。テストは 100% 成功、ダウンロードも数秒で完了。quiche を使っているサービス(Cloudflare のエッジもそう!)は、モバイル回線みたいにロスの多い環境で「なぜかスループットが伸びない」現象から救われるってわけ。

深く潜ってみよう

CUBIC が quiche に移植されたのは 2020 年。このアイドル調整も一緒に運ばれてきたんだけど、QUIC はカーネルじゃなくてユーザー空間で動くから、CA_EVENT_TX_START みたいなコールバックがない。そこで quiche は、パケットを送る on_packet_sent() の中で「bytes_in_flight == 0 ならアイドルだったな」と判定していたの。

if bytes_in_flight == 0 {
    let delta = now - self.last_sent_time;   // ここが落とし穴
    self.congestion_recovery_start_time += delta;
}

ここが罠だった。cwnd がロスで最小値(2 パケット、2700 バイト)まで落ちると、こんなループにハマるの。

  • 手持ちの 2 パケットを送る
  • 約 1 RTT(≒ 14 ms)後に両方 ACK が返って、bytes_in_flight が 0 になる
  • 次のバーストを送るとき on_packet_sent() が「bytes_in_flight == 0 だ!」と判定
  • delta = now - last_sent_time を計算 → でも last_sent_time は前の RTT の先頭の時刻。だから delta は本当のアイドル時間(ほぼ 0)じゃなく、まるまる 1 RTT(≒ 14 ms)になっちゃう
  • 膨らんだ delta で congestion_recovery_start_time が未来へ進む
  • 回復開始時刻が未来にあるせいで in_congestion_recovery() が true を返し続け、CUBIC は「まだ回復中」と勘違いして成長をスキップ
  • cwnd は 2 パケットのまま。次の ACK でパイプが空っぽになって、また最初へ…

こうして cwnd が最小値に張り付いたまま、回復と輻輳回避を ~14 ms ごとに 999 回も行き来する「デススパイラル」が完成しちゃうんだね。しかもこの罠が発動するには、(1) 実際のロスで回復境界がセットされている、(2) 輻輳回避モードにいる、(3) cwnd が 2 パケットの最小値、の 3 つが同時にそろう必要があった。だから Reno や、ウィンドウが大きい接続では表に出なかったの。

面白いのが、Linux カーネルチームも最初の修正の約 1 週間後に自分たちで同じ弱点に気づいて、追いフォローの修正を入れていたこと。送信イベントを基準にアイドルを測ると epoch_start が未来へ飛んでしまう、というまったく同じ落とし穴だったんだよ。

修正はシンプル。ACK が届いた時刻 last_ack_time を覚えておいて、アイドルの起点を「最後に送った時刻」じゃなく「bytes_in_flight が本当に 0 になった時刻」から測るようにしたの。

if bytes_in_flight == 0 {
    let idle_start = max(last_ack_time, last_sent_time);
    let delta = now - idle_start;
    congestion_recovery_start_time += delta;
}

max(last_ack_time, last_sent_time) を使うと、最小ウィンドウのときは delta がほぼ 0 になって回復境界が未来へ飛ばない。逆に本当に長くアイドルだった接続では last_ack_time が遠い過去のままだから、Dumazet さんたちの「epoch を後ろへずらす」振る舞いもちゃんと残る。両立できてるのが気持ちいいね!

まとめ

  • quiche の CUBIC が、ひどいパケットロスのあと輻輳ウィンドウを最小値(2 パケット / 2700 バイト)に張り付かせ、~14 ms ごとに 999 回も状態遷移する「デススパイラル」に陥っていた
  • 原因は、ユーザー空間のアイドル判定で now - last_sent_time を使い、本当のアイドル時間ではなく丸ごと 1 RTT を誤って足していたこと
  • その結果、回復開始時刻が未来へずれて CUBIC が「まだ回復中」と勘違いし、成長をスキップし続けていた
  • 修正は last_ack_time を導入し、bytes_in_flight が 0 になった実際の時刻から測るだけ。テストは 100% 成功に
  • QUIC と輻輳制御の中身を追いかけたい低レイヤー好き、そして「なぜかロス環境でスループットが伸びない」に悩むネットワークエンジニアに刺さる一本だよ!