- Published on
EKS NGINX Ingress 499 폭주 원인과 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 499가 폭주하면 대개 “NGINX가 뭔가 터졌다”라고 오해하기 쉽습니다. 하지만 NGINX의 499(Client Closed Request) 는 본질적으로 클라이언트가 응답을 받기 전에 연결을 끊었다는 의미입니다. 즉, 500/502/504처럼 서버가 실패를 반환한 것이 아니라, 서버는 처리 중이었는데 상대가 먼저 포기한 상황이죠.
EKS + NGINX Ingress 환경에서 499가 급증하는 패턴은 특히 아래 조합에서 자주 발생합니다.
- 상단에 ALB/NLB/CloudFront/Cloudflare 같은 프록시가 있고
- 백엔드가 느리거나(대기/큐잉), 스트리밍(SSE/Chunked), 업로드/다운로드가 길거나, 또는
- Pod 롤링 업데이트/오토스케일링 중 연결이 끊기거나
- 타임아웃/keepalive 설정이 계층별로 맞지 않아 중간에서 끊기거나
이 글에서는 499를 “원인별로 분해”해서 진단하고, Ingress 설정 + 애플리케이션 + 운영(배포/오토스케일) 관점에서 재발을 줄이는 방법을 다룹니다.
참고로 502/504와 섞여 보이는 경우도 많습니다. 이때는 아래 글도 함께 보면 원인 분리가 빨라집니다.
- AWS ALB 502·504 난사 - 원인별 해결 체크리스트
- Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김을 끝내는 NGINX Ingress와 Gunicorn Uvicorn 실전 튜닝
499의 정확한 의미: “NGINX가 반환한 코드”가 아니다
NGINX access log의 status에 499가 찍히면, 그 요청은 NGINX가 정상적인 HTTP 응답을 끝까지 쓰지 못했습니다. 이유는 하나입니다.
- 클라이언트(직접 클라이언트 또는 상위 프록시)가 소켓을 닫았다.
여기서 ‘클라이언트’는 브라우저/모바일 앱일 수도 있고, ALB/CloudFront/Cloudflare 같은 상위 프록시일 수도 있습니다. EKS에서 Ingress 앞단이 ALB라면, 499의 “클라이언트”는 실제 사용자라기보다 ALB가 연결을 닫은 것일 가능성이 큽니다.
즉, 499를 줄이려면 “NGINX를 고치는” 것보다 왜 클라이언트가 먼저 끊었는지를 계층별로 찾아야 합니다.
499 폭주를 만드는 대표 시나리오 7가지
1) 상위 프록시(ALB/CloudFront/Cloudflare)의 타임아웃이 더 짧다
가장 흔합니다. 상위 프록시가 “응답이 너무 늦다”고 판단해 연결을 끊고, NGINX는 그 사실을 나중에 알아차려 499로 기록합니다.
- 예: ALB idle timeout 60s인데 백엔드 처리가 70s
- 예: Cloudflare 100s 제한(플랜/기능에 따라 다름)보다 느린 API
증상
- NGINX: 499 증가
- ALB: 504/502가 함께 보이거나, 클라이언트 측에서 timeout
- 애플리케이션: 실제로는 정상 처리 완료(하지만 결과가 전달되지 않음)
해결 방향
- 계층별 타임아웃을 “가장 바깥 → 안쪽” 순서로 정렬
- 장시간 작업은 비동기(202 + polling) 또는 스트리밍으로 UX를 바꾸기
2) 스트리밍(SSE/Chunked)인데 중간에 버퍼링/keepalive가 깨진다
LLM 응답 스트리밍, SSE, chunked response에서 499는 정말 흔합니다.
- 상위 프록시가 “데이터가 안 온다”고 판단
- NGINX가 버퍼링 중이라 클라이언트가 ‘무응답’으로 인지
- 앱이 주기적으로 flush하지 않아 idle로 끊김
**핵심은 ‘주기적 바이트 전송’과 ‘버퍼링 비활성화’**입니다.
3) 클라이언트(브라우저/앱)가 사용자가 나가서 취소했다
사용자가 뒤로가기/탭 닫기/화면 전환을 하면 요청이 취소됩니다.
- 검색 자동완성, 긴 리포트 생성, 대용량 다운로드
- 모바일에서 네트워크 전환(Wi‑Fi ↔ LTE)
이 경우 499는 “정상적인 사용자 행동”일 수 있어, 폭주라도 반드시 장애는 아닙니다. 다만 서버 리소스를 과도하게 쓰고 있다면 취소 감지가 필요합니다.
4) HPA/롤링 업데이트 중 Pod 종료로 연결이 끊긴다
Pod가 SIGTERM을 받고 내려가는 순간, 처리 중 요청이 끊기면 클라이언트는 재시도하거나 연결을 닫습니다. NGINX Ingress는 대개 살아있지만, 업스트림이 끊기면서 499/502가 섞여 나옵니다.
- readiness가 너무 빨리 내려가거나
- terminationGracePeriodSeconds가 짧거나
- preStop 훅이 없거나
- 앱이 graceful shutdown을 제대로 하지 않거나
이 영역은 “종료”를 제대로 설계해야 합니다. 관련해서는 아래 글의 종료/그레이스 섹션이 도움이 됩니다.
5) NGINX Ingress의 upstream keepalive/HTTP2 설정 불일치
특히 HTTP/2 ↔ HTTP/1.1 변환, keepalive 재사용, connection pool 설정이 맞지 않으면 “가끔” 499가 튀는 형태가 나옵니다.
- 상위는 HTTP/2, 업스트림은 HTTP/1.1
- 업스트림 keepalive timeout이 짧아 중간에 FIN
6) 대기열(큐잉) 증가로 TTFB가 길어져 클라이언트가 포기
서버가 느린 게 아니라, 대기열이 길어져서 첫 바이트(TTFB)가 늦는 경우입니다.
- CPU throttling, DB connection pool 고갈
- 외부 API rate limit 대기
- 앱 서버 worker 부족
이때 499는 “성능 문제의 결과”로 나타납니다.
7) 대용량 업로드/다운로드에서 클라이언트가 중간에 끊김
모바일/불안정 네트워크에서 흔합니다. 업로드 중단은 499로 기록될 수 있습니다.
1단계: 로그로 “누가 끊었는지” 분리하기
NGINX Ingress에서 499를 디버깅하려면 access log 포맷에 아래 값이 있어야 합니다.
$request_time(전체 처리 시간)$upstream_response_time(업스트림 응답 시간)$upstream_status$bytes_sent$http_user_agent$http_x_forwarded_for/$remote_addr
Ingress-NGINX는 ConfigMap으로 log-format을 커스터마이즈할 수 있습니다.
NGINX Ingress ConfigMap 예시: 499 분석용 로그 포맷
apiVersion: v1
kind: ConfigMap
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
data:
log-format-upstream: >-
{"time":"$time_iso8601",
"remote_addr":"$remote_addr",
"xff":"$http_x_forwarded_for",
"host":"$host",
"method":"$request_method",
"uri":"$request_uri",
"status":$status,
"request_time":$request_time,
"upstream_status":"$upstream_status",
"upstream_response_time":"$upstream_response_time",
"bytes_sent":$bytes_sent,
"ua":"$http_user_agent"}
이제 499를 다음처럼 해석할 수 있습니다.
request_time이 상위 타임아웃 근처에서 끊긴다 → 상위 프록시 타임아웃 의심upstream_response_time이 비어있거나 매우 작다 → 업스트림 연결/전송 전 끊김(클라이언트 취소/네트워크)bytes_sent가 거의 0 → 응답 헤더도 못 보낸 상태에서 끊김bytes_sent가 어느 정도 있고 스트리밍이었다 → 중간에 끊긴 스트림
2단계: 계층별 타임아웃을 “바깥이 더 길게” 정렬하기
원칙은 간단합니다.
- 클라이언트/엣지(가장 바깥) 타임아웃 ≥ ALB 타임아웃 ≥ NGINX 타임아웃 ≥ 앱 타임아웃 ≥ DB/외부 의존성 타임아웃
중간 계층이 더 짧으면, 안쪽이 정상 처리해도 바깥이 먼저 끊어 499가 늘어납니다.
Ingress annotation 예시: proxy timeout 조정
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api
annotations:
nginx.ingress.kubernetes.io/proxy-connect-timeout: "5"
nginx.ingress.kubernetes.io/proxy-read-timeout: "180"
nginx.ingress.kubernetes.io/proxy-send-timeout: "180"
# 스트리밍/SSE면 버퍼링을 끄는 게 중요
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
spec:
ingressClassName: nginx
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api
port:
number: 80
proxy-read-timeout: 업스트림에서 다음 바이트가 오기까지 기다리는 시간- 스트리밍이면
proxy-buffering: off가 체감상 가장 큰 차이를 만듭니다.
> 주의: 타임아웃을 무작정 키우면, 진짜로 멈춘 요청이 오래 점유할 수 있습니다. “긴 요청이 정당한가”를 먼저 판단하고, 정당하면 스트리밍/비동기화를 고려하세요.
3단계: 스트리밍(SSE/LLM)이라면 “주기적 flush + 버퍼링 OFF”가 정답
SSE/스트리밍에서 499가 터지는 경우는 ‘속도’ 문제가 아니라 idle로 보이는 시간이 문제인 경우가 많습니다.
FastAPI(Uvicorn) SSE 예시: 주기적 heartbeat 전송
import asyncio
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
@app.get("/sse")
async def sse():
async def gen():
# 최초 바이트를 빨리 보내 TTFB를 줄임
yield "event: ready\ndata: ok\n\n"
for i in range(60):
# 5초마다 heartbeat (프록시 idle timeout 방지)
yield f"event: ping\ndata: {i}\n\n"
await asyncio.sleep(5)
return StreamingResponse(gen(), media_type="text/event-stream")
이와 함께 Ingress에서
proxy-buffering: offproxy-read-timeout을 스트림 특성에 맞게 충분히 크게
를 적용하면 499가 급감하는 경우가 많습니다.
4단계: “클라이언트 취소”를 서버가 빨리 감지해 낭비를 줄이기
499는 클라이언트가 끊은 것이므로, 서버는 가능하면 즉시 작업을 중단해야 비용이 줄어듭니다.
- 긴 DB 쿼리/외부 API 호출을 계속 수행
- LLM 생성 작업을 끝까지 돌림
- 백그라운드 작업 enqueue를 계속함
이런 낭비가 499 폭주를 “비용 폭주”로 바꿉니다.
Node.js(Express) 예시: 연결 종료 시 작업 취소
import express from "express";
const app = express();
app.get("/report", async (req, res) => {
let aborted = false;
req.on("close", () => { aborted = true; });
// 긴 작업을 여러 단계로 쪼개고 중간중간 취소 확인
for (let i = 0; i < 100; i++) {
if (aborted) return; // 499 유발 상황: 클라이언트가 떠남
await new Promise(r => setTimeout(r, 100));
}
if (!aborted) res.json({ ok: true });
});
app.listen(3000);
언어/프레임워크별로 cancellation token, request context, disconnect hook 등을 적극 활용하세요.
5단계: 배포/오토스케일 중 499를 줄이는 종료(Graceful) 설계
EKS에서 499가 “특정 시간대(배포 직후/HPA scale-in)”에 몰린다면 종료 설계가 핵심입니다.
체크리스트:
- readinessProbe가 트래픽을 받기 전에 충분히 검증하는가
- terminationGracePeriodSeconds가 실제 최대 처리 시간보다 큰가
- preStop으로 drain 시간을 확보하는가
- 애플리케이션이 SIGTERM을 받아 신규 요청을 중단하고 기존 요청을 마무리하는가
Deployment 예시: preStop + 충분한 grace
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
spec:
terminationGracePeriodSeconds: 90
containers:
- name: api
image: your-api:latest
lifecycle:
preStop:
exec:
# 엔드포인트/워커 종료 방식에 맞게 조정
command: ["/bin/sh", "-c", "sleep 20"]
readinessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 5
failureThreshold: 2
sleep 20은 단순 예시입니다. 목적은 Ingress/Service 엔드포인트에서 빠진 뒤 기존 연결이 정리될 시간을 주는 것입니다.
6단계: 499가 “장애”인지 “관측된 사용자 취소”인지 판별하기
499는 줄이는 게 목표가 아니라, 의미를 분류해야 합니다.
- 499가 늘었는데 p95/p99 latency도 같이 상승 → 성능 병목/대기열 문제 가능성
- 499가 늘었는데 2xx도 같이 늘고, 특정 UA/경로에서만 발생 → 사용자 취소/프론트 재시도 로직 가능성
- 499와 동시에 ALB 504 증가 → 타임아웃 정렬 실패 가능성
실무에서는 다음 3개를 같이 봐야 결론이 빨라집니다.
- NGINX access log의
request_time,upstream_response_time,bytes_sent - ALB/NLB/엣지의 타임아웃/에러 비율
- 애플리케이션의 처리 시간 분포(p95/p99)와 리소스(CPU throttling, DB pool)
자주 쓰는 처방 조합(상황별)
A) “긴 요청 + ALB 앞단”에서 499 폭주
- ALB idle timeout 상향(가능 범위 내)
- Ingress
proxy-read-timeout상향 - 앱 타임아웃/worker 설정 점검
- 가능하면 비동기화(202) 또는 스트리밍
B) SSE/LLM 스트리밍에서 499 폭주
- Ingress
proxy-buffering: off - 주기적 heartbeat/flush
- 중간 프록시의 idle timeout 확인
C) 배포/스케일 인 때만 499 증가
terminationGracePeriodSeconds확대preStop으로 drain- 앱 graceful shutdown 구현
- readiness/liveness 분리(ready는 트래픽 수신 가능 여부)
결론: 499는 “원인”이 아니라 “결과”다
EKS NGINX Ingress에서 499 폭주는 대부분 클라이언트(또는 상위 프록시)가 먼저 연결을 끊는 구조적 이유가 있습니다. 따라서 해결도 NGINX만 보지 말고,
- 계층별 타임아웃 정렬
- 스트리밍 버퍼링/heartbeat
- 배포/스케일의 graceful 종료
- 클라이언트 취소 감지로 낭비 제거
를 함께 적용해야 재발이 줄어듭니다.
499 자체를 0으로 만드는 것은 현실적으로 어렵지만, “정상적인 사용자 취소”와 “타임아웃/성능 문제로 인한 강제 종료”를 분리해 관리하면, 장애 대응 난이도와 비용을 동시에 낮출 수 있습니다.