- Published on
Triton+Nginx에서 LLM SSE 스트리밍 끊김 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
LLM 토큰 스트리밍을 SSE(Server-Sent Events)로 제공할 때, Triton Inference Server 앞단에 Nginx를 두면 특정 환경에서 스트림이 중간에 끊기거나(브라우저 EventSource failed), 토큰이 한꺼번에 뭉쳐서 도착하는 문제가 자주 발생합니다. 원인은 대개 프록시의 버퍼링/압축/타임아웃/커넥션 정책이 “스트리밍”의 전제(즉시 flush, 장시간 연결 유지)와 충돌하기 때문입니다.
이 글은 Triton + (앱 서버) + Nginx 구조에서 SSE 스트리밍이 끊길 때, 어디를 어떻게 점검하고 어떤 설정으로 안정화할지 정리합니다. 프록시 뒤 스트리밍 이슈 전반 체크리스트는 아래 글도 함께 참고하면 좋습니다.
- FastAPI Uvicorn에서 SSE 웹소켓 LLM 스트리밍이 프록시 뒤에서 끊길 때 Cloudflare Nginx ALB 버퍼 타임아웃 gzip으로 EventSource failed 100% 재현 해결 체크리스트
- 운영 환경에서 파드가 재시작되며 스트림이 끊기는 경우는 K8s CrashLoopBackOff 원인별 로그·Probe 해결 가이드도 같이 보세요.
문제 증상: “중간 끊김”과 “토큰 뭉침”
대표 증상은 다음 두 가지로 나뉩니다.
중간 끊김
- 브라우저에서
EventSource가 갑자기 종료 - 네트워크 탭에서 응답이
pending상태였다가canceled또는net::ERR_HTTP2_PROTOCOL_ERROR등으로 종료 - 서버 로그는 정상적으로 계속 생성되는데 클라이언트는 더 이상 못 받음
- 브라우저에서
토큰 뭉침(지연 후 한꺼번에 출력)
- 모델은 토큰을 계속 생성하지만, 클라이언트는 1초~수십 초 후에 덩어리로 받음
- 특히 Nginx 또는 CDN을 통과할 때 재현률이 높음
둘 다 “스트리밍이 즉시 전달되지 않는다”는 점에서 공통 원인이 겹치며, 우선순위는 보통 아래 순서로 해결됩니다.
- Nginx 버퍼링
off - gzip 비활성화
- 타임아웃(특히
proxy_read_timeout)을 스트리밍에 맞게 확대 - HTTP/2 또는 keepalive 관련 이슈 점검
- 업스트림(앱 서버)에서 flush/헤더를 올바르게 설정
아키텍처 관점: Triton이 직접 SSE를 말하지는 않는다
Triton Inference Server는 일반적으로 HTTP/gRPC로 추론 결과를 반환합니다. LLM 토큰 스트리밍은 다음 중 하나로 구현됩니다.
- 앱 서버가 Triton에 요청하고, Triton 응답(또는 gRPC stream)을 읽어 SSE로 재포장
- Triton의 특정 백엔드/확장(예: vLLM, TensorRT-LLM 연계)에서 스트리밍을 제공하고, 앱 서버는 그 스트림을 패스스루
즉, SSE의 핵심은 “앱 서버가 chunk를 즉시 flush하고, Nginx가 그 chunk를 버퍼링하지 않고, 클라이언트가 장시간 연결을 유지”하는 삼박자입니다.
재현 포인트: Nginx 기본값이 스트리밍을 망가뜨리는 순간
1) proxy_buffering on 으로 인한 토큰 뭉침
Nginx는 기본적으로 업스트림 응답을 버퍼에 모아두었다가 클라이언트로 내보낼 수 있습니다. SSE는 data: 라인이 도착하는 즉시 전달돼야 하므로, 버퍼링이 켜져 있으면 체감상 “모델이 느려졌다”처럼 보입니다.
2) gzip 압축으로 인한 flush 지연
SSE는 작은 chunk가 자주 오는데, gzip은 내부 버퍼를 채우기 전까지 압축 결과를 내보내지 않을 수 있습니다. 그 결과 “토큰이 한꺼번에” 전달됩니다.
3) 타임아웃으로 인한 중간 끊김
proxy_read_timeout은 “업스트림에서 다음 바이트가 도착할 때까지 기다리는 시간”입니다.- 스트리밍이 잠깐 멈추거나(예: 모델이 다음 토큰 생성에 시간이 걸림), 또는 서버가 heartbeat를 보내지 않으면, Nginx가 연결을 끊을 수 있습니다.
4) HTTP/2, 중간 프록시, ALB/CDN의 영향
SSE는 HTTP/1.1에서 잘 동작하는 편이며, HTTP/2 경로에서 특정 프록시 조합이 예민하게 반응하는 케이스가 있습니다. “Nginx는 괜찮은데 ALB/Cloudflare에서 끊김” 같은 상황이 여기에 해당합니다.
정답 설정: Nginx에서 SSE를 위한 최소 안전 구성
아래는 SSE 엔드포인트 전용 location에 적용하기 좋은 템플릿입니다. 핵심은 버퍼링 끄기, gzip 끄기, 타임아웃 늘리기, 커넥션 유지입니다.
# /etc/nginx/conf.d/app.conf
upstream llm_gateway {
server 127.0.0.1:8080;
keepalive 64;
}
server {
listen 80;
# (선택) 전역 gzip을 쓰더라도 SSE location에서는 끄는 것을 권장
gzip on;
location /api/llm/stream {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE 스트리밍 핵심
proxy_buffering off;
proxy_cache off;
gzip off;
# 타임아웃은 스트리밍 특성에 맞게 크게
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# 일부 환경에서 응답을 즉시 보내도록
add_header X-Accel-Buffering "no" always;
# 업스트림으로 전달
proxy_pass http://llm_gateway;
}
}
설정 체크 포인트
proxy_buffering off와add_header X-Accel-Buffering "no"는 함께 쓰는 것을 권장합니다.- gzip은 “전역 on + SSE location off”가 운영적으로 가장 무난합니다.
- 타임아웃은 길게 잡고, 대신 서버/앱에서 heartbeat를 보내는 쪽이 더 안전합니다.
앱 서버에서 SSE를 올바르게 내보내는 법(헤더/flush/heartbeat)
Nginx만 고쳐도 해결되는 경우가 많지만, 앱 서버가 SSE 규약에 맞게 응답하지 않으면 여전히 끊깁니다.
필수 응답 헤더
Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive
또한 프록시/브라우저 조합에 따라 Transfer-Encoding: chunked가 자연스럽게 붙어야 합니다(대부분 프레임워크가 자동 처리).
예시: FastAPI에서 Triton 결과를 SSE로 중계
아래 예시는 “Triton에서 토큰을 순차적으로 받는다”는 가정 하에, 받은 토큰을 SSE로 즉시 flush하는 형태입니다.
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio
app = FastAPI()
async def triton_token_stream(prompt: str):
# 실제로는 Triton HTTP/gRPC 클라이언트를 붙이세요.
# 여기서는 예시로 토큰을 생성하는 척합니다.
for t in ["안", "녕", "하", "세", "요"]:
await asyncio.sleep(0.2)
yield t
async def sse_event_generator(prompt: str):
# 초기 코멘트(일부 프록시에서 첫 바이트가 중요)
yield ": connected\n\n"
# 주기적 heartbeat: 프록시 read timeout 방지
last_ping = asyncio.get_event_loop().time()
async for token in triton_token_stream(prompt):
# SSE는 data: ... \n\n 형식
yield f"data: {token}\n\n"
now = asyncio.get_event_loop().time()
if now - last_ping >= 10:
yield ": ping\n\n"
last_ping = now
yield "event: done\ndata: end\n\n"
@app.get("/api/llm/stream")
async def stream(prompt: str):
headers = {
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
"Connection": "keep-alive",
}
return StreamingResponse(sse_event_generator(prompt), headers=headers)
핵심은 yield가 곧바로 네트워크로 흘러가도록(프레임워크가 chunked로 flush하도록) 구성하는 것입니다. 그리고 heartbeat는 proxy_read_timeout이나 중간 로드밸런서 idle timeout을 회피하는 데 매우 유효합니다.
Triton 쪽에서 흔히 겪는 병목: “스트림이 끊긴 게 아니라 서버가 멈췄다”
SSE 끊김처럼 보이지만, 실제로는 Triton 또는 모델 워커가 멈추거나 재시작되는 경우도 있습니다.
- GPU 메모리 압박으로 프로세스가 죽고 재기동
- 노드 OOM으로 커널이 프로세스를 kill
- K8s에서
OOMKilled또는CrashLoopBackOff
이 경우 Nginx 설정만으로는 해결되지 않습니다. 노드/컨테이너 OOM 추적은 아래 글을 같이 참고하세요.
운영 체크리스트: “어디에서 끊기는가”를 빠르게 분리
1) Nginx를 우회해서 앱 서버에 직접 붙여보기
- 우회 시 정상: Nginx 설정 문제 가능성이 큼
- 우회 시에도 끊김: 앱 서버 flush/heartbeat 또는 Triton/모델 문제
2) curl로 SSE가 chunk 단위로 오는지 확인
curl은 버퍼링 없이 확인하기 좋습니다.
curl -N -v "http://localhost/api/llm/stream?prompt=hello"
data:라인이 즉시 즉시 출력되면 스트리밍은 정상- 한참 있다가 몰아서 나오면 버퍼링/gzip/flush 문제
3) Nginx access log에 업스트림 타이밍을 찍기
업스트림 응답 시간이 길어지는지, 전송이 중간에 끊기는지 파악하려면 로그 포맷을 확장합니다.
log_format timing '$remote_addr - $request '
'status=$status '
'rt=$request_time '
'urt=$upstream_response_time '
'uht=$upstream_header_time '
'bytes=$bytes_sent';
access_log /var/log/nginx/access.log timing;
rt는 클라이언트 관점 전체 시간urt는 업스트림 응답 시간- 스트리밍은 “긴 연결”이 정상입니다. 중요한 건 중간에 499/502/504가 나는지와 정상 종료 시점이 의도한 종료인지입니다.
자주 하는 실수와 처방
실수 1) SSE에 전역 gzip을 그대로 적용
- 처방: SSE location에서
gzip off
실수 2) proxy_buffering off를 안 켬
- 처방: SSE 전용 location에 반드시 적용
실수 3) proxy_read_timeout을 기본값으로 둠
- 처방: 스트리밍은 길게, 대신 앱에서
: ping같은 heartbeat를 10~30초 주기로
실수 4) HTTP/2 경로에서만 끊김
- 처방: 우선 Nginx
proxy_http_version 1.1유지, 프론트(클라이언트까지) 경로에 있는 ALB/CDN의 HTTP/2 옵션과 idle timeout을 점검
결론: Triton 앞단 스트리밍의 핵심은 “버퍼링 제거 + 장기 연결 설계”
Triton 자체가 빠르게 토큰을 만들어도, Nginx가 버퍼링하거나 gzip이 flush를 지연하면 사용자는 “스트리밍이 안 된다”고 느낍니다. 또한 타임아웃은 “가끔 끊기는” 형태로 나타나므로, Nginx의 proxy_read_timeout과 앱 서버의 heartbeat를 세트로 가져가는 것이 가장 안정적입니다.
정리하면 아래 4가지만 먼저 적용해도 대부분의 SSE 끊김/뭉침 이슈가 사라집니다.
- Nginx:
proxy_buffering off - Nginx: SSE location
gzip off - Nginx:
proxy_read_timeout충분히 크게 - 앱:
text/event-stream헤더 + 주기적: pingheartbeat
이후에도 끊긴다면, “프록시 문제가 아니라 프로세스 재시작/리소스 부족” 가능성을 열고 Triton/노드 로그를 함께 보는 것이 정답입니다.