アイドル状態がアイドルではないとき:QUIC 輻輳のデス・スパイラルを解決する
輻輳制御アルゴリズム (CCAs) は、インターネットの目に見えない指揮者であり、ネットワークの崩壊を引き起こすことなく、送信者がどれだけのデータをネットワークに送り込めるかを決定します。Linux のデフォルトの輻輳コントローラーである CUBIC は、容量を探索し、パケット損失が検出されたときにバックオフを行うことで、帯域幅の利用率を最大化するように設計されています。しかし、ユーザースペースの実装とカーネルレベルの最適化の間の複雑な相互作用において、たとえ小さな論理エラーであっても、壊滅的な失敗につながる可能性があります。
Cloudflare は最近、自社のオープンソースの QUIC 実装である quiche において、輻輳崩壊イベントの後に 輻輳ウィンドウ (cwnd) が最小値に永久に固定されてしまうという重大なバグを特定しました。この「デス・スパイラル(死の連鎖)」は、ネットワークが正常な状態に戻った後でも、接続の回復を妨げました。
症状:61% の失敗率
この問題は、最初にイングレスプロキシの統合テストパイプラインで表面化しました。テストでは、高負荷シナリオをシミュレートしました:最初の 2 秒間に 30% のランダムなパケット損失を注入しながら、HTTP/3 を介して 10 MB のファイルをダウンロードします。期待されていた動作は、CUBIC が損失フェーズの間にスロットルダウンし、損失が停止した後に着実にランプアップすることでした。
その代わりに、テストの約 60% が、寛容な 10 秒のタイムアウト内にダウンロードを完了できませんでした。qlog による計測では、驚くべき異常が明らかになりました:T=2s で損失が停止した後、cwnd は最小値の 2,700 バイト(フルサイズのパケット 2 つ分)にロックされたままでした。輻輳状態は、約 14ms ごとに「recovery」と「congestion avoidance」の間で振動していました。これは、接続のラウンドトリップタイム (RTT) とほぼ正確に一致していました。
根本原因の追跡: 「アイドル」の最適化
なぜ CUBIC が増大に失敗するのかを理解するために、チームは 2017 年の Linux カーネルの最適化を振り返りました。CUBIC は、成長曲線を固定するために「epoch」という参照タイムスタンプを使用します。アプリケーションが一定期間アイドル状態(データの送信を停止)になると、epoch から経過した時間が成長し続けます。アプリケーションが epoch を更新せずに送信を再開すると、CUBIC は時間の巨大なギャップを感知し、再開直後に cwnd を不当に大きな値に膨張させようと試みます。
これを修正するために、Linux カーネルは変更を行いました:接続がアイドル状態の後に再開されるとき、epoch はアイドル期間の期間分だけ前方にシフトされます。これにより、送信のギャップを accounted for(考慮に入れ)つつ、成長曲線の形状を維持します。
ポート移植の誤り
このロジックを quiche に移植した際、on_packet_sent() 関数内で実装されました。コードは、接続がアイドル状態であったかどうかを判断するために bytes_in_flight がゼロであるかを確認していました:
if bytes_in_flight == 0 {
let delta = now - self.last_sent_time;
self.congestion_recovery_start_time += delta;
}
この実装には、元の修正の直後に Linux カーネルのフォローアップコミットで対処された、微妙な欠陥が含まれていました。問題は、last_sent_time が、前の RTT サイクルの 開始 時点のタイムスタンプであることです。
cwnd が最小値(パケット 2 つ分)に崩壊したシナリオでは、次のような「デス・スパイラル」が発生します:
- 送信: 送信者は 2 つのパケットケットを送信します。
- ACK の到着: 1 RTT(約 14ms)後、両方のパケットが ACK されます。
bytes_in_flightはゼロに低下します。 - 偽のアイドル検出: 次のバーストが送信されるとき、
on_packet_sent()はbytes_in_flight == 0を検知し、接続がアイドル状態であったと仮定します。 - 膨張した Delta: 彼は「アイドル」期間を
now - last_sent_timeとして計算します。ウィンドウが非常に小さいため、この delta は、接続が実際にはアイドルではなかった(単に輻輳制限されていた)としても、約 1 つの完全な RTT (14ms) となります。 - The Trap (罠): この膨張した delta が
congestion_recovery_start_timeを未来に押し出します。回復開始時間が未来にあるため、システムはまだ回復期間中であると信じ、次の ACK に対してcwndの増大をスキップします。
このループが数千回繰り返され、ランダムなスケジューラのジッターによってサイクルが最終的に打破されるまで、接続を最小ウィンドウに固定してしまいます。
修正方法:正確なアイドル測定
解決策は、単に最後に送信されたパケットの時間に頼るのではなく、実際に接続がアイドル状態になったとき、具体的には、最後に ACK が処理されたときに、接続がいつアイドルになったかを追跡することでした。
last_ack_time タイムスタンプを導入することで、チームは、直近のアクティビティ(最後の ACK または最後の送信、どちらか遅い方)からのアイドル期間を測定できるようになりました。これにより、 delta が小さなウィンドウの RTT ではなく、実際の送信のギャップを反映するようにすることが保証されます。
let idle_start = cmp::max(cubic.last_ack_time, cubic.last_sent_time);
if let Some(idle_start) = idle_start {
if idle_start < now {
let delta = now - idle_start;
r.congestion_recovery_start_time = Some(recovery_start_time + delta);
}
}
この変更の後、テストスイートの合格率は 100% に戻り、cwnd は損失フェーズが終了した直後に期待される CUBIC 曲線に従って増大しました。
エンジニアリングの教訓
この事例は、システムエンジニアリングとプロトコル実装におけるいくつかの重要な教訓を浮き彫りにしています:
- 「盲目的な」移植の危険性: コミュニティの観察者によって指摘されたように、カーネルのロジックを移植する際、その後のバグ修正を完全に追跡せずにコピーすると、修正済みの脆弱性を新しい実装に持ち込む可能性があります。
- コーナーケースのテストの重要性: このバグは、標準的なスループット・ダッシュボードや定常状態のテストでは見視えませんでした。重いパケット損失が発生している状況下で、 CCA を最小
cwnd状態に追い込む意図的な試みによってのみ表面化しました。 - 「アイドル」の定義は容易ではない: 高性能ネットワークにおいて、接続が「アイドル」であることと、接続が「輻輳制限」されていることの差は、数ミリ秒の差ですが、その区別はアルゴリズムの安定性に不可欠です。
quiche のアイドル期間の処理を洗練させることで、Cloudflare は、その QUIC 実装が最も深刻なネットワーク条件から優雅に回復できることを保証し、健全な接続を接続停止状態へと変えてしまうデス・スパイラルを回避できるようにしました。