KEDA HTTP add-on으로 워커 아닌 웹서비스를 scale-to-zero 하는 법

KEDA HTTP add-on으로 워커 아닌 웹서비스를 scale-to-zero 하는 법
트래픽이 늘어나면 파드 늘리고, 줄어들면 다시 줄이고. 이건 이제 HPA로 다들 하고 있다. 그런데 새벽 3시부터 아침 8시까지 요청이 거의 안 들어오는 사내 도구, 데모 환경, 사이드 프로젝트 API 같은 걸 굳이 파드 한 개씩 띄워둬야 할까? CPU가 놀고 있어도 노드 자원은 계속 먹는다.
KEDA는 원래 큐 워커나 이벤트 소비자 오토스케일링용으로 유명한데, HTTP add-on을 붙이면 일반 웹서비스도 요청이 없을 때 파드 0개까지 내리고, 요청이 들어오는 순간 다시 띄울 수 있다. HTTP add-on이 아직 v1.0 정식 릴리즈 전 베타 단계라 조심할 지점은 있지만, 우리 팀 내부 도구 클러스터에서는 몇 달째 잘 굴러가고 있어서 정리해둔다.
왜 그냥 HPA + minReplicas=0 은 안 되나
이 얘기부터 짚고 가야 한다. HPA만으로는 파드를 0개로 내릴 수 없다. minReplicas는 1이 최소값이다. 왜냐면 HPA가 스케일링 결정을 하려면 메트릭이 필요한데, 파드가 0개면 메트릭 자체가 안 나오기 때문이다. 닭이 먼저냐 달걀이 먼저냐.
KEDA는 이 문제를 외부 스케일러로 푼다. 파드가 0개여도 외부(메시지 큐, DB, HTTP 인터셉터 등)에서 신호를 가져와서 "지금 스케일 업 필요함"을 판단한다. HTTP add-on의 경우 인터셉터라는 프록시 파드가 항상 떠있으면서 들어오는 HTTP 요청을 큐잉하고, 실제 애플리케이션 파드가 0개일 때 스케일 업을 트리거한 다음 파드가 뜨면 요청을 전달한다.
아키텍처 이해하고 넘어가기
정말 짧게만 짚는다. 세 개 컴포넌트가 붙는다.
- Interceptor: L7 프록시. 서비스 앞단에 들어가서 요청 수를 세고, 파드가 0개면 요청을 버퍼링한다.
- Scaler: 인터셉터에서 메트릭을 긁어와서 KEDA operator에게 "지금 in-flight 요청이 N개, 목표는 M개"라고 알려준다.
- Operator: HTTPScaledObject CR을 watch 하다가, 이 CR을 만나면 뒤에 있는 Deployment에 대한 ScaledObject를 자동 생성해서 HPA를 관리한다.
여기서 헷갈리기 쉬운 게 하나 있다. 기존 애플리케이션이 그냥 Service를 통해 트래픽을 받고 있었다면, HTTP add-on을 붙인 뒤에는 클라이언트가 인터셉터를 거쳐서 오도록 라우팅을 바꿔야 한다. Ingress나 Gateway에서 인터셉터 서비스로 향하도록 백엔드를 바꾸는 작업이 필요하다는 뜻이다. 이걸 놓치면 파드가 0개일 때 그냥 502가 뜬다.
설치와 최소 세팅
Helm으로 KEDA 본체를 먼저 설치하고, add-on을 그 다음에 설치한다. 순서가 중요하다. add-on이 KEDA CRD에 의존하기 때문이다.
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm upgrade --install keda kedacore/keda \
--namespace keda --create-namespace \
--version 2.16.0
helm upgrade --install http-add-on kedacore/keda-add-ons-http \
--namespace keda \
--version 0.10.0
버전은 지금 시점(2026년 9월) 기준으로 KEDA 2.16, HTTP add-on 0.10 조합이 잘 맞는다. 예전에 HTTP add-on 0.7 시절에는 KEDA 마이너 버전 궁합이 살짝 까다로웠는데 요즘은 그나마 안정됐다.
HTTPScaledObject 작성
내부에서 쓰는 사내 위키 API를 예로 든다. 원래 Deployment 이름은 wiki-api, Service는 wiki-api-svc, 포트는 8080이라고 하자.
apiVersion: http.keda.sh/v1alpha1
kind: HTTPScaledObject
metadata:
name: wiki-api
namespace: internal-tools
spec:
hosts:
- wiki.internal.example.com
pathPrefixes:
- /
scaleTargetRef:
name: wiki-api
kind: Deployment
apiVersion: apps/v1
service: wiki-api-svc
port: 8080
replicas:
min: 0
max: 10
scaledownPeriod: 300
scalingMetric:
concurrency:
targetValue: 50
몇 가지 포인트.
hosts와 pathPrefixes가 라우팅 매칭 조건이다. 인터셉터는 자기한테 들어온 요청의 Host 헤더와 경로를 보고 어느 HTTPScaledObject에 해당하는지 결정한다. 그러니까 Ingress에서 인터셉터로 트래픽을 보낼 때 Host 헤더가 살아 있어야 한다. NGINX Ingress면 기본으로 살아 있는데, Envoy 계열에서 auto_host_rewrite 켜놨으면 인터셉터가 매칭을 못 한다. 이걸로 반나절 날린 적 있다.
scaledownPeriod: 300은 마지막 요청 이후 300초 동안 새 요청이 없으면 0으로 내린다는 뜻. 너무 짧게 잡으면 요청이 뜸한 시간대에 계속 콜드 스타트가 발생해서 사용자 체감 응답 시간이 튄다. 우리 팀 내부 도구는 5분으로 잡았는데, 사용자가 계속 있는 서비스면 15~30분으로 늘리는 게 맞다.
scalingMetric.concurrency.targetValue: 50은 파드 하나가 동시에 처리 중인 in-flight 요청이 50개 이상이면 스케일 업 트리거. 사용하는 애플리케이션의 실제 concurrency 한도에 맞춰야 한다. Node.js처럼 이벤트 루프 하나로 도는 앱이면 훨씬 작게, Go나 Java처럼 스레드/고루틴 여유 있는 앱이면 크게 잡는다.
Ingress에서 인터셉터로 트래픽 넘기기
인터셉터 서비스 이름은 기본으로 keda-add-ons-http-interceptor-proxy, 네임스페이스는 keda, 포트는 8080이다. Ingress에서 이걸 백엔드로 지정한다.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wiki-api
namespace: internal-tools
annotations:
nginx.ingress.kubernetes.io/upstream-vhost: wiki.internal.example.com
spec:
ingressClassName: nginx
rules:
- host: wiki.internal.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: keda-add-ons-http-interceptor-proxy
port:
number: 8080
Ingress를 애플리케이션 네임스페이스에 만들면서 백엔드는 keda 네임스페이스의 인터셉터 서비스를 가리켜야 하는데, 이게 기본으로는 안 된다. NGINX Ingress면 ExternalName 서비스를 하나 만들어서 우회하거나, 아니면 애플리케이션 네임스페이스에 인터셉터를 프록시해주는 Service를 추가로 만드는 방법을 쓴다. 나는 후자를 선호한다.
apiVersion: v1
kind: Service
metadata:
name: keda-interceptor-proxy
namespace: internal-tools
spec:
type: ExternalName
externalName: keda-add-ons-http-interceptor-proxy.keda.svc.cluster.local
ports:
- port: 8080
targetPort: 8080
그리고 Ingress의 backend를 keda-interceptor-proxy로 바꾸면 된다.
콜드 스타트 시간 줄이기
이게 실운영에서 제일 골치 아프다. 파드가 0개인 상태에서 첫 요청이 들어오면, 인터셉터가 요청을 큐에 잡아두고 KEDA가 Deployment의 replicas를 1로 올린다. 컨테이너 이미지가 풀되고, 프로세스가 뜨고, readiness probe가 통과하고, Service endpoint에 등록되고, 그때야 인터셉터가 큐잉한 요청을 흘려보낸다.
우리 앱의 경우 이 전체 시간이 15초쯤 걸렸다. 사용자 입장에서 15초를 기다리는 건 그냥 안 뜨는 거랑 다를 게 없다. 몇 가지 튜닝 포인트.
이미지 사이즈 줄이기 — 노드에 캐시가 없으면 이미지 풀 시간이 지배적이다. distroless 베이스에 바이너리만 넣어서 100MB 이하로 만들면 이 부분이 대부분 사라진다.
Startup probe 별도 설정 — Java 앱은 JVM 워밍업 때문에 startup probe를 넉넉하게 잡는데, 그러면 인터셉터의 요청 타임아웃과 부딪힌다. 인터셉터의 KEDA_HTTP_SCALER_TARGET_ADMIN_DEPLOYMENT 관련 환경변수와 라우팅 타임아웃(responseHeaderTimeout)을 애플리케이션 startup 시간보다 길게 잡아둬야 한다. 기본값은 짧다.
Warm pool — replicas.min을 0이 아니라 1로 두고, 특정 시간대에만 CronScaler로 0으로 내리는 하이브리드도 있다. 야간에만 0으로 내리고 업무시간에는 항상 1개는 떠있게. 이 방식이 사용자 경험 측면에서는 제일 낫다.
관찰해야 할 메트릭
프로메테우스로 인터셉터 자체 메트릭을 스크레이핑하면 대시보드가 그럴싸해진다. 특히 이 두 개는 무조건 봐야 한다.
keda_http_pending_request_count — 인터셉터가 현재 큐잉하고 있는 요청 수. 이게 계속 쌓이면 스케일 업이 못 따라가고 있다는 뜻이다.
keda_http_cold_start_duration_seconds — 파드 0개 상태에서 첫 응답까지 걸린 시간의 히스토그램. P95가 이상적으로는 3초 이하여야 한다. 이걸로 이미지 사이즈나 스타트업 튜닝 효과를 측정한다.
실전에서 조심할 것들
우리 팀이 도입하면서 배운 것들 몇 개.
WebSocket이나 SSE 같은 long-lived 커넥션은 in-flight 요청으로 계속 카운트된다. 하나만 접속해 있어도 concurrency 카운터가 1로 유지되니까 절대 0으로 안 내려간다. 이런 트래픽은 별도 Deployment로 분리하고 HTTPScaledObject를 안 붙이는 게 맞다.
Health check 트래픽도 조심해야 한다. 외부 uptime monitor가 1분마다 /healthz를 때리면 그 요청이 in-flight로 카운트돼서 파드가 안 내려간다. 인터셉터 앞에서 health check를 별도로 처리하거나, /healthz는 인터셉터를 우회해서 직접 Service로 라우팅하는 방법이 있다. 나는 후자로 뒀다.
버전 업그레이드할 때 HTTP add-on의 CRD가 조용히 바뀌는 경우가 있었다. 특히 0.7 → 0.8 사이에 scaleTargetRef.deploymentName이 deprecated 되고 scaleTargetRef.name으로 바뀌었는데, 이거 모르고 올렸다가 모든 HTTPScaledObject가 reconcile 실패로 뜬 적이 있다. 릴리즈 노트는 반드시 읽자.
아직 HTTP add-on이 v1.0 전이라서 프로덕션 크리티컬 워크로드에는 조심스러운 게 사실이다. 하지만 내부 도구, 데모 환경, 저트래픽 API에서는 노드 비용이 눈에 띄게 줄어든다. 우리 팀은 사내 도구 클러스터에서 t3.medium 4대를 t3.medium 2대로 줄였다. 야간과 주말에 파드가 대부분 0으로 내려가니까.
다음에는 HTTP add-on과 Gateway API를 어떻게 붙이는지도 정리해보려고 한다. 요즘 NGINX Ingress를 Gateway API로 갈아타는 팀이 많아서.