Published on

Gunicorn Uvicorn Worker timeout 재현과 해결

Authors

서버가 멀쩡히 떠 있고 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 증상은 보통 아래 순서로 접근합니다.

  1. 블로킹/CPU 바운드 제거(또는 분리)
  2. 외부 호출 타임아웃 설정
  3. 그래도 필요한 경우에만 --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)**과 프록시 버퍼링 해제가 핵심입니다. 이 주제는 별도 최적화 포인트가 많아서, 아래 글을 같이 참고하는 게 빠릅니다.

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을 키우는 땜질이 아니라 재발 방지 구조로 바꿔보세요.