Published on

Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김을 끝내는 NGINX Ingress와 Gunicorn Uvicorn 실전 튜닝

Authors

서빙 중인 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-stream
  • Cache-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가 종료될 때:

  1. Service 엔드포인트에서 빠짐
  2. 이미 연결된 스트리밍은 계속 보내야 함
  3. 일정 시간 후 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 먼저 확인할 로그/지표

  1. Ingress access log
  • status, upstream_status
  • request_time, upstream_response_time
  • 499/502/504 비율
  1. 앱 로그
  • SIGTERM 시점 로그(종료 훅이 실행되는지)
  • worker timeout/worker exit
  1. Kubernetes 이벤트
  • OOMKilled 여부
  • readiness flapping 여부
  1. 로드 테스트
  • 단순 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가지

  1. 클라우드 로드밸런서 idle timeout
  • Ingress 이전 단계(ALB/NLB/Cloud LB)가 먼저 끊을 수 있습니다.
  1. readinessProbe가 “프로세스 살아있음”만 체크
  • 모델 로드 전에도 200을 주면 초기 요청이 실패/지연 → 502/504 유발
  1. 버퍼링으로 인한 가짜 멈춤
  • 실제론 토큰이 생성되는데 사용자에겐 10초 후에 몰아서 보임
  1. worker 과다로 인한 GPU thrash
  • GPU inference는 worker를 늘린다고 선형 확장되지 않습니다. 오히려 컨텍스트 경쟁으로 지연이 늘어 timeout을 부릅니다.
  1. SIGTERM 시 백그라운드 태스크 정리 부재
  • 이벤트 루프 태스크가 남아있으면 종료가 꼬이고, 스트림이 끊기며, 다음 배포에서 재현이 더 어려워집니다.
  1. 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가지입니다.

  1. Ingress 로그에 upstream 시간을 포함하고 502/504/499를 분리해 원인을 확정하기
  2. proxy_read_timeout/grpc_read_timeoutproxy-buffering: off를 적용해 스트리밍 끊김부터 제거하기
  3. Deployment에 terminationGracePeriodSecondspreStop을 추가하고 Gunicorn --graceful-timeout을 맞춰 배포 중 끊김을 없애기