- Published on
Gunicorn Uvicorn Worker timeout 재현과 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 멀쩡히 떠 있고 CPU도 한가한데, 갑자기 Gunicorn 로그에 WORKER TIMEOUT이 찍히고 요청이 502/504로 터지는 경험은 꽤 흔합니다. 특히 FastAPI/Starlette를 gunicorn -k uvicorn.workers.UvicornWorker로 운영할 때, “응답이 늦어서 타임아웃 난 건지”, “이벤트 루프가 막힌 건지”, “스트리밍(SSE) 때문에 오해한 건지”가 뒤섞여 원인 파악이 어려워집니다.
이 글에서는 Worker timeout을 의도적으로 재현해 증상을 눈으로 확인하고, 원인별로 분리 진단한 뒤, 설정/코드/운영 Best Practice로 확실히 해결하는 방법을 정리합니다.
1) Gunicorn UvicornWorker의 timeout이 의미하는 것
Gunicorn의 timeout은 “클라이언트 응답 시간이 N초를 넘으면 끊는다”가 아닙니다. 기본적으로는 다음 의미에 가깝습니다.
- worker 프로세스가 일정 시간 동안 ‘살아있다’는 신호를 Gunicorn master에 못 보냈다
- 그 결과 master가 worker를 죽이고(restart) 요청을 실패 처리
즉, 느린 요청이 문제일 수도 있지만 더 자주 발생하는 건:
- 이벤트 루프/스레드가 막힘(CPU 바운드, 블로킹 I/O)
- 죽지 않고 멈춘 상태(deadlock, 외부 자원 대기)
- 스트리밍 응답에서 주기적 flush가 없거나 프록시가 끊음(증상 혼동)
운영에서 502/504가 같이 보인다면, Gunicorn 자체 timeout + 앞단 프록시 타임아웃이 함께 얽혔을 가능성이 큽니다. (프록시/스트리밍 이슈는 이 글의 범위를 넘어가지만, 관련해서는 Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김을 끝내는 NGINX Ingress와 Gunicorn Uvicorn 실전 튜닝도 같이 보시면 원인 분리가 빨라집니다.)
2) 로컬에서 100% 재현하기
재현이 되면 해결은 절반입니다. 아래는 FastAPI 기준으로 worker timeout을 확실히 유발하는 최소 예제입니다.
2.1 프로젝트 준비
python -m venv .venv
source .venv/bin/activate
pip install fastapi uvicorn gunicorn
app.py:
from fastapi import FastAPI
import time
app = FastAPI()
@app.get("/block")
def block_cpu_or_io():
# 의도적으로 이벤트 루프/워커를 오래 붙잡는 블로킹 작업
time.sleep(60)
return {"ok": True}
2.2 Gunicorn으로 실행 (timeout을 짧게)
gunicorn app:app \
-k uvicorn.workers.UvicornWorker \
-w 1 \
-b 0.0.0.0:8000 \
--timeout 5 \
--log-level info
다른 터미널에서 호출:
curl -v http://127.0.0.1:8000/block
몇 초 내로 Gunicorn 로그에서 대개 아래와 유사한 메시지를 보게 됩니다.
WORKER TIMEOUT (pid: ...)Worker exiting (pid: ...)
이 재현 케이스의 핵심은 time.sleep() 같은 블로킹 호출이 워커를 붙잡아 heartbeat를 못 보내게 만든다는 점입니다.
3) 원인 분류: “느린 요청” vs “워커가 멈춤”
현업에서는 단순히 “timeout을 늘리자”로 끝내면 재발합니다. 아래 체크로 원인을 분류하세요.
3.1 블로킹 I/O가 이벤트 루프를 막는 경우
FastAPI 엔드포인트가 async def라 해도 내부에서 다음을 하면 이벤트 루프가 멈춥니다.
time.sleep()requests.get()같은 동기 HTTP- 동기 DB 드라이버 호출
- 대용량 파일 읽기/압축/암호화 같은 CPU 작업
해결 방향
- 동기 작업을
asyncio.to_thread()로 넘기거나 - 완전한 async 라이브러리로 교체하거나
- 아예 백그라운드 잡(Celery/RQ/Arq)으로 분리
예시(동기 함수 to_thread로 분리):
import time
import asyncio
from fastapi import FastAPI
app = FastAPI()
def slow_sync():
time.sleep(60)
return "done"
@app.get("/fixed")
async def fixed():
result = await asyncio.to_thread(slow_sync)
return {"result": result}
> 주의: to_thread()는 “워커가 죽지 않게” 만들 수는 있지만, 요청이 오래 걸리는 문제 자체를 해결하진 않습니다. 오래 걸리는 작업은 큐로 빼는 게 정석입니다.
3.2 CPU 바운드가 워커를 장시간 점유하는 경우
- PDF 파싱/텍스트 추출
- 이미지 리사이즈/인코딩
- 대규모 JSON 직렬화
- 토큰화/임베딩 전처리
해결 방향
- 워커 수 늘리기(단, CPU 코어 고려)
- CPU 바운드는 프로세스 풀/별도 워커 서비스로 분리
- 요청 단에서 제한(파일 크기/페이지 수)
3.3 외부 자원 대기(Deadlock/커넥션 고갈/느린 DNS)
이 경우는 “코드는 블로킹 안 하는데도” timeout이 납니다.
대표 시나리오:
- DB connection pool 고갈로 대기
- 외부 API가 무한 대기(타임아웃 미설정)
- DNS 이슈로 소켓 연결이 지연
해결 방향
- 모든 외부 호출에 connect timeout + read timeout을 강제
- DB pool/프록시(RDS Proxy/pgBouncer 등) 설계 점검
4) Gunicorn 설정으로 해결하는 법 (하지만 과신 금지)
4.1 timeout을 올리기 전에 확인할 것
--timeout을 늘리면 “진짜로 오래 걸리는 요청”은 살아남지만, 멈춘 워커를 늦게 발견하게 되어 장애가 길어질 수 있습니다.
따라서 timeout 증상은 보통 아래 순서로 접근합니다.
- 블로킹/CPU 바운드 제거(또는 분리)
- 외부 호출 타임아웃 설정
- 그래도 필요한 경우에만
--timeout상향
4.2 추천 베이스라인 예시
gunicorn app:app \
-k uvicorn.workers.UvicornWorker \
-w 4 \
-b 0.0.0.0:8000 \
--timeout 30 \
--graceful-timeout 30 \
--keep-alive 5 \
--max-requests 2000 \
--max-requests-jitter 200 \
--log-level info
--max-requests/--jitter: 메모리 누수/조각화가 있는 서비스에서 “가끔 멈춤”을 완화하는 데 도움이 됩니다.--graceful-timeout: 종료 신호 후 정리 시간을 줘서 롤링 업데이트 시 끊김을 줄입니다.
5) 스트리밍(SSE)에서 timeout이 “오해”되는 케이스
LLM SSE 스트리밍을 할 때 다음이 섞여 장애처럼 보입니다.
- Gunicorn worker timeout
- NGINX/ALB/Cloudflare의 idle timeout
- 프록시 버퍼링으로 인해 “서버는 보내는데 클라이언트가 못 받음”
스트리밍에서는 **주기적으로 데이터를 흘려 보내는 것(heartbeat)**과 프록시 버퍼링 해제가 핵심입니다. 이 주제는 별도 최적화 포인트가 많아서, 아래 글을 같이 참고하는 게 빠릅니다.
- LLM SSE 스트리밍 499 502 급증과 응답 끊김을 잡는 프록시 튜닝 체크리스트
- FastAPI Uvicorn에서 SSE 웹소켓 LLM 스트리밍이 프록시 뒤에서 끊길 때 Cloudflare Nginx ALB 버퍼 타임아웃 gzip으로 EventSource failed 100% 재현 해결 체크리스트
6) 트러블슈팅 체크리스트 (현업용)
6.1 로그로 “진짜 worker timeout”인지 확인
- Gunicorn master 로그에
WORKER TIMEOUT이 찍히는가? - 같은 시각에 worker가 재시작되는가?
- 앞단 프록시(nginx/ingress/alb) 로그의 504와 시간대가 일치하는가?
6.2 코드 레벨 점검
async def내부에 동기 라이브러리 호출이 있는가?- 외부 HTTP 호출에 timeout이 명시되어 있는가?
- DB pool이 고갈될 수 있는 구조인가(세션 반환 누락 등)?
6.3 이벤트 루프 관련 경고 확인
간헐적으로 이벤트 루프가 꼬이면 종료/재기동 시 다음 류의 경고가 동반됩니다.
Task was destroyed but it is pending!Event loop is closed
이런 경고가 같이 보인다면 “timeout”은 결과일 뿐이고, 종료/취소 처리나 루프 정책 문제일 수 있습니다. 필요하면 아래도 함께 점검하세요.
7) Best Practice: timeout을 ‘늘리는’ 대신 구조를 바꾸기
7.1 오래 걸리는 작업은 요청-응답 경로에서 분리
- 업로드 파일 처리
- 배치성 크롤링/정제
- 임베딩 생성/대규모 인덱싱
이런 작업을 HTTP 요청에서 끝내려 하면, timeout을 키워도 결국 동시성/리소스 경합에서 터집니다.
권장 패턴:
- 요청은 즉시
job_id를 반환 - 백그라운드 워커가 처리
- 클라이언트는 폴링 또는 SSE/WebSocket으로 진행률 구독
7.2 외부 호출은 반드시 “2단 타임아웃”
HTTP 클라이언트(예: httpx) 기준으로:
- connect timeout (연결 실패를 빨리 감지)
- read timeout (상대가 응답을 안 주는 상황 차단)
이 원칙 하나만 지켜도 “가끔 워커가 멈춘다” 류의 장애가 크게 줄어듭니다.
7.3 관측 가능성(Observability)을 최소 세트로 갖추기
- 요청별 처리 시간(라우트 단위)
- 외부 의존성별 latency/timeout 카운트
- 워커 재시작 횟수
이 3가지만 있어도 “timeout을 늘릴지, 코드를 고칠지” 판단이 빨라집니다.
결론
Gunicorn+UvicornWorker의 WORKER TIMEOUT은 단순히 “요청이 오래 걸렸다”가 아니라, 워커가 일정 시간 동안 진행/heartbeat를 못 한 상태를 의미하는 경우가 많습니다.
- 먼저
time.sleep, 동기 HTTP/DB, CPU 바운드 같은 블로킹 원인을 제거/분리하고 - 모든 외부 호출에 connect/read timeout을 강제한 뒤
- 마지막 수단으로 Gunicorn
--timeout을 조정하세요.
지금 운영 중인 서비스에서 동일 증상이 있다면, 이 글의 재현 예제로 먼저 “내 시스템도 같은 방식으로 죽는지” 확인하고, 체크리스트대로 원인을 분류해 timeout을 키우는 땜질이 아니라 재발 방지 구조로 바꿔보세요.