◆ ESSAY
오전 트래픽이 몰리는 시간대의 그래프를 흉내 내서, 실험 클러스터의 요청률을 초당 10개에서 121개로 한 번에 올렸다. HPA(CPU 70% 목표)는 걸려 있었다. 그 순간부터 스톱워치로 잰 기록이 이렇다. HPA가 목표 초과를 알아채고 replicas를 고쳐 쓴 것이 22.5초 뒤, 새 파드 4개는 그 판단과 같은 초 안에 만들어졌고, Ready가 된 것이 22~23초 뒤. 그리고 새 파드가 첫 실제 요청을 받은 것은 31.5초 뒤였다. 그 사이의 부하는 전부 기존 파드 3개가 버텼고, p95는 9ms에서 74ms까지 밀렸다가 확장이 끝나고서야 내려왔다.
1편에서 "스케일 아웃은 자동이지만 즉시는 아니다"라고 한 줄로 적고 지나갔는데, 이 글은 그 한 줄의 내역서다. 31.5초가 어떤 구간들로 이루어져 있는지, 각 구간은 줄일 수 있는 것인지 없는 것인지를 스톱워치로 분해한다. 그런데 미리 말해 두면, 이 온건한 그림이 이야기의 전부가 아니다. 부하를 더 올리자 오토스케일러가 아예 반응을 멈추는 구간이 나타났다. 처음에는 과부하가 오토스케일러의 눈을 가리는 악순환으로 보였는데, 부검해 보니 공범이 하나 더 있었다. 그 이야기는 후반부의 몫이다.
이 글은 "프론트엔드 개발자가 알아야 할 쿠버네티스" 시리즈의 다섯 번째 편이다. 용어가 낯설면 1편의 개념 지도를, 파드와 컨테이너는 2편을, 트래픽 경로와 conntrack은 3편을, 파드의 종료는 4편을 먼저 읽는 것을 권한다.
측정 환경: Apple M5 macOS 위의 colima VM(4 CPU/8GB), kind v0.32.0(kindest/node v1.36.1, Kubernetes v1.36.1), metrics-server(해상도 15초), 앱은 Next.js 16.2.12 standalone(node:24-slim, Node v24.19.0)으로 앞 편들과 같다. 실험용 Deployment는 4편 최종 조립본(preStop sleep 3초, 결원 없는 보폭, readiness 5초)을 계승하되 requests CPU 200m/limit 400m으로 줄였다. 물리 코어가 4개뿐인 단일 머신에서 "스케일 아웃이 실제로 지연을 회복시키는" 규모를 만들기 위한 값이다. HPA는 autoscaling/v2, CPU 70%, min 3/max 12. 부하는 클러스터 안 client 파드에서 개루프(고정 요청률)로 흘렸다. 폐루프(동시성 고정) 부하는 4동시만으로도 150rps가 나와 실험 전에 HPA를 깨워 버린다는 것을 사전 검증에서 배웠고, 응답이 늦어져도 유입률이 유지되는 개루프가 트래픽 스파이크의 실제 모형이기도 하다. KEDA는 v2.20.2, 이미지는 kind 네트워크에 붙인 로컬 레지스트리(registry:2)에서 받는다. HPA 컨트롤러 소스 인용은 kubernetes v1.36.1 태그 기준이다. 측정 스크립트와 raw 로그는 별도 보관했다.
구간을 분해하려면 먼저 루프의 생김새를 알아야 한다. HPA 컨트롤러는 15초마다 한 번씩 깨어나(기본값), 대상 파드들의 메트릭을 읽고, 공식 하나를 계산하고, 필요하면 Deployment의 replicas를 고쳐 쓴다. 공식은 이것이 전부다.
desiredReplicas = ceil( currentReplicas × 현재 사용률 / 목표 사용률 )
파드 3개가 목표 70%의 두 배인 140%로 돌고 있다면 ceil(3 × 140/70) = 6개. 여기서 짚을 것이 셋 있다.
첫째, 분모의 "사용률"은 requests 기준이다. 1편에서 예고한 연결이 여기서 실측으로 확인되는데, limit이 아니라 requests 대비 비율이라서 이번 실험(requests 200m, limit 400m)의 파드는 사용률이 200%까지 올라갈 수 있다. requests가 실사용과 동떨어져 있으면 이 공식 전체가 어긋난다.
둘째, 읽는 메트릭에는 이미 두 겹의 지연이 있다. kubelet의 통계를 metrics-server가 15초 간격으로 긁고(이 클러스터의 --metric-resolution=15s), HPA가 그것을 다시 15초 간격으로 읽는다. 두 루프의 위상이 맞물리는 정도에 따라, 부하가 올라도 HPA가 그것을 "볼" 때까지 최대 30초 안팎이 걸린다. 스파이크가 스크레이프 직후에 시작되면 다음 스크레이프까지 15초를 기다리고, 그 값이 HPA의 다음 판단 주기에 걸리기까지 다시 최대 15초가 쌓이는 식이다. 뒤의 실측에서 이 감지 창이 전체 지연의 지배항으로 나온다.
셋째, tolerance라는 무시 구간이 있다. 현재/목표 비율이 1.0에서 ±0.1 안이면 HPA는 움직이지 않는다. 사용률이 70~77% 사이를 오가는 동안 replicas가 출렁이지 않는 이유다. 흥미로운 것은 이 0.1이 오랫동안 클러스터 전역 설정이었는데, 이번 실험 클러스터(v1.36)에서는 HPA 오브젝트별로 spec.behavior.scaleUp.tolerance를 넣어 보니 그대로 수용되어 저장됐다. v1.33에서 알파로 들어온 퍼-HPA tolerance가 기본 게이트로 살아 있다는 뜻이다. 직접 확인한 기록은 이렇다.
$ kubectl patch hpa autoscale-lab --type=merge \
-p '{"spec":{"behavior":{"scaleUp":{"tolerance":"0.2"}}}}'
horizontalpodautoscaler.autoscaling/autoscale-lab patched
$ kubectl get hpa autoscale-lab -o jsonpath='{.spec.behavior.scaleUp}'
{"policies":[{"periodSeconds":15,"type":"Pods","value":4},
{"periodSeconds":15,"type":"Percent","value":100}],
"selectPolicy":"Max","stabilizationWindowSeconds":0,"tolerance":"200m"}
거부 없이 저장됐다(0.2가 수량 표기 "200m"으로 적혀 있다). 이 덤프는 덤으로 다음 문단의 기본 확장 정책, "15초마다 4개 또는 100% 중 큰 쪽"의 원문이기도 하다.
그리고 계산 결과를 바로 적용하지 않고 한 번 거르는 behavior가 있다. 기본값 기준으로 확장은 15초마다 "4개 또는 현재의 100% 중 큰 쪽"까지, 축소는 지난 300초의 계산값 중 최댓값으로만(안정화 창) 움직인다. 확장은 빠르게, 축소는 신중하게라는 설계인데, 이 축소 쪽 장치는 뒤의 스케일 다운 절에서 실물로 본다.
여기까지가 루프의 전부다. 중요한 것은 HPA가 하는 일이 replicas 숫자를 고쳐 쓰는 것까지라는 사실이다. 파드를 실제로 띄우는 것은 2편에서 본 사슬(Deployment → ReplicaSet → 스케줄러 → kubelet)이고, 트래픽이 새 파드에 닿는 것은 3편의 세계(readiness → EndpointSlice → 각 노드의 규칙)다. 스파이크에서 첫 요청까지의 시간은 이 세 층의 합이다.
서두의 실측을 구간별로 편다. 조건은 이렇다. 베이스라인 초당 10요청이 흐르는 상태에서 T0에 초당 111요청을 더 얹었다(합계 121rps, 약 12배). 관측은 0.5초 간격 폴링과 파드 conditions의 타임스탬프, 그리고 응답에 실린 파드 이름을 썼다.
| 구간 (T0 = 스파이크 시작) | 시각 (실측) | 소요 |
|---|---|---|
| HPA가 사용률>70%를 처음 관찰, desired 3→7 | +22.5초 | 22.5초 |
| 새 파드 4개 생성(스케줄링 완료) | +21~22.5초 (동시) | 0초 안팎 |
| 새 파드 Ready (readiness 통과) | +22~23초 | 1~2초 |
| 새 파드에 첫 실트래픽 도착 (가장 빠른 파드) | +31.5초 | +9초 |
| p95 회복 (74ms 피크 → 60ms 안정) | +45초 안팎 | - |
| 2차 확장 (desired 7→8, 파드 1개 추가) | +51초 | - |
파드 생성은 HPA가 replicas를 고쳐 쓴 결과라 감지보다 앞설 수 없는데, 기록상으로는 생성 시각이 +21초로 먼저 찍힌다. 파드 생성 시각은 초 단위로 잘리고 HPA 필드는 0.5초 폴링으로 읽어서 1초 안팎 늦게 보이는, 채널 사이의 반올림 차다. 실제 순서는 "desired 쓰기 → 같은 초 안에 파드 생성"이고, 표의 앞 두 줄은 HPA 이벤트 원문으로도 남아 있다.
$ kubectl get events --field-selector involvedObject.kind=HorizontalPodAutoscaler ...
08:22:34 New size: 7; reason: cpu resource utilization (percentage of request) above target
08:23:04 New size: 8; reason: cpu resource utilization (percentage of request) above target
에러는 24,265요청 중 0개였고, p95는 베이스라인 9ms에서 첫 15초 동안 74ms까지 밀렸다가 60ms 선에서 안정됐다. 이 표에서 가져갈 것이 세 가지다.
첫째, 지배항은 감지다. 전체 31.5초 중 22.5초가 "HPA가 알아채기까지"였다. 스크레이프 15초와 판단 15초가 겹친 위상차로, 이 구간은 운이 좋으면 15초 근처, 나쁘면 30초까지 벌어진다. 반면 파드 생성부터 Ready까지는 1~2초에 끝났다. 이미지가 노드에 있고(프리로드) 앱이 가벼우면 기동은 병목이 아니다.
둘째, Ready와 첫 트래픽은 다른 사건이다. 신규 파드 다섯 개를 하나씩 추적하면 이 간극이 잘 보인다.
| 신규 파드 (T0 기준) | 생성 | Ready | 첫 실트래픽 |
|---|---|---|---|
| 1호 | +21초 | +23초 | +31.5초 |
| 2호 | +21초 | +23초 | +58.2초 |
| 3호 | +21초 | +22초 | +97.4초 |
| 4호 | +21초 | +22초 | +168.9초 |
| 5호 (2차 확장) | +51초 | +57초 | 관찰 창 내 없음 |
Ready는 1~2초 만에 일제히 끝났는데, 첫 요청은 31초에서 169초까지 흩어졌고 하나는 끝내 일을 받지 못했다. 3편에서 본 그대로다. 분배는 커넥션이 태어날 때 한 번뿐이라, keep-alive로 이미 맺어진 커넥션들은 기존 파드에 붙어 있고, 새 파드는 새 커넥션이 열릴 때만 일을 받는다. 실제로 스파이크 2분 뒤의 분포를 세어 보니 살아남은 기존 파드 3개가 트래픽의 54%를 쥐고 있었고, 가장 바쁜 파드와 가장 한가한 파드의 차이가 3.4배였다(1,940 대 577). 레플리카 수는 8개로 늘었는데 부하는 고르게 퍼지지 않는, 스케일 아웃과 로드밸런싱이 별개라는 증거다.
셋째, 그 사이를 버티는 것은 기존 파드다. 22.5초의 감지 창 동안 기존 3개 파드가 12배 부하를 받아냈다. 이번에는 p95 74ms로 버텼지만, 버티지 못하면 어떻게 되는지가 뒤의 실명 절이다. 사이징 글이 minReplicas를 "스파이크 흡수 여력"으로 정하라고 한 이유가 이 창에 있다.
내역서가 나왔으니 각 구간에 개입해 본다. 같은 스파이크를 조건만 바꿔 반복했다.
개입 1: 미리 띄워 둔다 (minReplicas 6). 스파이크 전부터 6개를 유지하니 확장 자체가 필요 없어졌고, desired는 T0 전에 이미 6이었다. 그런데 p95의 첫 15초는 75ms로, 3개에서 시작한 기본 실험(74ms)과 사실상 같았다. 이 규모의 스파이크는 3개로도 버틸 수 있었기 때문에, 미리 띄워 둔 여유가 지연 그래프에는 나타나지 않은 것이다. 미리 띄우기의 가치는 평시 지연이 아니라 한계 상황의 여유에 있고, 그 한계 상황은 실명 절에서 온다.
개입 2: 한 번에 크게 띄운다 (behavior 튜닝). scaleUp 정책을 "15초마다 4개"에서 "15초마다 12개까지"로 풀고 같은 스파이크를 반복했다. desired가 3에서 단번에 12로 뛸 것을 기대했는데, 실측은 계단이었다. 3→5(+22.6초)→7(+37.4초)→10(+82.5초)→12(+128초). behavior는 상한을 풀 뿐이고 매 주기의 desired는 여전히 공식이 정하기 때문이다. 첫 판단 시점에 관찰된 사용률이 101%였으니 ceil(3 × 101/70) = 5가 되고(기본 실험에서는 이 첫 관찰이 142%였다, 위상 운이다), 방금 뜬 파드는 메트릭이 아직 없어 계산 표본에 들어오지 못하니(이 규칙의 정체는 실명 절에서 소스로 확인한다) 다음 계단도 기존 표본 기준으로 한 단씩만 오른다. 실제로 두 번째 계단이 ceil(3 × 153/70) = 7로 정확히 맞아떨어진다. 결과적으로 Ready 6개 도달은 +43.9초로 기본(+23.5초)보다 오히려 늦었고, 파드들이 연달아 기동한 구간(+30~45초)에서는 p95가 421ms로 튀었다. 물리 CPU가 4코어뿐인 환경이라 기동(Node 부팅, Next 초기화)이 서빙과 경합한 것인데, 노드가 넉넉한 실전 클러스터에서는 정도가 덜하겠지만 같은 노드에 몰려 뜨는 파드들 사이에서는 방향이 같은 이야기다. 정리하면 behavior 상한을 풀어도 공식과 새 파드의 메트릭 공백이 계단을 만들어 더 빨라지지 않았고, 기동 경합의 역효과만 관측됐다. "더 빨리 더 많이"는 기동 비용이 공짜일 때만 공짜다.
개입 3: 이미지를 미리 두지 않는다 (콜드 노드). 반대 방향의 개입도 재 봤다. 지금까지의 실험은 이미지가 모든 노드에 프리로드된 상태라 풀(pull) 구간이 0이었는데, 노드 하나에서 앱 이미지의 레이어를 전부 지우고 로컬 레지스트리에서 다시 받게 하면 이렇게 된다.
| 콜드 노드에서의 풀 (로컬 레지스트리) | 소요 시간 |
|---|---|
| standalone 이미지 (전송 69MB) | 0.23초 |
| naive 이미지 (전송 590MB) | 7.7초 |
로컬 레지스트리라 네트워크 왕복이 없는, 압축 해제 비용 중심의 하한값이다. 실제 원격 레지스트리라면 여기에 다운로드 시간이 얹힌다. 스케일 아웃이 낯선 노드(방금 추가된 노드, 이미지가 밀려난 노드)에 파드를 놓는 순간 이 시간이 감지 창 뒤에 그대로 더해지고, 그 크기는 2편에서 잰 전송 크기에 비례한다. 이미지 감량이 오토스케일링의 속도 문제이기도 한 이유다.
정리하면 이렇다. 감지 창(15~30초)은 HPA의 구조라 줄이기 어렵고, 기동(1~2초)은 이미 짧으며, 억지로 몰아서 띄우면 오히려 손해를 본다. 개입할 자리는 결국 두 곳이다. 감지 창을 버틸 여유(minReplicas와 requests 사이징), 그리고 기동을 짧게 유지하는 준비(작은 이미지, 프리로드).
하나 더, 이 타임라인 앞에 훨씬 큰 시간이 붙을 수 있다는 것도 적어 둔다. 지금까지의 실험은 파드를 받아줄 노드가 항상 있었지만, 예비 실험에서 maxReplicas를 여유 없이 잡았을 때는 desired 11 중 2개가 Pending으로 굳는 것을 봤다. requests의 합이 노드가 수용할 수 있는 양을 넘은 것이다. 1편에서 파드 스케일링과 노드 스케일링은 층이 다르다고 했는데, 이 상황이 그 경계다. Pending을 본 노드 오토스케일러(Cluster Autoscaler, Karpenter)가 노드를 새로 띄우는 데는 분 단위가 걸리고, 그 시간이 이 글의 모든 타임라인 앞에 그대로 더해진다. 단일 머신 kind에서는 재현할 수 없어 실측은 못 했지만, 감지 창 뒤에 붙는 가장 큰 변수라는 것은 적어 둘 필요가 있다. 파드 하나의 크기를 조절하는 VPA(Vertical Pod Autoscaler)라는 다른 축도 있는데, 이 시리즈의 범위 밖이라 이름만 남긴다.
이제 서두에 예고한 부검이다. 이 현상은 사실 계획에 없었다. 부하기의 보호 장치(유입 상한) 없이, 응답이 밀리든 말든 keep-alive 커넥션 위로 요청을 계속 쌓는 초기 실험에서 처음 만났다. 한계를 한참 넘긴 그 부하에서 HPA는 더 크게 반응하기는커녕 아예 반응을 멈췄다.
| 사건 (T0 = 한계 초과 부하 시작) | 시각 (실측) |
|---|---|
| p95가 10ms → 3.3초로 폭주 시작 | +15초 이내 |
| 파드 3개 전부 NotReady (readiness probe 연쇄 타임아웃) | 스파이크 초반 |
| HPA "did not receive metrics ... pods might be unready" | 반복 발생 |
| desired 첫 변화 (3→7) | +146.7초 |
| Ready 6개 복귀 | +172.4초 |
| 신규 파드 첫 트래픽 | +157~180초 |
| p95 889ms까지 회복 | +225초 |
desired가 146.7초 동안 3에서 꼼짝하지 않았다. 그 사이 p95는 18.7초까지 치솟았고, 20,138요청 중 27%가 실패했다(TIMEOUT 5,255개가 대부분). 부하가 오토스케일러를 깨우기는커녕 재워 버린 것이다. 그 구간의 HPA 이벤트 원문이 사인을 그대로 말해 준다.
$ kubectl describe hpa autoscale-lab # 실명 구간의 이벤트 발췌
Warning FailedGetResourceMetric (x10 over 6m36s) horizontal-pod-autoscaler
failed to get cpu utilization: did not receive metrics for targeted pods
(pods might be unready)
Warning FailedComputeMetricsReplicas (x10 over 6m36s) horizontal-pod-autoscaler
invalid metrics (1 invalid out of 1), first error is: failed to get cpu
resource metric value: ...
사슬의 앞부분은 분명하다. 과부하로 응답 큐가 깊어지면, readiness probe의 요청도 같은 이벤트 루프에 줄을 선다. probe의 기본 타임아웃은 1초라, 앱이 멀쩡히 일하고 있어도 검사는 3연속 실패하고 파드는 NotReady가 된다(3편의 탈락 타임라인 그대로다). 그리고 이벤트가 말하는 대로 HPA는 메트릭을 받지 못해 판단을 보류했다. 여기까지가 관찰이고, 남는 질문은 연결부다. NotReady가 되면 정말 메트릭에서 빠지는가? HPA 컨트롤러 소스로 내려가 봤다. 이벤트의 그 문장은 pkg/controller/podautoscaler/replica_calculator.go(v1.36.1)의 여기서 나온다.
removeMetricsForPods(metrics, ignoredPods)
removeMetricsForPods(metrics, unreadyPods)
if len(metrics) == 0 {
return 0, 0, fmt.Errorf("did not receive metrics for targeted pods (pods might be unready)")
}
unready로 분류된 파드의 메트릭을 표본에서 지우고, 남은 표본이 0이면 저 에러와 함께 판단을 보류한다. 그럼 무엇이 unready인가. 같은 파일의 groupPods가 CPU 메트릭에 대해 이렇게 가른다.
if resource == v1.ResourceCPU {
var unready bool
_, condition := podutil.GetPodCondition(&pod.Status, v1.PodReady)
if condition == nil || pod.Status.StartTime == nil {
unready = true
} else {
if pod.Status.StartTime.Add(cpuInitializationPeriod).After(time.Now()) {
unready = condition.Status == v1.ConditionFalse || metric.Timestamp.Before(condition.LastTransitionTime.Time.Add(metric.Window))
} else {
unready = condition.Status == v1.ConditionFalse && pod.Status.StartTime.Add(delayOfInitialReadinessStatus).After(condition.LastTransitionTime.Time)
}
}
if unready {
unreadyPods.Insert(pod.Name)
continue
}
}
읽어 보면 "NotReady면 뺀다"가 아니다. 조건이 두 갈래다. 기동 후 cpuInitializationPeriod가 지나지 않은 파드, 즉 생긴 지 얼마 안 된 파드는 NotReady면 빠지고, Ready여도 상태 전이 직후의 낡은 메트릭이면 빠진다. CPU 사용량에 기동 비용이 섞인 어린 파드를 판단에 넣지 않으려는 유예 장치고, 그 길이는 kube-controller-manager 플래그(--horizontal-pod-autoscaler-cpu-initialization-period)의 기본값으로 5분이다. 반면 유예가 끝난 파드는 "한 번도 Ready였던 적이 없는" 경우에만 빠진다. 다시 말해 오래 돌던 파드는 과부하로 NotReady가 되어도 표본에 남는다.
그럼 실측의 실명은 왜 일어났는가. 파드들의 나이에 답이 있었다. 매 회차 실측 전에 설정을 바꾸며 롤아웃을 했기 때문에, 스파이크 시점의 서빙 파드들은 전부 생후 2~3분, 전원이 5분 유예 창 안이었다. 그리고 결정적으로, 실명이 풀린 시각이 회차마다 "서빙 파드 생성 + 5분"과 초 단위로 일치한다.
| 실명 회차 | 서빙 파드 생성 | 생성 + 5분 | desired 첫 변화 (실측) |
|---|---|---|---|
| 최초 발견 (유입 상한 없음) | 08:04:01~08 | 08:09:01~08 | +146.7초 = 08:09:13 |
| 222rps, probe 3초 | 10:00:15 | 10:05:15 | +197.0초 = 10:05:15 정각 |
| 222rps, probe 1초 | 10:14:55~56 | 10:19:55~56 | +196.8초 = 10:20:01 |
동결의 길이는 부하의 깊이가 정한 것이 아니라, 파드들이 5분이 되기까지 남은 시간이었다. 이 해석이 맞다면 5분을 넘긴 파드로는 같은 부하에서 실명이 없어야 한다. 그래서 마지막 검증으로, 같은 222rps를 같은 probe 설정에서 파드만 생후 63분으로 묵힌 뒤 다시 쐈다.
| 같은 222rps 스파이크 (probe timeout 3초) | 생후 ~2분 파드 (배포 직후) | 생후 63분 파드 |
|---|---|---|
| desired 첫 변화 | +197.0초 (생성+5분) | +26.8초 (정상 감지 창) |
| 확장 전개 | 부하 안에서는 3→6이 전부 | +26.8초 6, +41.7초 9, +72.1초 12 |
| 실패율 | 19.1% (7,342/38,399) | 7.4% (1,711/22,993) |
늙은 파드들도 과부하 속에서 똑같이 NotReady를 오갔지만 표본에서 빠지지 않았고, HPA는 첫 감지 창이 끝나자마자 움직였다. 남은 실패 7.4%는 한계의 1.6배 부하라 12개까지 확장이 끝나는 +194초까지 큐가 깊었던 대가다. 확장이 살아 있어도 한계 초과의 첫 2분은 아프다. 다만 눈을 잃지는 않는다.
그러니 이 현상의 정확한 이름은 "과부하 실명"이 아니라 **"배포 직후의 실명"**이다. 롤아웃이 끝난 직후에는 전 파드가 5분 유예 창 안에 있고, 그 창에서 스파이크가 와 probe까지 무너지면 HPA는 표본 전체를 잃는다. 두 조건이 겹쳐야 하지만 드문 조합은 아니라고 생각한다. 트래픽 이벤트에 맞춰 직전에 배포하는 일은 흔하고, 그 배포가 만든 어린 파드들이 바로 그 이벤트의 첫 스파이크를 받기 때문이다. 4편에서 liveness에 의존성을 걸면 장애가 재시작 폭풍이 된다고 했는데, 이것은 그 readiness 판이기도 하다. probe가 서빙과 같은 큐를 쓰는 한, 깊은 과부하는 트래픽 이탈과 오토스케일러 실명을 동시에 부른다. liveness였다면 여기에 재시작까지 겹쳤을 것이다.
그럼 probe는 어떤 부하에서 무너지는가. 유입 상한을 둔 개루프 부하로, 같은 배포 직후 조건에서 강도만 바꿔 경계를 찾아봤다.
| 개루프 스파이크 (배포 직후, probe timeout 기본 1초) | 143rps (한계 언저리) | 222rps (한계의 약 1.6배) |
|---|---|---|
| desired 첫 변화 | +32.2초 (정상 감지) | +196.8초 (파드 생성+5분에야) |
| p95 최악 구간 | 1.6초 | 5.6초 |
| 실패율 | 0% (0/30,064) | 26.3% (8,112/30,838) |
한계의 기준은 실측이다. 이 앱은 요청 하나에 CPU 약 8.7ms를 쓰므로 파드 3개의 limit(1.2코어)으로는 초당 약 138요청이 상한이고, 143rps는 그 언저리, 222rps는 약 1.6배다. 143rps에서는 실명이 없었다. 파드들이 똑같이 5분 유예 창 안이었는데도, 큐가 1.6초까지만 밀려 probe가 버텼고(NotReady 판정에는 5초 간격 3연속 실패, 최소 십수 초가 필요하다), Ready를 유지한 파드의 메트릭은 유예 창 안에서도 표본에 남기 때문이다. 반면 222rps에서는 probe 1초가 무너져 전 파드가 표본에서 빠졌고, 5분 창이 끝날 때까지 실패가 26%까지 쌓였다. 즉 실명의 조건은 곱이다. 파드가 5분 유예 창 안일 것, 그리고 그 안에서 probe가 무너질 만큼 큐가 깊을 것.
probe 쪽에 시간을 벌어 주는 처방으로 보이는 것이 timeoutSeconds 상향이다. readiness의 타임아웃을 1초에서 3초로 올리고 같은 222rps를 반복했다.
| 같은 222rps 스파이크 (배포 직후) | probe timeout 1초 | probe timeout 3초 |
|---|---|---|
| desired 첫 변화 | +196.8초 | +197.0초 |
| p95 최악 구간 | 5.6초 | 2.3초 |
| 실패율 | 26.3% (8,112/30,838) | 19.1% (7,342/38,399) |
해동 시각은 1초든 3초든 각자 파드의 "생성+5분"으로 사실상 같았다. 타임아웃 상향이 바꾼 것은 실명의 길이가 아니라 그동안의 피해다. probe가 좀 더 버텨 Ready를 유지하는 구간이 길어진 만큼 트래픽 이탈이 줄었을 뿐(실패 26.3%→19.1%, p95 꼬리 5.6초→2.3초), 큐가 3초마저 넘겨 밀리는 부하에서 5분 창을 벗어나게 해 주지는 못한다. 더 근본적으로는 probe 응답이 서빙 큐를 우회하게 만들거나 유입 쪽의 백프레셔가 필요한데, 그 설계는 이 글의 범위를 넘는다.
덧붙여 둘 것이 하나 있다. probe 1초 회차에는 직전 회차에서 살아남은 생후 10분짜리 파드가 하나 섞여 있었다. 소스의 규칙대로라면 이 파드는 표본에 남아 실명을 막았어야 하는데, 동결은 그대로 지속됐다. 물리 4코어가 포화되면서 메트릭 수집 경로(kubelet 통계 → metrics-server) 자체가 이 파드의 표본을 놓친 것으로 보인다. 단일 머신 실험 환경의 특성이 섞인 결과라 그대로 일반화할 수는 없지만, 깊은 과부하가 판단 재료의 공급망까지 흔들 수 있다는 것은 적어 둔다.
여기서 가져갈 것은 진단 쪽이다. 스파이크 때 HPA가 안 움직였다면 desired가 아니라 kubectl describe hpa의 이벤트부터 볼 것. "pods might be unready"가 찍혀 있다면 probe 설정과 함께 마지막 롤아웃 시각을 볼 것. 롤아웃이 5분 안이었다면 이 글의 재현과 같은 상태다.
여기까지의 구조를 비유 하나로 정리해 둔다. 손님이 몰리는 식당에서 주방장을 더 부르는 일을 생각하면 된다.
| 식당 | 오토스케일링 |
|---|---|
| 15분마다 홀을 둘러보는 매니저 | 15초짜리 HPA 루프 |
| "테이블 대비 주문이 많다"는 판단 기준 | requests 대비 사용률과 목표치 |
| 대기 중인 주방장에게 전화하고 출근을 기다리는 시간 | 파드 기동 (이미지가 있으면 짧다) |
| 새 주방장에게는 새로 앉은 테이블의 주문만 가는 것 | keep-alive 커넥션의 분배 고정 (3편) |
| 갓 들어온 주방장은 5분간 실적 평가에서 빼 주는 수습 규칙 | 기동 후 5분의 메트릭 유예 |
| 전원이 갓 들어온 날 홀이 터져 평가표가 백지가 되는 것 | 배포 직후의 실명 (5분 창 × probe 붕괴) |
| 손님이 빠져도 30분은 두고 보다가 한 명씩 돌려보내기 | 스케일 다운 안정화 창 (300초) |
| 예약 장부를 보고 회식 시간 전에 미리 불러두기 | KEDA cron 선제 확장 |
비유에서 챙길 것은 두 가지다. 매니저의 순회 간격(감지 창)은 주방장의 출근 속도(기동)와 무관한 별개의 시계라는 것, 그리고 가장 확실한 대응은 몰릴 시간을 미리 아는 것(선제 확장)이라는 것이다.
부하가 빠진 뒤의 이야기도 스톱워치로 쟀다. 12개까지 확장된 상태에서 스파이크를 끊으면, 사용률은 즉시 목표 아래로 떨어지지만 replicas는 바로 내려오지 않는다.
| 사건 (T1 = 부하 제거) | 시각 (실측) |
|---|---|
| 사용률 목표 아래로 | +15초 이내 |
| desired 12 유지 | 304초간 |
| desired 12 → 8 | +304초 |
| desired 8 → 3 | +319초 |
관측 로그의 해당 구간은 이렇다.
+299s hpa=[12 0] deploy=[12 12] # 사용률은 0%인데 desired는 아직 12
+305s hpa=[8 0] deploy=[8 8] # 창이 밀리며 첫 계단
+320s hpa=[3 0] deploy=[3 3] # 두 번째 계단, 파드 5개의 종료 시퀀스 시작
5분 넘게 12개가 그대로 있다가, 15초 간격의 계단 두 개로 3까지 내려왔다. 이것이 앞에서 말한 **안정화 창(기본 300초)**의 실물이다. HPA는 매 주기의 계산값을 버리지 않고 지난 300초 치를 들고 있다가, 축소 방향으로는 그중 최댓값만 적용한다. 창이 시간을 지나며 밀리면 창 안의 최댓값이 12 → 8 → 3으로 낮아지고, replicas가 그것을 계단처럼 따라간 것이 위의 기록이다. 스파이크가 출렁이는 트래픽에서 내렸다가 다시 올리는 왕복(그 사이 감지 창을 또 치르는)을 막는 장치라, 이 5분의 보수성은 비용이 아니라 보험으로 읽는 것이 맞다고 생각한다.
그리고 이때 내려가는 파드 9개는 4편의 종료 시퀀스를 그대로 탄다. SIGTERM과 라우팅 갱신의 경주, preStop의 3초, keep-alive 커넥션의 인질극까지 전부다. 스케일 다운이 잦은 서비스라면 4편의 처방들이 배포만이 아니라 평상시 오토스케일링에서도 매일 작동하고 있는 셈이다.
HPA의 메트릭으로 CPU 대신 메모리를 쓰면 어떻게 되는가. Node.js에서는 이것이 함정이라는 것을 사이징 글의 V8 습성으로 예고해 뒀는데, 이번에 HPA와 붙여서 끝까지 재현했다. memory requests 128Mi인 Deployment(유휴 RSS 36Mi, 사용률 28%)에 메모리 40% 목표의 HPA를 걸고, 부하를 3분쯤 흘렸다 끊었다.
| 사건 (메모리 40% 목표, 2레플리카 시작) | 관찰 (실측) |
|---|---|
| 유휴 상태 | 파드당 RSS 36Mi, 사용률 28% |
| 부하 유입 (약 3분) | RSS 71~89Mi, 사용률 54%로 목표 초과 |
| 스케일 아웃 | desired 2 → 3 → 5 → 7 → 8 (max 도달) |
| 부하 제거 후 10분 | 부푼 파드 58~64Mi 고착, 평균 사용률 39~40% |
| 스케일 인 | 없음. 8개 그대로 (관찰 종료까지) |
관측 로그에서 세 장면만 뽑으면 이렇다.
(부하 전) hpa=[2 28] mem=[36Mi 36Mi]
(부하 중) hpa=[5 54] mem=[85Mi 89Mi | 새 파드 36Mi]
(부하 후 10분) hpa=[8 40] mem=[58~64Mi x4 | 새 파드 40~46Mi]
두 개의 메커니즘이 겹쳐 있다. 첫째는 V8이다. 부하로 한 번 부푼 힙과 RSS를 V8은 OS에 잘 돌려주지 않는다. 사이징 글에서 실측한 습성 그대로, 부하를 겪은 파드들은 10분이 지나도 유휴값의 1.7배인 58~64Mi에 머물렀다. 둘째는 평균의 희석이다. 확장으로 들어온 새 파드들은 부하를 겪지 않아 40Mi 안팎이고, 부푼 파드들과 섞인 평균 사용률이 39~40%, 정확히 목표(40%)의 tolerance 띠 안에 앉는다. 내릴 근거도 올릴 근거도 없는 값이라 HPA는 8개를 그대로 유지한다. CPU는 일이 끝나면 0으로 돌아가는 소모량이지만 Node의 RSS는 한 번 오르면 잘 내려오지 않는 수위이고, 거기에 평균 희석까지 겹치면 스케일 인은 구조적으로 불발된다. Node 서비스에서 메모리는 스케일링 신호가 아니라 OOM 방어선(limit)과 사이징의 재료로만 쓰는 것이 안전하다.
감지 창이 구조적 지연이라면, 예측 가능한 스파이크는 감지 자체를 건너뛸 수 있다. 출근 시간, 점심 피크, 방송 노출처럼 시각을 아는 트래픽이다. HPA에는 시간 개념이 없지만, KEDA(Kubernetes Event-driven Autoscaler)를 얹으면 cron 트리거로 "이 시간대에는 미리 N개"를 선언할 수 있다. KEDA는 HPA를 대체하는 것이 아니라 HPA를 만들어 조종하는 층이라, cpu 트리거를 함께 걸면 예측 밖 부하는 기존 방식대로 받친다.
설정은 ScaledObject 하나다. 기존 HPA는 지우고(같은 대상을 두고 소유권이 겹친다), cron과 cpu 두 트리거를 나란히 건다.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: autoscale-lab
spec:
scaleTargetRef:
name: autoscale-lab
minReplicaCount: 3
maxReplicaCount: 12
triggers:
- type: cron # 몰릴 시간을 아는 트래픽: 창 안에서는 최소 9개
metadata:
timezone: Asia/Seoul
start: 50 8 * * *
end: 0 11 * * *
desiredReplicas: '9'
- type: cpu # 예측 밖 부하는 기존 방식대로
metricType: Utilization
metadata:
value: '70'
143rps 스파이크(반응형 확장이 p95를 1.6초까지 흘려보냈던 바로 그 부하)를 cron 창 안, 미리 9개로 확장된 상태에서 다시 흘려 봤다.
| 같은 143rps 스파이크 | 반응형 (HPA, 3개에서 시작) | 선제 (KEDA cron, 9개에서 시작) |
|---|---|---|
| 스케일 아웃 대기 | +32.2초 (감지 창) | 0초 (이미 떠 있음) |
| p95 최악 구간 | 1.6초 | 101ms (내내 두 자릿수) |
| 실패율 | 0% (0/30,064) | 0% (0/30,094) |
이 부하에서는 반응형도 에러 없이 살아남았지만, 감지 창 동안의 p95 폭주가 통째로 사라졌다. 그리고 배포 직후의 222rps라면 반응형은 실명까지 간다는 것을 앞 절에서 봤다. 이 대조가 말하는 바는 분명하다. 가장 빠른 스케일 아웃은 스파이크가 오기 전에 끝난 스케일 아웃이다. 덧붙이면, 밤 시간에 완전히 쉬어도 되는 배치성 워크로드라면 minReplicaCount 0과 cron 창의 조합으로 scale-to-zero까지 갈 수 있다(사용자 트래픽을 받는 HTTP 서비스는 0에서 깨울 요청 기반 장치가 따로 필요해서 별도 애드온의 영역이다). 물론 선제 확장은 시각을 모르는 스파이크에는 통하지 않는 방법이고, 그때 돌아갈 곳은 다시 감지 창을 버틸 여유, 즉 minReplicas와 requests다.
이 글의 실측을 각자의 서비스에 물어볼 수 있는 형태로 추린다.
1. HPA의 분모(requests)는 실측 기반인가?
kubectl get hpa -o custom-columns=NAME:.metadata.name,TARGET:.spec.metrics[0].resource.target.averageUtilization
kubectl get deploy my-app -o jsonpath='{.spec.template.spec.containers[0].resources.requests}'
사용률의 분모가 requests다. requests가 복사된 값이면 70%라는 목표도 복사된 목표다. 정하는 방법은 사이징 글 전체가 다룬다.
2. 감지 창을 버틸 여유가 있는가?
스파이크 도달 전 15~30초를 기존 파드가 버텨야 한다. minReplicas × (requests 대비 여유)가 그 버퍼다. 평시 사용률이 이미 60~70%라면 감지 창 동안 갈 곳이 없다.
3. 과부하에서 probe는 살아남는가?
kubectl describe hpa my-app | grep -A3 "unready\|invalid metrics"
부하 테스트 중 이 로그가 찍히면 실명 시나리오가 재현된 것이다. probe timeoutSeconds, probe 경로가 서빙 큐와 얼마나 얽혀 있는지, 그리고 마지막 롤아웃이 5분 안이었는지를 본다. 배포 직후는 전 파드가 메트릭 유예 창 안이라 실명에 가장 취약한 시간이다.
4. 스케일 아웃될 노드에 이미지가 있는가?
kubectl get events --sort-by=.metadata.creationTimestamp | grep -i "pulling\|pulled"
확장 때마다 실제 다운로드가 찍히면, 감지 창 뒤에 풀 시간이 더해지고 있는 것이다. 이미지 크기(2편)와 프리풀 전략을 검토한다.
5. 메모리 기반 HPA를 Node 서비스에 걸어 두지 않았는가?
걸려 있다면 스케일 인이 되는지 그래프로 확인해 볼 것. 부하가 빠져도 replicas가 안 내려온다면 이 글의 재현과 같은 상태다.
이번 편으로 무리의 크기가 정해지는 시간을 쟀다. 시리즈의 다음이자 마지막 조각은 그 무리를 이루는 파드 하나를 얼마로 빚는가다. requests와 limits, NODE_OPTIONS와 힙, 그리고 그것이 청구서가 되는 과정까지, Node.js 파드 사이징 글이 이 시리즈의 종착점으로 그 답을 맡는다.