- Published on
BentoML 서빙 429 폭주 대응 - 오토스케일·레이트리밋
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 트래픽이 조금만 튀어도 BentoML 엔드포인트에서 429 Too Many Requests가 연쇄적으로 발생하는 경우가 있습니다. 많은 팀이 이를 단순히 “레이트리밋이 걸렸다”로만 해석하지만, 실제로는 동시성(concurrency)·큐(queuing)·워커(worker)·모델 추론 지연(latency)·오토스케일 지연이 서로 맞물려 만들어낸 결과인 경우가 더 많습니다.
이 글에서는 BentoML 기반 모델서빙에서 429 폭주가 생기는 전형 패턴을 짚고, 오토스케일(수평 확장)과 레이트리밋(유입 제어)을 함께 설계해 429를 “최후의 안전장치”로 만들기 위한 방법을 다룹니다.
참고로 쿠버네티스에서 장애가 겹치면 원인 추적이 더 어려워지니, 배포/운영 단에서의 기본 점검도 함께 권합니다. 예를 들어 이미지 풀 실패는 트래픽 급증 때 치명적입니다. 관련해서는 K8s ImagePullBackOff·ErrImagePull 원인 12가지도 같이 보면 도움이 됩니다.
429 폭주의 본질: “너무 많은 요청”이 아니라 “처리 용량보다 빠른 유입”
HTTP 429는 보통 다음 두 상황에서 나옵니다.
- 명시적 레이트리밋: API Gateway, Ingress, 앱 레벨 미들웨어가 초당 요청 수(QPS)나 동시 요청 수를 제한
- 암묵적 과부하 제어: 서버가 내부 큐/스레드풀/워커풀의 한계에 도달했을 때 빠르게 실패시키기 위해 429(또는 503)를 반환
BentoML 서빙에서 429가 폭주한다면, 아래 질문으로 구조를 분해해보는 게 좋습니다.
- 단일 Pod(또는 단일 프로세스)에서 동시 추론을 몇 개까지 처리하도록 설정했나
- 요청이 몰릴 때 큐가 어디에 존재하나(클라이언트, LB, Ingress, BentoML, 워커)
- 오토스케일이 실제로 늘어나기까지 몇 초/몇 분의 지연이 있나
- 모델 추론이 CPU/GPU에서 batching 가능한지, 혹은 동시 실행 시 오히려 느려지는지
핵심은 간단합니다.
- 유입률이 처리율을 초과하면 큐가 쌓이고
- 큐가 한계에 다다르면 429(또는 타임아웃)가 연쇄적으로 터집니다.
BentoML에서 429가 자주 나오는 전형 패턴 5가지
1) 동시성 설정이 실제 하드웨어/모델 특성과 불일치
GPU 추론은 “동시성 높이면 더 빨라진다”가 아니라, 대부분의 경우 적절한 배치(batch)와 제한된 동시성에서 가장 안정적입니다. 동시 요청을 과하게 열어두면 GPU 메모리 압박, 컨텍스트 스위칭 증가, 커널 런치 오버헤드 증가로 p95/p99가 튀고 결과적으로 429/타임아웃이 늘어납니다.
2) 큐가 Ingress나 LB에만 존재하고 앱은 즉시 실패
Ingress(NGINX)나 ALB는 기본적으로 어느 정도 버퍼링을 합니다. 그런데 앱(BentoML)이 내부적으로는 작은 큐만 갖거나, 워커가 꽉 차면 빠르게 429를 반환하도록 되어 있으면 “Ingress에는 대기열이 쌓이는데 응답은 429” 같은 모양이 나옵니다.
3) 오토스케일이 늦게 반응한다
HPA는 메트릭 수집 주기, 스케일링 정책, 쿨다운 때문에 급격한 스파이크를 즉시 따라가지 못합니다. 특히 GPU 노드는 노드 프로비저닝 자체가 느려서(수십 초~수분) 스파이크 초기에 429가 몰리는 건 자연스러운 현상입니다. 이 구간을 레이트리밋/큐/프리워밍으로 완충해야 합니다.
4) 콜드 스타트(모델 로딩)가 길다
Pod가 늘어나도 모델 로딩이 30초~2분이면, 그동안 새 Pod는 트래픽을 받지 못하거나 readiness가 늦어집니다. 그 사이 기존 Pod가 과부하로 429를 뱉습니다.
5) 클라이언트 재시도가 “동기 폭주”를 만든다
429를 받은 클라이언트가 즉시 재시도하면(특히 지터 없는 고정 backoff) 같은 타이밍에 재시도가 동기화되어 더 큰 스파이크가 됩니다. gRPC/HTTP 클라이언트에서 타임아웃과 재시도는 반드시 함께 설계해야 합니다. 네트워크 레이어에서 지연이 쌓일 때의 원인 분해는 Go gRPC context deadline exceeded 원인 9가지도 참고할 만합니다.
목표 아키텍처: “스케일로 흡수 + 레이트리밋으로 보호”
실전에서 추천하는 목표는 아래 3단 구성입니다.
- Ingress/API Gateway 레벨 레이트리밋: 시스템 전체를 보호(최상단 브레이커)
- 서비스 레벨 동시성/큐 제어: Pod 내부에서 안정적인 처리(과부하 시 빠른 실패 또는 큐잉)
- 오토스케일: 중장기적으로 처리량을 늘려 steady state를 맞춤
이 셋 중 하나만 쓰면 항상 구멍이 생깁니다.
- 오토스케일만 믿으면 스파이크 초기에 무너짐
- 레이트리밋만 쓰면 처리량이 늘 여유가 있어도 계속 429
- Pod 내부 큐만 키우면 지연이 무한정 늘고 결국 타임아웃
BentoML 서비스 예제: 동시성 제한과 과부하 제어
아래는 BentoML 서비스에서 추론 엔드포인트를 만들고, 서버 측 동시성을 “무한”으로 두지 않도록 구성하는 기본 예시입니다. (세부 옵션은 BentoML 버전에 따라 다를 수 있으니, 운영 환경에서는 사용 중인 버전의 설정 키를 확인하세요.)
import bentoml
from bentoml.io import JSON
# 예: scikit-learn 모델
model_ref = bentoml.sklearn.get("my_model:latest")
runner = model_ref.to_runner()
svc = bentoml.Service("my_inference", runners=[runner])
@svc.api(input=JSON(), output=JSON())
async def predict(input_data: dict) -> dict:
# runner는 내부적으로 워커/리소스 제약을 받으며 실행됨
result = await runner.async_run(input_data)
return {"result": result}
여기서 중요한 포인트는 “엔드포인트 함수가 async냐 sync냐”가 아니라,
- runner가 실제로 몇 개의 워커에서 처리되는지
- 워커당 동시 실행을 얼마나 허용하는지
- 요청이 몰릴 때 큐를 얼마나 허용할지
입니다.
운영에서는 다음 2가지를 반드시 관측해야 합니다.
- inflight(동시 처리 중인 요청 수)
- queue depth(대기 중인 요청 수)
이 값이 없으면 429를 보고도 “레이트리밋이 너무 빡센가?”만 반복하게 됩니다.
Ingress 레이트리밋: 429를 “의도적으로” 만들기
429를 완전히 없애는 게 목표가 아니라, 예측 가능한 조건에서만 429가 나오게 만드는 게 목표입니다. 그 역할을 Ingress가 담당하면 애플리케이션이 덜 흔들립니다.
NGINX Ingress를 쓴다면 limit_req 계열로 IP 단위 또는 헤더 기반 제한을 둘 수 있습니다. 예시는 아래처럼 “초당 요청 수 + 버스트”를 둬서 짧은 스파이크를 흡수합니다.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-inference
annotations:
nginx.ingress.kubernetes.io/limit-rps: "20"
nginx.ingress.kubernetes.io/limit-burst-multiplier: "3"
nginx.ingress.kubernetes.io/limit-connections: "10"
spec:
rules:
- host: api.example.com
http:
paths:
- path: /predict
pathType: Prefix
backend:
service:
name: my-inference
port:
number: 80
여기서 버스트는 매우 중요합니다.
- 버스트가 없으면 작은 지터에도 429가 과도하게 발생
- 버스트가 너무 크면 지연이 쌓여 타임아웃으로 전이
즉, “버스트는 짧게, 하지만 존재해야 함”이 운영적으로 가장 안정적입니다.
오토스케일: CPU 기반 HPA만으로는 부족한 이유
모델 서빙은 CPU 사용률이 낮아도 429가 날 수 있습니다.
- GPU가 병목인데 CPU는 한가한 경우
- Python 런타임에서 GIL, 이벤트 루프, 직렬화 비용이 병목인데 CPU 평균은 낮게 나오는 경우
- 동시 요청이 늘면 latency가 증가하지만 CPU 평균은 완만한 경우
그래서 추천하는 접근은 다음 중 하나입니다.
- 커스텀 메트릭(HPA v2): 큐 길이, inflight, p95 latency 같은 지표 기반
- KEDA: Prometheus 메트릭 기반 스케일링
- 요청률(RPS) 기반: Ingress/LB 지표를 Prometheus로 가져와 스케일
예를 들어 Prometheus 어댑터로 requests_per_second 같은 메트릭을 HPA에 연결할 수 있습니다.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: my-inference-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: my-inference
minReplicas: 2
maxReplicas: 20
metrics:
- type: Pods
pods:
metric:
name: requests_per_second
target:
type: AverageValue
averageValue: "5"
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 200
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 120
policies:
- type: Percent
value: 50
periodSeconds: 60
포인트는 behavior 입니다.
- 스파이크 대응은 빠르게 scale up
- scale down은 천천히(플랩 방지)
GPU 노드가 필요하다면 Cluster Autoscaler 또는 Karpenter까지 포함해 “노드가 늘어나는 시간”을 감안해야 합니다. 이때 IAM/IRSA가 꼬이면 스케일링은 되는데 권한 문제로 실패하는 케이스도 있으니, EKS라면 EKS IRSA인데 AccessDenied? OIDC·TrustPolicy·SA 점검 같은 체크리스트를 갖고 있는 편이 안전합니다.
429를 줄이는 실전 튜닝 체크리스트
1) 요청 단위를 줄이거나, 배치로 바꾸기
- 작은 요청을 여러 번 보내는 패턴은 QPS를 폭증시킵니다.
- 가능하면 클라이언트에서 미니배치로 묶어 보내고, 서버는 배치 추론으로 처리합니다.
다만 배치는 latency와 trade-off이므로, “최대 배치 크기 + 최대 대기 시간”을 함께 둬야 합니다.
2) 타임아웃 계층을 정렬하기
타임아웃은 바깥에서 안으로 갈수록 짧아야 합니다.
- 클라이언트 타임아웃이 2초인데
- Ingress가 60초 대기하고
- 앱이 120초까지 처리한다
같은 구조는 큐를 키워서 더 큰 장애를 만듭니다. 타임아웃은 “빠른 실패”를 통해 시스템을 보호하는 장치이기도 합니다.
3) 클라이언트 재시도에 지터를 넣기
429에 대한 재시도는 반드시
- exponential backoff
- full jitter
- 최대 재시도 횟수 제한
을 적용해야 합니다.
예를 들어 Python httpx를 쓴다면 재시도는 라이브러리/미들웨어로 통제하고, 애플리케이션 로직에서 무한 재시도를 만들지 않게 합니다.
import random
import time
import httpx
def request_with_backoff(url: str, payload: dict, max_retries: int = 5):
for i in range(max_retries):
r = httpx.post(url, json=payload, timeout=5.0)
if r.status_code != 429:
r.raise_for_status()
return r.json()
# exponential backoff + full jitter
base = 0.2 * (2 ** i)
sleep_s = random.uniform(0, base)
time.sleep(sleep_s)
raise RuntimeError("Too many 429 responses")
4) 관측 지표를 “429 개수”가 아니라 “원인 지표”로 본다
429는 결과입니다. 원인 지표를 같이 봐야 합니다.
- Pod당 inflight
- 큐 길이
- p95/p99 latency
- GPU utilization, GPU memory
- 요청 크기(입력 토큰 수, 이미지 크기 등)
429만 보고 레이트리밋을 풀면, 다음은 503이나 OOM이 될 가능성이 큽니다.
5) 콜드 스타트 줄이기: 프리로드와 readiness
- 모델 로딩을 컨테이너 시작 시 미리 수행
- readiness는 “서빙 가능” 기준으로 엄격히
- 최소 replica를 0으로 두지 말고(가능하면) 1~2로 유지
특히 LLM/비전 모델은 로딩과 워밍업이 길어, 최소 replica를 유지하는 비용이 429 폭주 비용보다 싼 경우가 많습니다.
운영 설계 예시: 스파이크를 3단으로 흡수
실전에서 자주 쓰는 수치를 예로 들면:
- Ingress: IP당
limit-rps20, burst 60 - Pod: 워커 1~2(모델 특성에 따라), inflight 상한을 낮게
- HPA: RPS 또는 큐 길이로 2초~10초 단위로 scale up, maxReplicas 충분히
- 클라이언트: 429 재시도는 최대 2~3회, 지터 필수
이렇게 하면 스파이크 초기에 429가 일부 발생하더라도,
- 무한 재시도로 더 큰 폭주가 생기지 않고
- 오토스케일이 따라붙는 동안 시스템이 버티며
- steady state에서는 429가 거의 사라지는
형태로 수렴합니다.
마무리: 429는 “나쁜 응답”이 아니라 “제어된 실패”여야 한다
BentoML 모델서빙에서 429 폭주는 대개 레이트리밋 하나의 문제가 아니라, 처리 용량 대비 유입 제어 실패 + 스케일링 지연 + 재시도 증폭이 함께 만든 현상입니다.
정리하면 우선순위는 다음과 같습니다.
- Pod 내부 동시성/큐를 모델 특성에 맞게 제한
- Ingress에서 버스트를 포함한 레이트리밋으로 최상단 보호
- RPS 또는 큐 기반으로 오토스케일을 빠르게 반응하도록 조정
- 클라이언트 재시도는 지터와 상한을 강제
이 4가지를 맞추면 429는 “폭주 신호”가 아니라 “시스템이 건강하게 거절하는 신호”로 바뀝니다.