생전 처음 들어본 Slowloris 이야기
리소스는 멀쩡한데 웹 페이지만 안 열리는 장애. 단 4개의 미완성 TCP 연결이 서비스 전체를 마비시킨 Slowloris 이야기.
들어가며
운영 중인 서비스에서 몇 달에 한 번씩, 예고도 없이 웹 페이지가 안 열리는 장애가 있었다. 컨테이너를 재시작하면 멀쩡히 돌아왔기 때문에 그동안은 "재기동"으로 덮어왔지만, 원인을 특정하지 못한 장애는 반드시 다시 돌아온다.
이 글은 그 장애의 정체가 Slowloris였음을 밝혀내기까지의 디버깅 과정 기록이다. 결론부터 말하면, 단 4개의 미완성 TCP 연결이 서비스 전체를 멈춰 세우고 있었다.
1. 장애 상황
2026년 4월 11일 오전, 현장에서 로봇을 사용할 수 없다는 문의가 들어왔다. 곧바로 웹 워치(watch) 페이지 접속을 시도했지만 페이지가 로딩되지 않았다. 이 현상은 이전에도 간헐적으로 있었고, 로깅을 붙여봤지만 직접적인 원인까지는 잡지 못한 채였다.
일단 서비스를 살려야 했기에 기존 컨테이너를 종료하고 동일 이미지로 새 컨테이너를 띄웠다. 접속은 즉시 복구됐다. 하지만 "재기동하면 낫는다"는 건 원인이 아니라 증상일 뿐이다.
2. 컨테이너는 놀라울 만큼 멀쩡했다
가장 먼저 의심한 건 Flask 애플리케이션 자체의 이상이었다. 컨테이너 상태부터 확인했다.
$ docker stats app-server-production-2
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % PIDS
568800588c86 app-server-production-2 0.08% 53.68MiB / 961.4MiB 5.58% 9- CPU: 0.08%
- 메모리: 53.68MiB / 961.4MiB (5.58%)
- PIDS: 9
리소스 관점에서는 완벽하게 건강했다. 메모리 릭도, CPU 폭주도 없었다. 프로세스 상태와 로그도 봤다.
$ docker top app-server-production-2
... gunicorn -b 0.0.0.0:5000 --workers 1 --threads 4 run:app[2026-04-11 00:13:36][INFO] [RES] GET /api/v1/.../state/total_state 200 OK 1.82ms
[2026-04-11 00:13:37][INFO] [RES] GET /api/v1/.../state/monitor_topic 200 OK 1.59ms
로그를 보면 API 요청이 1~2ms 만에 200 OK로 처리되고 있었다. 서버는 요청이 도달하기만 하면 즉시 정상 응답하고 있었던 것이다.
여기서 문제의 성격이 명확해졌다. 애플리케이션 내부는 정상이다. 그렇다면 문제는 "요청이 서버까지 도달하지 못하는" 연결 계층에 있다. 리소스 지표가 멀쩡하다는 사실 자체가, 원인을 앱 바깥으로 좁혀주는 결정적인 단서였다.
3. AI의 그럴듯한 오답
로그와 컨테이너 상태를 정리해 AI에게 원인 분석을 요청했다. 돌아온 답변의 "가장 유력한 원인"은 이것이었다.
원인 A: SSL 인증서 갱신 누락 (Let's Encrypt 90일 이슈) — 🚨 가장 유력 Let's Encrypt 인증서는 유효기간이 90일이라, 자동 갱신 후 프록시를 reload 하지 않으면 만료된 인증서를 계속 제공하게 된다…
그럴듯했다. "2~3개월 주기"라는 힌트와 "90일"이라는 숫자가 절묘하게 맞아떨어졌으니까. 하지만 이 가설에는 치명적인 전제가 있었다. Nginx 같은 프록시와 HTTPS가 존재한다는 것.
우리 구조는 그렇지 않았다. Gunicorn을 프록시 없이 HTTP 포트로 브라우저에 직접 노출하고 있었다. SSL 인증서라는 개념 자체가 성립하지 않는 환경이었다.
이 지점이 중요하다. AI는 로그에 드러나지 않은 인프라 구조를 알 수 없기 때문에, 통계적으로 흔한 시나리오(SSL 만료)를 자신 있게 제시했다. 그럴듯한 답을 걸러내는 건 결국 실제 배포 구조를 아는 사람의 몫이다. "Nginx 없이 HTTP 직결"이라는 한 줄의 사실이 최유력 가설을 통째로 폐기시켰다.
4. 내 가설도 데이터로 기각했다
AI가 부가 발견으로 지적한 것 중 하나는 웹소켓 브릿지 코드의 ws.close() 누락이었다. 연결 실패 시 데몬 스레드를 정리하지 않아 좀비 스레드가 쌓일 수 있다는 것.
그래서 나는 이런 가설을 세웠다.
"ws.close()를 안 해서 커넥션 풀이 고갈됐고, 그래서 내부 컨테이너는 정상인데 새 연결을 못 받는 것 아닐까?"
이 코드 결함 자체는 실재했고 패치했다. 하지만 이번 장애의 원인은 아니었다. 근거는 다시 PIDS: 9였다.
만약 3월 4일부터 한 달 넘게 스레드가 새고 있었다면, PIDS는 수십, 수백 단위로 폭증했을 것이다. 그런데 여전히 9개다. 즉, 그동안 스레드 누수는 사실상 한 건도 없었다. 내가 세운 그럴듯한 가설을, 내가 이미 수집한 데이터가 반증한 것이다.
자기 가설에 애착을 갖는 순간 디버깅은 산으로 간다. 가설은 데이터로 검증하거나 기각해야 하고, 이번엔 기각이 맞았다.
5. 진짜 원인: Slowloris
SSL도 아니고 스레드 누수도 아니라면, "리소스는 멀쩡한데 새 요청을 못 받는" 상태를 만들 수 있는 건 무엇인가. 답은 Gunicorn의 스레드가 전부 붙잡혀 있는 상태였다.
핵심은 HTTP와 TCP의 동작 방식에 있다.
- HTTP 요청은 TCP 연결을 맺은 뒤, 요청의 끝을 알리는 종료 기호(
\r\n\r\n)가 도착할 때까지 기다린다. - 그런데 클라이언트가
\r\n까지만 보내고 종료 기호를 보내지 않으면, 서버는 "아직 요청이 안 끝났구나" 하고 그 연결을 계속 붙잡은 채 대기한다. - 현재 설정은
--workers 1 --threads 4. 이런 미완성 연결 4개만 들어오면, 4개 스레드가 전부 그 연결에 묶인다. - 그 순간부터 정상 사용자의 요청은 처리할 스레드가 없어 무한 로딩에 빠진다.
미완성 연결이 [1, 3, 6, 10] 시점에 하나씩 들어온다고 하면, 스레드는 이렇게 하나씩 잠식된다.
| 시간 | 1 | 3 | 6 | 10 |
|---|---|---|---|---|
| 스레드1 | 점유 | 점유 | 점유 | 점유 |
| 스레드2 | - | 점유 | 점유 | 점유 |
| 스레드3 | - | - | 점유 | 점유 |
| 스레드4 | - | - | - | 점유 |
이것이 Slowloris다. 이름은 공격 기법에서 왔지만, 반드시 악의적 공격일 필요는 없다. 모바일 사용자의 네트워크가 갑자기 느려지거나, 브라우저 탭을 켜둔 채 화면이 꺼져 TCP 소켓이 어정쩡하게 굳어버리는 것 — 이런 일상적인 상황만으로도 미완성 연결은 만들어진다. 평소엔 API가 1 ~ 2ms로 워낙 빨라 문제없이 지나가다가, 2 ~ 3개월에 한 번씩 이런 연결들이 우연히 겹치면 4칸이 모두 막히는 "퍼펙트 스톰"이 발생한다.
Nginx 없이 Gunicorn을 인터넷에 직접 노출하지 말라는 공식 문서의 경고가, 바로 이 시나리오를 막기 위한 것이었다.
6. 가설을 실제로 재현하다
가설은 재현으로 증명해야 한다. 종료 기호를 보내지 않고 가짜 헤더만 찔끔찔끔 흘려보내는 클라이언트를 4개 띄우는 스크립트를 작성했다.
import socket, time, threading
TARGET_HOST = "example-api.com"
TARGET_PORT = 80
TARGET_PATH = "/web-watch"
NUM_CONNECTIONS = 4 # threads 4 → 4개만 잡아도 마비
stop_event = threading.Event()
def slow_client(client_id):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(10)
s.connect((TARGET_HOST, TARGET_PORT))
# 요청 시작만 보내고, 종료 기호(\r\n\r\n)는 절대 보내지 않는다
s.send(f"GET {TARGET_PATH} HTTP/1.1\r\n".encode())
s.send(f"Host: {TARGET_HOST}\r\n".encode())
# 5초마다 의미 없는 헤더를 흘려 "아직 전송 중"인 척한다
for i in range(100):
if stop_event.is_set():
break
s.send(f"X-Fake-Header-{i}: hold\r\n".encode())
for _ in range(5):
if stop_event.is_set():
break
time.sleep(1)
s.close()
# 4개 스레드를 띄우면 서버의 4칸이 모두 점유된다이 스크립트를 로컬 환경에 돌리자, 정상 요청이 그대로 막혔다. 프로덕션에서 관찰된 현상과 정확히 일치했다. 가설이 실증된 것이다.
7. 재발을 잡기 위한 관측 장치
로컬 재현만으로 프로덕션의 원인을 100% 단정할 수는 없다. 그래서 프로덕션에서 같은 현상이 실제로 일어나는지 확인할 수 있도록 관측 장치를 심었다.
- 30초 주기로 백그라운드에서 TCP 연결 개수를 확인한다.
- 연결이 스레드 수(4)를 초과하면, 스레드 고갈 의심 상황으로 보고
ss명령으로 상세 정보를 남긴다. - 어떤 IP:Port가 연결을 붙잡고 있는지 로그로 기록한다.
- (
ss사용을 위해 Dockerfile에iproute2패키지 설치를 추가했다.)
이제 다음에 장애가 재발하면, 추측이 아니라 로그로 "누가 몇 개의 연결을 얼마나 오래 붙잡고 있었는지"를 확인할 수 있다.
8. 정리와 다음 단계
이번 장애를 통해 얻은 것을 정리하면 이렇다.
- 리소스 지표의 정상은 그 자체로 단서다. CPU·메모리·PIDS가 멀쩡하다는 사실이 원인을 애플리케이션 바깥으로 좁혀줬다.
- AI의 그럴듯한 답은 도메인 지식으로 검증해야 한다. "SSL 만료"라는 최유력 가설은 우리 인프라 구조를 아는 순간 무너졌다.
- 내 가설도 예외 없이 데이터로 검증한다. 스레드 누수 가설은
PIDS: 9하나로 기각됐다. - 가설은 재현으로 증명한다. 로컬 재현과 프로덕션 관측 로깅으로 심증을 물증에 가깝게 만들었다.
근본적인 해결책은 명확하다. Gunicorn 앞에 Nginx 리버스 프록시를 두어 느린 연결을 완충 계층에서 흡수하게 만드는 것이다. Nginx는 수많은 느린 연결을 대신 받아뒀다가, 완성된 요청만 Gunicorn에 넘긴다. 그러면 미완성 연결 몇 개가 애플리케이션 스레드를 직접 잠그는 일은 사라진다.
원인을 특정하지 못한 장애는 반드시 돌아온다. 하지만 정체를 알아낸 장애는, 이제 막을 수 있는 문제가 된다.