- Published on
Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김을 끝내는 NGINX Ingress와 Gunicorn Uvicorn 실전 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 중인 LLM API가 평소엔 잘 되다가, 트래픽이 몰리거나 특정 길이의 스트리밍 응답에서만 간헐적으로 502/504가 뜨는 경우가 있습니다. 더 골치 아픈 건 “요청이 실패하진 않는데 스트리밍이 중간에 끊기는” 케이스죠. 로그는 애매하고, 재현도 어렵고, 사용자는 "답변이 끊겼다"고만 말합니다.
이 글은 이런 장애를 Kubernetes + NGINX Ingress + (Gunicorn/Uvicorn) 기반 Python LLM 서버에서 실제로 많이 만나는 원인으로 쪼개고, Ingress(프록시) 레이어와 앱 서버 레이어를 같이 튜닝해서 502/504와 스트리밍 끊김을 동시에 잡는 실전 가이드입니다.
1) 증상별로 원인 레이어를 먼저 분리하자
LLM 서비스의 502/504는 대개 아래 3개 레이어 중 하나(혹은 복합)에서 발생합니다.
(A) Ingress/프록시 레이어
- 504 Gateway Timeout: Ingress가 업스트림(앱)에서 응답을 제때 못 받음
- 502 Bad Gateway: 업스트림 연결이 끊기거나, 프로토콜 오류(특히 gRPC), keepalive 문제
- 스트리밍 중간 끊김:
proxy_read_timeout/grpc_read_timeout이 스트리밍 특성과 불일치하거나, keepalive/버퍼링 설정이 부적절
(B) 앱 서버 레이어(Gunicorn/Uvicorn)
- worker가 부족/과도, event loop block, max requests 미설정으로 메모리 누수 누적
- graceful shutdown 미구현/설정 부족으로 롤링 업데이트 중 스트리밍이 끊김
- preload/초기화 비용이 커서 readiness 전에 트래픽 유입 → 순간 502
(C) 모델/의존 리소스 레이어(GPU/외부 API/DB)
- GPU OOM/컨텍스트 스위칭, 외부 API rate limit, DB 커넥션 고갈 등
외부 API 호출이 섞인 LLM 파이프라인이라면, 장애가 502/504로 “보이기만” 하고 실제로는 rate limit이 뿌리인 경우도 많습니다. 이 경우는 OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기도 같이 점검하세요.
2) NGINX Ingress에서 502/504를 유발하는 대표 설정 5가지
아래는 LLM 스트리밍/장시간 요청에서 빈번한 설정 포인트입니다. (예시는 NGINX Ingress Controller 기준)
2.1 proxy_read_timeout/proxy_send_timeout을 “스트리밍” 기준으로 잡기
스트리밍은 “전체 응답이 오래 걸리는 것”보다 더 중요한 특징이 있습니다.
- 마지막 바이트 이후 다음 바이트까지의 간격이 timeout을 넘으면 끊깁니다.
- 모델이 잠깐 멈추거나(배치/큐잉), tool 호출/검색 단계가 길어지면 chunk 간 간격이 벌어질 수 있습니다.
Ingress annotation 예시:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: llm-api
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "10"
spec:
ingressClassName: nginx
rules:
- host: llm.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: llm-svc
port:
number: 8000
proxy-read-timeout: 업스트림에서 다음 바이트를 기다리는 시간proxy-send-timeout: 업스트림으로 요청 전송이 지연될 때
LLM 스트리밍은 보통 60초로는 부족합니다. “최대 응답 시간”이 아니라 “chunk 간 최대 정지 시간”을 기준으로 300~900초를 잡는 경우가 많습니다.
2.2 keepalive를 켜서 업스트림 연결 재사용(특히 높은 QPS에서 502 감소)
짧은 요청이 많은 서비스는 TCP/TLS 핸드셰이크 비용이 커지고, 순간적인 포트 고갈/연결 폭주로 502가 날 수 있습니다. Ingress에서 업스트림 keepalive를 켜면 완화됩니다.
metadata:
annotations:
nginx.ingress.kubernetes.io/keepalive: "64"
nginx.ingress.kubernetes.io/keepalive-timeout: "75"
주의:
- keepalive가 늘면 업스트림(앱) 측도 keepalive를 안정적으로 처리해야 합니다.
- Pod가 자주 교체되면 keepalive 연결이 끊기며 502가 순간적으로 늘 수 있어 graceful shutdown과 함께 봐야 합니다.
2.3 스트리밍에서 버퍼링을 끄지 않으면 “끊김처럼 보이는 지연”이 생긴다
SSE/Chunked 응답에서 NGINX가 버퍼링하면, 클라이언트는 토큰이 생성돼도 한참 후에 몰아서 받습니다. 사용자는 “멈췄다”라고 느끼고 새로고침 → 중복 요청 → 부하 증가 → 502/504 악순환.
metadata:
annotations:
nginx.ingress.kubernetes.io/proxy-buffering: "off"
추가로 SSE라면 헤더도 확인하세요.
Content-Type: text/event-streamCache-Control: no-cache- 가능하면
X-Accel-Buffering: no(앱에서 설정)
2.4 gRPC는 proxy_*가 아니라 grpc_* 타임아웃을 써야 한다
LLM 서빙을 gRPC로 붙였는데도 HTTP proxy 설정만 만지면 “왜 안 고쳐지지?”가 됩니다. gRPC는 별도 타임아웃이 필요합니다.
metadata:
annotations:
nginx.ingress.kubernetes.io/backend-protocol: "GRPC"
nginx.ingress.kubernetes.io/grpc-read-timeout: "600"
nginx.ingress.kubernetes.io/grpc-send-timeout: "600"
- gRPC 스트리밍은 특히 read timeout이 중요합니다.
- 클라이언트가 HTTP/2를 쓰는지도 함께 확인하세요.
2.5 Ingress에서 499/502/504 로그를 “원인 추적 가능하게” 남기기
간헐 장애는 재현보다 관측이 먼저입니다.
- 499: 클라이언트가 먼저 끊음(브라우저 탭 닫기/프론트 타임아웃/로드밸런서)
- 504: NGINX가 업스트림 응답을 못 받음
- 502: 업스트림 연결 오류/리셋/프로토콜 mismatch
Ingress Controller의 access log 포맷에 $upstream_response_time, $request_time, $upstream_status 등을 포함시키면 “Ingress가 기다리다 끊긴 건지, 앱이 먼저 죽었는지”가 보입니다.
3) Gunicorn/Uvicorn에서 스트리밍 끊김과 502/504를 만드는 지점
Ingress만 늘려도 해결되는 경우가 있지만, LLM 스트리밍은 앱 서버 쪽이 병목이면 결국 다시 터집니다.
3.1 worker 수: CPU 바운드 vs I/O 바운드를 구분하자
- 토큰 생성이 서버 프로세스에서 CPU를 많이 쓰는 구조(예: CPU inference/전처리 heavy)면 worker를 CPU 코어 기준으로 잡고, 요청 큐/레이트리밋을 별도로 둬야 합니다.
- GPU inference + Python은 주로 I/O(대기) + 이벤트 루프가 많다면 uvicorn worker를 늘려 동시 연결을 처리합니다.
권장 출발점(ASGI + FastAPI 기준):
workers = min(2 * CPU, 8)정도로 시작- 스트리밍이 많으면 worker를 무작정 늘리기보다 한 worker가 처리 가능한 동시 스트림 수를 측정
Gunicorn 실행 예시:
gunicorn app.main:app \
-k uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--workers 4 \
--timeout 0 \
--graceful-timeout 60 \
--keep-alive 75 \
--max-requests 2000 \
--max-requests-jitter 200
포인트:
--timeout 0: Gunicorn의 강제 타임아웃을 끔(스트리밍/장시간 요청에서 중요)--keep-alive: Ingress keepalive와 호흡 맞추기--max-requests(+jitter): 장시간 운영 시 메모리 누수/fragmentation 완화(LLM 라이브러리에서 특히 효과)
3.2 preload_app은 “콜드 스타트 vs 메모리” 트레이드오프
--preload는 마스터 프로세스에서 앱을 로드한 뒤 fork 하므로:
- 장점: import/초기화 비용을 공유(COW)하여 메모리 절약 가능
- 단점: GPU 컨텍스트/스레드/이벤트 루프 관련 라이브러리는 preload 시 문제를 만들 수 있음
권장:
- CPU 모델/가벼운 초기화:
--preload고려 - GPU/토치/텐서RT/커스텀 런타임: preload로 인해 이상 동작 가능 → 먼저 끄고 안정화 후 실험
초기화가 길어 readiness가 늦어져 502가 난다면, preload보다 먼저 readinessProbe를 정확히 잡는 게 우선입니다.
3.3 graceful shutdown을 하지 않으면 롤링 업데이트 때 스트리밍이 “뚝” 끊긴다
Pod가 종료될 때:
- Service 엔드포인트에서 빠짐
- 이미 연결된 스트리밍은 계속 보내야 함
- 일정 시간 후 SIGKILL
이 과정이 짧거나 앱이 SIGTERM을 제대로 처리하지 않으면 스트리밍이 중간에 끊깁니다.
Kubernetes 종료 시나리오를 안전하게 만들기
Deployment 예시:
spec:
template:
spec:
terminationGracePeriodSeconds: 120
containers:
- name: llm
image: your/llm:tag
ports:
- containerPort: 8000
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
readinessProbe:
httpGet:
path: /healthz
port: 8000
periodSeconds: 5
failureThreshold: 2
preStop: sleep 10: 엔드포인트에서 빠진 뒤 커넥션 드레인 시간을 약간 확보terminationGracePeriodSeconds: 스트리밍이 끝날 시간을 충분히(실제 최대 스트림 시간을 기반으로)
FastAPI(Uvicorn)에서 SIGTERM 처리와 스트리밍 종료
SSE 예시(간단):
import asyncio
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
@app.get("/stream")
async def stream():
async def gen():
for i in range(1000):
yield f"data: token-{i}\n\n"
await asyncio.sleep(0.2)
return StreamingResponse(gen(), media_type="text/event-stream")
여기서 중요한 건 “종료 신호가 왔을 때 새 요청은 막고, 기존 스트림은 가능한 한 마무리”입니다.
- readiness를 즉시 fail로 바꿔 신규 유입 차단
- 진행 중 작업을 추적하고 일정 시간 내 정리
비동기 작업을 많이 쓰는 서비스라면 종료 시점에 Task was destroyed but it is pending 경고가 함께 나오면서 스트리밍이 끊기는 경우가 흔합니다. 이 경우는 Python asyncio Task was destroyed but it is pending 경고 원인 5가지와 완벽 해결법에서 제시하는 “취소 전파/await 누락/백그라운드 태스크 수명 관리” 체크리스트를 그대로 적용하면 안정성이 크게 올라갑니다.
4) 실전 트러블슈팅 플레이북 재현부터 원인 확정까지
4.1 먼저 확인할 로그/지표
- Ingress access log
status,upstream_statusrequest_time,upstream_response_time- 499/502/504 비율
- 앱 로그
- SIGTERM 시점 로그(종료 훅이 실행되는지)
- worker timeout/worker exit
- Kubernetes 이벤트
- OOMKilled 여부
- readiness flapping 여부
- 로드 테스트
- 단순 QPS가 아니라 동시 스트림 수를 늘려보기
- 토큰 생성이 잠깐 멈추는 구간(툴콜/검색) 포함
4.2 증상 → 원인 매칭
- 항상 60초 근처에서 끊김:
proxy_read_timeout/grpc_read_timeout가능성 높음 - 롤링 업데이트 때만 끊김:
terminationGracePeriodSeconds,preStop, 앱 SIGTERM 처리 - 트래픽 피크 때만 502: keepalive/업스트림 연결 폭주, worker 부족, 커넥션 제한
- 클라이언트는 끊었다는데 서버는 499: 프론트/중간 LB 타임아웃(Cloud LB idle timeout)도 확인
5) Best Practice 구성 예시 SSE와 gRPC를 모두 고려한 기본값
5.1 Ingress 권장(HTTP SSE)
metadata:
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "900"
nginx.ingress.kubernetes.io/proxy-send-timeout: "900"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/keepalive: "64"
nginx.ingress.kubernetes.io/keepalive-timeout: "75"
5.2 Ingress 권장(gRPC)
metadata:
annotations:
nginx.ingress.kubernetes.io/backend-protocol: "GRPC"
nginx.ingress.kubernetes.io/grpc-read-timeout: "900"
nginx.ingress.kubernetes.io/grpc-send-timeout: "900"
nginx.ingress.kubernetes.io/keepalive: "64"
5.3 Gunicorn/Uvicorn 권장
gunicorn app.main:app \
-k uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--workers 4 \
--timeout 0 \
--graceful-timeout 90 \
--keep-alive 75 \
--max-requests 2000 \
--max-requests-jitter 200
- 스트리밍/장시간 요청이면
timeout 0또는 충분히 크게 graceful-timeout은 termination grace와 함께 설계(둘 중 더 짧은 쪽이 실제 상한)
6) 마지막으로 자주 놓치는 함정 6가지
- 클라우드 로드밸런서 idle timeout
- Ingress 이전 단계(ALB/NLB/Cloud LB)가 먼저 끊을 수 있습니다.
- readinessProbe가 “프로세스 살아있음”만 체크
- 모델 로드 전에도 200을 주면 초기 요청이 실패/지연 → 502/504 유발
- 버퍼링으로 인한 가짜 멈춤
- 실제론 토큰이 생성되는데 사용자에겐 10초 후에 몰아서 보임
- worker 과다로 인한 GPU thrash
- GPU inference는 worker를 늘린다고 선형 확장되지 않습니다. 오히려 컨텍스트 경쟁으로 지연이 늘어 timeout을 부릅니다.
- SIGTERM 시 백그라운드 태스크 정리 부재
- 이벤트 루프 태스크가 남아있으면 종료가 꼬이고, 스트림이 끊기며, 다음 배포에서 재현이 더 어려워집니다.
- max-requests 미설정
- 장시간 운영에서 메모리 단편화/누수 누적으로 tail latency가 늘고, 결국 timeout으로 504가 늘어납니다.
결론 핵심은 Ingress와 앱 서버를 같이 맞추는 것이다
LLM 스트리밍에서 간헐 502/504와 응답 끊김은 한 군데 설정만으로 해결되지 않는 경우가 많습니다.
- Ingress에서는 read_timeout(또는 grpc_read_timeout) 을 스트리밍 특성에 맞게 늘리고, keepalive와 버퍼링 off로 연결 안정성과 체감 지연을 잡아야 합니다.
- 앱 서버에서는 Gunicorn/Uvicorn의 timeout(스트리밍이면 0 또는 충분히 크게), worker 수, max-requests, 그리고 무엇보다 graceful shutdown(terminationGracePeriod + preStop + SIGTERM 처리) 을 제대로 갖춰야 롤링 업데이트에서도 스트림이 끊기지 않습니다.
오늘 바로 할 일은 3가지입니다.
- Ingress 로그에 upstream 시간을 포함하고 502/504/499를 분리해 원인을 확정하기
proxy_read_timeout/grpc_read_timeout과proxy-buffering: off를 적용해 스트리밍 끊김부터 제거하기- Deployment에
terminationGracePeriodSeconds와preStop을 추가하고 Gunicorn--graceful-timeout을 맞춰 배포 중 끊김을 없애기