Published on

EKS NGINX Ingress 499 폭주 원인과 해결

Authors

서버에서 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와 섞여 보이는 경우도 많습니다. 이때는 아래 글도 함께 보면 원인 분리가 빨라집니다.

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: off
  • proxy-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개를 같이 봐야 결론이 빨라집니다.

  1. NGINX access log의 request_time, upstream_response_time, bytes_sent
  2. ALB/NLB/엣지의 타임아웃/에러 비율
  3. 애플리케이션의 처리 시간 분포(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으로 만드는 것은 현실적으로 어렵지만, “정상적인 사용자 취소”와 “타임아웃/성능 문제로 인한 강제 종료”를 분리해 관리하면, 장애 대응 난이도와 비용을 동시에 낮출 수 있습니다.