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