502가 매일 돌아왔다 — 범인은 QUIC이었다

또 502입니다.

지난달에도 502를 잡았습니다. 터널 재시작하면 낫고, 다음 날 또 죽고. “간헐적 오류”라고 넘기기엔 패턴이 너무 정확했습니다. 매일 같은 시간에 터졌습니다.

이번에는 재시작만 하고 넘어가지 않기로 했습니다.


증상: 어제와 똑같은 502

exam.florentyoon.com에 접속하면 10번 중 5~7번이 502입니다.

$ for i in $(seq 1 5); do
    curl -s -o /dev/null -w "%{http_code}" https://exam.florentyoon.com
  done
502 502 502 502 502

컨테이너는 전부 정상입니다. docker ps에 빨간불 하나 없습니다.

cloudflare-tunnel   Up 24 hours
caddy               Up 25 hours
exam-app            Up 30 hours

내부 통신도 완벽합니다.

# Caddy → exam-app 직접 테스트
$ docker exec caddy wget -qO- http://192.168.107.7:3000 | head -1
<!doctype html>   ← 정상

문제는 밖에서 안으로 들어오는 구간입니다.


단서: via 헤더가 없다

정상 응답과 실패 응답의 헤더를 비교했습니다.

200 OK (정상):

HTTP/2 200
server: cloudflare
via: 1.1 Caddy     ← Caddy를 거쳐옴

502 Bad Gateway (실패):

HTTP/2 502
server: cloudflare
content-length: 15  ← "error code: 502"
                    ← via 헤더 없음!

via: 1.1 Caddy가 없다는 건, 요청이 Caddy까지 도달하지 못했다는 뜻입니다. Cloudflare Edge에서 터널로 보내려다 실패하고, 바로 502를 뱉은 겁니다.


결정적 증거: 24시간 주기의 타임아웃

터널 로그를 뒤졌습니다. 그리고 찾았습니다.

2026-03-01T13:59:28Z ERR failed to accept QUIC stream:
    timeout: no recent network activity

2026-03-02T13:59:38Z ERR failed to accept QUIC stream:
    timeout: no recent network activity

정확히 24시간 간격.

그리고 터널 시작 로그를 보니:

INF Initial protocol quic
INF Registered tunnel connection connIndex=0 ... protocol=quic
INF Registered tunnel connection connIndex=1 ... protocol=quic
INF Registered tunnel connection connIndex=2 ... protocol=quic
INF Registered tunnel connection connIndex=3 ... protocol=quic

프로토콜이 QUIC이었습니다.


왜 QUIC이 문제인가

Cloudflare Tunnel은 Cloudflare Edge 서버와 4개의 연결을 유지합니다. 이 연결을 통해 외부 요청이 내 서버로 전달됩니다.

기본 프로토콜은 QUIC(UDP 기반)입니다. QUIC은 빠르지만, TCP와 달리 유휴 연결 감지가 약합니다.

QUIC (UDP)                    HTTP/2 (TCP)
─────────────────            ─────────────────
빠른 핸드셰이크 ✓             TCP 3-way handshake
유휴 감지 약함 ✗              TCP keepalive ✓
NAT 테이블 만료에 취약 ✗      안정적인 장기 연결 ✓

트래픽이 적은 개인 서버에서는 연결이 오랜 시간 유휴 상태가 됩니다. 약 24시간이 지나면 QUIC 연결이 조용히 죽습니다. 그런데 cloudflared는 이걸 모릅니다. 로그에는 여전히 “Registered”로 표시됩니다.

Cloudflare Edge가 요청을 보낼 때:

  • 살아있는 연결로 가면 → 200 OK
  • 죽은 연결로 가면 → 502 Bad Gateway

4개 연결 중 몇 개가 죽었느냐에 따라 실패율이 달라집니다. 그래서 “간헐적”이었던 겁니다.


해결: QUIC → HTTP/2

docker-compose.yml에 딱 한 단어를 추가했습니다.

# Before
command: tunnel run --token <TOKEN>

# After
command: tunnel run --protocol http2 --token <TOKEN>

HTTP/2는 TCP 기반이라 keepalive가 동작합니다. 유휴 상태에서도 연결이 살아있는지 주기적으로 확인합니다.

적용 후 프로토콜이 바뀐 것을 확인:

INF Initial protocol http2
INF Registered tunnel connection connIndex=0 ... protocol=http2
INF Registered tunnel connection connIndex=1 ... protocol=http2
INF Registered tunnel connection connIndex=2 ... protocol=http2
INF Registered tunnel connection connIndex=3 ... protocol=http2

테스트 결과:

exam.florentyoon.com:     10/10 성공
laundry.florentyoon.com:  10/10 성공
yndl.dev:                 10/10 성공

보험: 자동 복구 스크립트

HTTP/2로 바꿨지만, 만약을 위해 5분마다 도메인을 체크하는 스크립트도 만들었습니다.

#!/bin/bash
# scripts/check-tunnel.sh (cron: */5 * * * *)

DOMAINS=("exam.florentyoon.com" "laundry.florentyoon.com" "yndl.dev")

for domain in "${DOMAINS[@]}"; do
  fails=0
  for i in $(seq 1 5); do
    code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "https://$domain")
    [ "$code" -ge 500 ] && fails=$((fails + 1))
  done

  if [ "$fails" -ge 3 ]; then
    echo "$(date) [ALERT] $domain - ${fails}/5 failed" >> logs/tunnel-monitor.log
    docker restart cloudflare-tunnel
    break
  fi
done

5회 요청 중 3회 이상 5xx면 터널을 자동 재시작합니다.


배운 것

  1. QUIC이 항상 좋은 건 아닙니다. 트래픽이 적은 서버에서는 HTTP/2가 더 안정적입니다. QUIC의 빠른 핸드셰이크는 이미 터널이 연결된 상태에서는 의미가 없습니다.
  2. “간헐적” 오류에는 패턴이 있습니다. 로그의 타임스탬프를 비교하면 보입니다. 이번엔 정확히 24시간이었습니다.
  3. via 헤더는 좋은 진단 도구입니다. 502가 어디서 발생했는지 즉시 알 수 있습니다. via 없으면 터널 문제, 있으면 백엔드 문제.
  4. docker restart vs docker compose up -d는 다릅니다. restart는 같은 컨테이너를 재시작하고, compose up은 새 컨테이너를 만듭니다. 새 컨테이너는 새 Connector ID를 받기 때문에, 구 커넥터 정리까지 일시적 502가 발생할 수 있습니다. 긴급 복구 시에는 docker restart를 먼저 쓰세요.

502 에러 3연전 시리즈가 되어가고 있습니다. 다음편은 없길 바랍니다.

댓글 남기기