'Idle'이 'Idle'이 아닐 때: QUIC 혼잡 데스 스파이럴(Death Spiral) 해결하기

혼잡 제어 알고리즘(CCAs)은 인터넷의 보이지 않는 조절자로, 송신자가 네트워크 붕괴를 일으키지 않으면서 얼마나 많은 데이터를 밀어 넣을 수 있는지를 결정합니다. Linux 커널의 기본 혼잡 제어기인 CUBIC은 용량을 탐색하고 손실이 감지되면 속도를 늦춤으로써 대역폭 활용도를 극대화하도록 설계되었습니다. 그러나 최근 커널 수준의 최적화와 QUIC의 사용자 공간(user-space) 특성 사이의 미묘한 상호작용으로 인해 Cloudflare의 오픈 소스 QUIC 구현체인 quiche에서 "데스 스파이럴(death spiral)" 현상이 발생했습니다.

이 버그는 표준 처리량 대시보드나 정적 검토에서는 나타나지 않았습니다. 대신, 특정하고 스트레스가 높은 코너 케이스(corner case)에서 나타났습니다. 바로 연결 초기 단계에서 심각한 패킷 손실을 경험하는 경우입니다. 그 결과, 네트워크가 정상 상태로 돌아왔음에도 불구하고 연결이 최소 혼잡 윈도우(congestion window)에 갇혀 회복하지 못하는 현상이 발생했습니다.

증상: 61%의 실패율

Cloudflare의 조사는 그들의 인그레스 프록시(ingress proxy) 통합 테스트가 불규칙하게 실패하기 시작하면서 시작되었습니다. 테스트 시나리오는 CUBIC의 회복 단계를 스트레스 테스트하도록 설계되었습니다:

  • 설정: CUBIC을 사용하는 HTTP/3를 통한 10 MB 파일 다운로드.
  • 스트레스: 처음 2초 동안 30%의 무작위 패킷 손실 주입.
  • 기대 결과: T=2s에서 손실이 중단된 후, CUBIC은 혼잡 윈도우(cwnd)를 늘려야 하며, 넉넉한 10초 타임아웃 내에 다운로드를 완료해야 합니다.

놀랍게도, 이러한 테스트 중 약 60%가 다운로드를 완료하지 못했습니다. qlog를 통한 계측(instrumentation) 결과 기이한 이상 현상이 드러났습니다. 패킷 손실이 중단된 후에도 cwnd가 최소 바닥(2700 bytes, 또는 전체 크기의 패킷 2개)에 고정되어 있었습니다. 연결은 약 14ms마다—연결의 왕복 시간(RTT)과 거의 정확히 일치하게—"혼잡 회피"와 "회복" 상태 사이를 빠르게 진동했습니다.

근본 원인 추적: "Idle" 최적화

CUBIC이 왜 성장하지 못하는지 이해하기 위해, 팀은 2017년 Linux 커널 최적화를 살펴보았습니다. CUBIC은 성장을 위한 기준점(anchor)으로 epoch를 사용합니다. 만약 애플리케이션이 장기간 유휴 상태(idle)가 되면(데이터 전송을 중단하면), 마지막 epoch 이후 경과된 시간이 매우 커집니다. 애플리케이션이 다시 시작될 때, 단순한 CUBIC 구현체는 이 거대한 시간 차이를 보고 즉시 cwnd를 비합리적인 값으로 팽창시킬 것입니다.

이를 해결하기 위해 Linux 커널은 유휴 기간의 지속 시간만큼 epoch를 앞으로 밀어내는 메커니즘을 구현했습니다. 이는 성장 곡선을 시간상으로 "슬라이딩"하여 알고리즘이 중단된 지점부터 다시 시작할 수 있게 합니다.

사용자 공간 QUIC으로의 포팅

Cloudflare가 CUBIC을 quiche로 포팅할 때, 이 유휴 기간 조정 기능을 구현했습니다. 그러나 전송 시작을 위한 특정 콜백(callback)이 있는 Linux 커널과 달리, quicheon_packet_sent() 함수 내부에서 유휴 상태를 확인합니다:

