Knative activator는 왜 데이터 경로에 끼어들까 — KPA 내부 동작
서버리스를 K8s 위에 얹어보려고 Knative Serving을 한 분기 정도 운영 중이다. 처음엔 그냥 "0에서 N으로 자동 스케일되는 마법" 같은 거였는데, 정작 cold start P99가 800ms를 찍기 시작하면서 내부를 안 뜯어볼 수가 없었다. 사실 KPA(Knative Pod Autoscaler) 문서를 읽어도 표면만 도는 느낌이라, 컴포넌트들이 실제로 어떤 흐름으로 협업하는지 직접 추적해봤다.
이 글은 activator가 왜 데이터 경로에 들어왔다 빠졌다 하는지, EBC(Excess Burst Capacity)라는 값이 그 결정을 어떻게 끌어내는지, 그리고 panic window라는 약간 무서운 이름의 윈도우가 실제로 뭘 하는지를 정리한다. 운영하면서 만난 함정 두어 개도 함께.
0에서 1로 가는 길: activator가 등장하는 순간
Knative Service의 replicas가 0인 상태를 가정하자. 이때 들어오는 첫 요청은 누가 받아야 할까. Pod이 없으니 직접 라우팅할 곳이 없다. 여기서 activator가 등장한다.
대략의 흐름은 이렇다. Istio 또는 Kourier 같은 ingress gateway가 요청을 받고, KPA가 해당 Revision의 replicas를 0으로 줄여놨을 때는 SKS(ServerlessService)가 "proxy mode"로 설정돼 있다. proxy mode일 때 트래픽은 Revision Pod이 아니라 activator로 라우팅된다. activator는 요청을 자기 메모리 버퍼에 잠시 잡아두면서 동시에 autoscaler에 "지금 트래픽이 들어왔다"는 스탯을 푸시한다.
autoscaler는 이걸 받자마자 panic mode로 진입한다. 6초 짜리 panic window 안에 갑작스러운 트래픽이 잡히면 stable window(기본 60초)의 부드러운 평균 대신 panic window의 즉각적인 burst를 기준으로 desired pod 수를 계산한다. 그래서 0에서 1, 또는 0에서 N으로의 점프가 빠르다.
Pod이 Ready 되면 activator는 그제야 자기 버퍼에 잡아둔 요청을 새로 뜬 Pod으로 forward한다. 요청을 떨구지 않고 잡아둔다는 점이 활성기의 핵심 역할 중 하나다. 일종의 application-level 백프레셔라고 보면 된다.
activator가 데이터 경로에서 빠지는 조건: EBC 이야기
여기서 헷갈리는 부분. activator는 항상 데이터 경로에 있는 게 아니다. 트래픽이 어느 정도 안정되면 SKS가 "serve mode"로 전환되고, ingress는 Revision Pod으로 다이렉트 라우팅을 한다. activator는 빠진다.
전환을 결정하는 게 EBC(Excess Burst Capacity)다. 공식은 단순하다.
EBC = (현재 Ready Pod 수 × Pod당 capacity) - 현재 동시 요청 수 - target-burst-capacity
target-burst-capacity(TBC)는 KPA가 "이만큼은 갑작스러운 burst를 받아낼 여유로 잡아둬라"고 정해주는 값이다. 기본값은 211이다. 이 숫자는 의도된 매직 넘버인데, 옛날 디폴트가 200이었던 시절의 호환성 이슈로 살짝 올린 값이라고 알려져 있다. 어쨌든 의미는 "이만큼의 동시 요청을 추가로 받아낼 여유가 없으면 activator는 path에 남아 있어라"다.
EBC가 0 이상이면 충분한 여유가 있다고 판단해서 activator를 path에서 뺀다. 0 미만이면 activator를 path에 유지한다.
# config-autoscaler ConfigMap의 핵심 키들
target-burst-capacity: "211" # -1이면 activator를 항상 path에 유지
container-concurrency-target-default: "100"
panic-window-percentage: "10.0" # stable-window의 10% = 6초
panic-threshold-percentage: "200.0"
scale-to-zero-grace-period: "30s"
stable-window: "60s"
target-burst-capacity: -1을 주면 EBC 계산을 무시하고 activator가 영구적으로 path에 남는다. 이건 트래픽 패턴이 spiky한 워크로드에 유리한 옵션인데, latency overhead와 trade-off다. 우리 팀 케이스는 이미지 transcoding 워크로드인데 트래픽이 매시 정각에 1000 RPS 스파이크를 치는 패턴이라, 이걸 -1로 두니까 cold burst 시 throttle이 확실히 줄었다. 단, activator hop이 추가되면서 평균 latency는 8ms 정도 증가했다. 받아들일 만했다.
panic mode가 실제로 뭘 하는가
운영하다 보면 KPA의 두 가지 모드를 자주 마주친다. stable mode와 panic mode다.
stable mode는 stable window(60초) 동안의 평균 concurrency를 보고 desired replicas를 계산한다. 부드럽고 안정적이지만 느리다. panic mode는 panic window(6초) 동안의 평균을 보고 즉각 반응한다. 갑작스러운 burst를 잡으려는 의도다.
전환 조건은 panic-threshold-percentage다. 기본값 200%다. 즉 panic window의 부하가 현재 capacity의 200%를 넘기는 순간 panic mode 진입.
panic mode 진입 후에는 보수적으로 동작한다. 한 번 panic mode에 들어가면 60초 동안은 desired replicas를 줄이지 않는다. 갑작스러운 burst가 잠깐 잦아들었다고 바로 scale down하면, 그 다음 burst를 또 cold start로 잡아야 하니까. 이게 운영에서 만난 첫 번째 함정이었다.
테스트 환경에서 부하 테스트를 30초 돌리고 끄면, 부하가 사라진 뒤에도 한참 동안 Pod이 안 줄어들었다. "어디서 메모리 누수가?" 의심하다가 KPA 동작 의도라는 걸 깨달았다. panic mode가 60초 lockout을 가지고 있으니 당연한 거였다.
scale-to-zero가 데이터를 흘리지 않는 이유와, 그래도 흘릴 수 있는 경우
KPA의 scale-to-zero는 두 단계로 동작한다. 우선 idle-period(기본 60초) 동안 트래픽이 없으면 SKS를 serve mode에서 proxy mode로 전환한다. 그 다음 scale-to-zero-grace-period(기본 30s) 추가 대기 후에 Pod을 제거한다.
핵심은 이 순서다. 먼저 proxy mode 전환 → 그 다음 Pod 제거. 즉 Pod이 사라지기 30초 전부터는 이미 activator가 path에 들어와 있고, 그 사이 들어오는 요청은 activator가 받아서 새로 뜨는 Pod으로 forward한다.
근데 이게 항상 무손실은 아니다. 우리가 만난 케이스 한 가지. queue-proxy(각 Revision Pod에 사이드카로 들어가는 그 proxy)가 graceful shutdown을 기다리는 동안 들어온 요청을, 막 종료 중인 Pod에 잘못 보내버리는 race가 있었다. 1.13 어딘가에서 고쳐졌다는 얘기를 들었는데, 우리는 그 직전 버전이었고 한 달에 두세 번 502가 떴다. revision-timeout-seconds와 termination-grace-period를 좀 더 보수적으로 잡으면서 줄였지만, 완전히 없애려면 업그레이드가 답이었다.
또 하나, container concurrency 설정에 따라 동작이 미묘하게 다르다. containerConcurrency: 0은 soft limit이고 > 0은 hard limit이다. hard limit이면 queue-proxy가 명시적으로 큐잉을 하기 때문에 KPA가 RPS metric 대신 concurrency metric을 쓰면서 더 정확한 스케일링이 나온다. cold start 민감한 서비스라면 hard limit으로 가는 게 보통 낫다.
우리 팀이 결국 잡은 다이얼들
300번 정도 시행착오 끝에 정착한 설정 일부다.
# Service annotation
autoscaling.knative.dev/min-scale: "1" # 0→1 cold start 완전 회피
autoscaling.knative.dev/max-scale: "50"
autoscaling.knative.dev/target: "80" # concurrency target
autoscaling.knative.dev/target-burst-capacity: "200"
autoscaling.knative.dev/scale-down-delay: "5m" # burst 후 빠른 scale-down 방지
min-scale: 1은 일종의 항복이다. cold start를 진짜 못 견디는 endpoint 한두 개만 1로 두고 나머지는 0으로 풀어둔다. 서버리스의 미덕인 "안 쓰면 0" 철학을 일부 포기하는 거지만, P99 SLO를 지키려면 어쩔 수 없었다. 솔직히 처음엔 "이러면 서버리스 쓰는 의미가 뭐냐" 싶었는데, 50개 Revision 중 2개만 항상 1을 유지하고 나머지는 0~N으로 스케일되니까 전체 컴퓨트 비용은 여전히 절반 이하로 나왔다. 합리화한 셈이다.
scale-down-delay: 5m은 2026년 2월쯤 잡힌 기능을 적극적으로 쓰는 케이스다. burst가 끝난 뒤에도 Pod을 5분 동안 유지해서 후속 burst에 대한 cold start를 막는다. panic mode의 60초 lockout만으로는 부족한 워크로드에서 유용하다.
한계와 남은 숙제
KPA 내부를 다 뜯어보고 나서도 명쾌하지 않은 게 남는다. activator의 throughput 한계가 그중 하나다. 단일 activator pod이 대략 1000 RPS 정도까지는 무난한데, 그 위로 가면 activator HPA를 또 별도로 튜닝해야 한다. activator 자체가 데이터 경로에 들어왔을 때 병목이 될 수 있다는 게 약간 아이러니다.
그리고 HPA(metrics-server 기반) 대신 KPA를 쓰는 게 항상 옳은 결정인지도 다시 생각하게 된다. 트래픽이 안정적이고 cold start 민감도가 낮은 워크로드라면 HPA가 더 단순하고 디버깅도 쉽다. 모든 K8s 워크로드를 Knative로 끌고 갈 필요는 없다는 결론에 도달했다.
다음 글에서는 queue-proxy가 KPA에 metric을 어떻게 push하는지, websocket 기반 push와 pull 방식의 차이를 들여다보려고 한다. 거기서 또 의외의 함정을 만났는데, 그건 따로 정리할 만하다.
혹시 KPA 대신 다른 autoscaler(KEDA + Knative 조합 같은)를 운영해 본 분이 있다면 후기 댓글 환영합니다. 우리도 KEDA scaler를 일부 도입할까 고민 중이라.
'IT > Kubernets' 카테고리의 다른 글
| kubectl debug --profile, 이거 모르는 분 꽤 많더라 (0) | 2026.06.09 |
|---|---|
| KEDA로 SQS 워커 스케일링 했다가 메시지 절반이 사라진 이야기 (0) | 2026.06.07 |
| 새벽 3시, etcd db size 알람이 울렸다 (0) | 2026.06.04 |
| 1.36 Pod-level in-place resize, 사이드카 많은 Pod일수록 의미가 크다 (0) | 2026.05.31 |
| distroless 파드 디버깅, kubectl debug로 5초 (0) | 2026.05.28 |