if bytes_in_flight == 0 {
    let delta = now - self.last_sent_time;
    self.congestion_recovery_start_time += delta;
}

이 로직은 bytes_in_flight가 0이라면 연결이 유휴 상태였을 것이라고 가정합니다. 하지만 특정 상황—즉, cwnd가 절대 최소치에 있을 때—이 가정은 실패합니다.

데스 스파이럴(Death Spiral)

혼잡 윈도우가 패킷 2개 분량에 고정되어 있을 때, 자기 유지적인 루프가 발생합니다:

  1. 배출(Drain): 송신자는 패킷 2개를 전송합니다. 한 RTT 후에, 두 패킷 모두 ACK를 받습니다. bytes_in_flight는 0으로 떨어집니다.
  2. 잘못된 유휴 상태 감지: 송신자는 다음 버스트를 전송합니다. on_packet_sent()bytes_in_flight == 0을 보고 연결이 유휴 상태였다고 가정합니다.
  3. 팽창된 델타(Delta): 코드는 유휴 기간을 now - last_sent_time으로 계산합니다. 윈도우 크기가 매우 작기 때문에, last_sent_time은 이전 RTT 사이클의 시작점이었습니다. 결과적으로 델타는 실제 유휴 시간(마지막 ACK와 다음 전송 사이의 간격)이 거의 0에 가까움에도 불구하고, 약 한 RTT(~14ms)가 됩니다.
  4. 미래의 Epoch 설정: 이 팽창된 델타는 congestion_recovery_start_time을 앞으로 밀어내며, 종종 미래의 시간으로 밀어넣습니다.
  5. **정체체(Stagnation): CUBIC은 자신이 회복 기간에 있다고 인식하는 모든 패킷에 대해 cwnd 성장을 스킵합니다. 회복 시작 시간이 현재보다 미래라면, 알고리즘은 "회복" 모드에 상태를 유지하며, 윈도우를 패킷 2개로 유지하고 다음 ACK가 올 때마다 파이프라인을 완전히 비워게 만듭니다.

이 사이클은 스케줄러 지터(jitter)나 ACK 변동성 때문에 경계가 전송 시간 뒤로 밀려나 루프가 깨질 때까지 수천 번 반복됩니다.

해결책: 정밀한 유휴 상태 측정

해결책은 마지막으로 전송된 패킷에만 의존하는 대신, 파이프라인이 실제로 비워진 시점—즉, 마지막으로 처리된 ACK의 시점부터 유휴 상태를 측정하는 것이었습니다.

last_ack_time 타임스탬프를 도입함으로써, 팀은 마지막 ACK 시간과 마지막 전송 시간 중 더 큰 값을 사용하여 유휴 델타를 계산할 수 있게 되었습니다. 이는 델타가 작은 윈도우로 인한 전체 RTT가 아닌, 실제 활동의 간격(gap)을 반영하도록 보장합니다.

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);
    }
}

엔지니어링 인사이트

이 사건은 시스템 엔지니어링에서 몇 가지 중요한 교훈훈을 제공합니다:

  • 맥락 없는 포팅의 위험성: 커뮤니티 관찰자들의 언급처럼, 서로 다른 이벤트 모델(예: CA_EVENT_TX_START 콜백의 부재)을 고려하지 않고 커널 로직을 포기하는 것은 미묘한 버그를 유생할 수 있습니다.

  • 코너 케이스 테스트의 가치: 이 버그는 트래픽의 99%에서 보이지 않았습니다. 오직 CCA를 최소 윈도우 상태로 몰아넣고 다시 상승할 수 있는지 관찰하기 위해 설계된 의도적인 테스트 덕분에 발견되었습니다.

  • 복잡성 vs. 해결책: 버그를 찾는 데 드는 노력은 엄청났습니다—수주간의 계측과 시각화가 필요했습니다—하지만 해결책은 단순한 세 줄 줄의 로직 변경이었습니다. 이는 비결정론적 성능 저하를 디버깅할 때 "엔지니어링 끈기(engineering grit)"의 중요성을 강조합니다.

Sources