같은 앱에 같은 HPA 설정, 같은 세기의 부하를 두 번 줬다. 90초 동안 준 쪽은 요청의 1.88%가 실패했고, 5분 동안 준 쪽은 182만 5,692건 가운데 한 건도 실패하지 않았다. 90초 측정에서도 HPA는 제때 움직였다. 부하가 들어오고 45초 만에 Pod를 1개에서 3개로 늘리기로 했는데, 새 Pod가 일을 시작하기 전에 부하가 끝났다.
하나의 서비스를 여러 고객사가 각자 쓰도록 바꾸는 멀티 테넌트 SaaS 전환 프로젝트에서, 운영 전에 자동 확장이 얼마나 빨리 따라오는지 재던 중이었다. HPA의 REPLICAS 숫자만 보면 확장은 1분 안에 끝난다. 하지만 확장이 끝난 시점은 Pod 수가 목표에 닿았을 때가 아니라 새 Pod가 Ready가 되어 트래픽을 받기 시작했을 때다. 이 글에서는 부하 시작부터 새 Pod가 일하기까지 단계마다 걸린 시간과, Spring Boot 앱에서 그 시간이 어디에 쓰이는지를 정리한다.
90초와 5분의 차이
대상은 Spring Boot로 만든 모바일 백엔드 mo-back의 헬스체크 API였다. Pod 1개에서 시작했고, HPA는 최소 1개·최대 24개, 앱 컨테이너 CPU 60%를 기준으로 걸었다. 부하는 AWS DLT로 Fargate 작업 10개 × 동시 사용자 100, 합쳐서 1,000명을 줬다.
| 부하 시간 | Pod 수 | 처리량 | 평균 응답 | 오류율 |
|---|---|---|---|---|
| 90초 | 1 → 3 | 1,244 RPS | 704ms | 1.88% (2,810건) |
| 5분 | 1 → 17 (새 노드 14대) | 5,532 RPS | 172ms | 0% |
90초 측정의 흐름은 단순하다. 부하 30초 뒤 HPA가 CPU 174%를 봤고, 45초에 Pod를 3개로 늘리기로 했다. 부하는 90초에 끝났다. 그사이 들어온 요청은 처음부터 떠 있던 Pod 하나가 받았다. 5분 측정은 확장이 따라올 시간이 있었지만, 5분이 됐을 때도 Pod 17개 가운데 Running은 8개뿐이었고 9개는 아직 기동 중이었다.
DLT는 클라이언트가 본 숫자(처리량, 응답 시간, 오류)만 보여준다. 확장 단계를 보려면 클러스터 쪽 기록이 따로 있어야 해서, bastion에서 루프를 띄워 10~15초 간격으로 HPA, Pod, NodeClaim, 컨테이너별 CPU를 한 파일에 쌓았다.
# 15초 간격으로 HPA, Pod 상태, NodeClaim, 컨테이너별 CPU를 한 로그에 쌓는다 (bastion에서 nohup으로 실행)
LOG=r029-monitor.log
for i in $(seq 1 48); do
echo "==t=$((i*15))s ($(date +%H:%M:%S))==" >> $LOG
kubectl get hpa -n tenant-a tenant-a-mo-back-hpa --no-headers >> $LOG 2>&1
kubectl get pods -n tenant-a -l app=tenant-a-mo-back --no-headers >> $LOG 2>&1
kubectl get nodeclaim -l karpenter.sh/nodepool=loadtest --no-headers >> $LOG 2>&1
kubectl top pods -n tenant-a -l app=tenant-a-mo-back --containers >> $LOG 2>/dev/null
sleep 15
done
kubectl top에 --containers를 붙여야 앱 컨테이너와 istio-proxy 사이드카의 CPU가 나뉘어 보인다. DLT는 Fargate 작업을 준비하느라 Run을 누르고 2~5분 뒤에야 부하가 들어오므로, 로그에서 HPA의 CPU 값이 처음 튄 줄을 찾아 그 시점을 t=0으로 다시 잡았다. 5분 측정에서는 이 루프가 이유를 모른 채 수십 초 만에 끝나 버려서 5분 뒤 상태를 한 번 찍는 것으로 대신했다. 5분 측정의 시간표가 “2~3분쯤 3개에서 9개”처럼 거친 이유다. 다음에 다시 잰다면 이 부분부터 손봐야겠다.
부하 도구 자체가 결과를 왜곡하던 문제는 부하 서버를 한 대 늘린 측정에서 따로 다룬다.
부하 시작부터 새 Pod가 일하기까지
Pod 수가 늘어나는 것과 그 Pod가 일하는 것 사이에 무엇이 있는지 보려고 단계를 나눠 쟀다. HPA와 Karpenter 단계는 빈 nginx 컨테이너로 쟀고, 마지막 단계만 nginx와 Spring Boot 앱을 따로 봤다.
| 단계 | 걸린 시간 |
|---|---|
| 부하 시작 → HPA가 Pod 추가 명령 | 약 10초 |
| HPA 목표 Pod 수 도달 | 약 60초 |
| Karpenter가 새 노드 준비 | 60~90초 |
| 새 Pod가 실제로 트래픽을 받기 시작 | nginx 약 90초 / Spring Boot 앱 약 3분 |
| 부하 종료 → 축소 시작 | 5~10분 |
표에 덧붙일 것이 세 가지 있다.
첫째, 10초는 nginx로 잰 값이다. mo-back은 HPA가 처음 Pod를 늘리기까지 30~50초가 걸렸다.
둘째, 노드에 자리가 남아 있으면 노드 단계가 통째로 빠진다. nginx의 CPU request를 100m로 두었을 때는 새 Pod가 기존 노드 2대에 다 들어갔고, Pod 생성부터 Running까지 9~15초, 1개에서 9개가 되기까지 약 30초가 걸렸다. request를 500m로 키워 일부러 자리를 모자라게 하자 Karpenter가 부하 시작 직후 NodeClaim을 만들었고, 노드가 Ready가 되기까지 60~90초가 걸렸다.
셋째, nginx는 노드만 준비되면 몇 초 안에 뜨므로 nginx의 90초는 거의 노드를 기다린 시간이다. Spring Boot 앱은 노드가 준비된 뒤에도 기동에 2분이 더 걸린다. 표에서 nginx와 앱의 차이가 벌어지는 곳이 여기다.
Spring Boot Pod 하나가 Ready가 되기까지
새 mo-back Pod 하나의 기동 과정을 시간순으로 펼치면 이렇다. 4월 28일 측정에서 Pod가 노드에 배치된 시점을 0으로 둔 누적 시간이다.
| 시점 | 일어난 일 |
|---|---|
| +0 | Pod가 노드에 배치됨 |
| +16초 | 이미지 pull 완료 (625MB) |
| +47초 | 컨테이너 init을 마치고 JVM 구동 시작 |
| +65초 | Spring WebApplicationContext 완료 |
| +1분 43초 | Tomcat 8080 listen 시작 |
| +2분 14초 | readinessProbe 통과 (2/2 Ready) |
gantt
title 새 Pod 하나가 Ready가 되기까지 (배치 시점 = 0)
dateFormat s
axisFormat %M:%S
section nginx
생성부터 Running까지 (9~15초) :done, n1, 0, 15s
section Spring Boot (mo-back)
이미지 pull :active, m1, 0, 16s
컨테이너 init :active, m2, after m1, 31s
JVM 구동, Spring 컨텍스트 :active, m3, after m2, 18s
Tomcat listen까지 :active, m4, after m3, 38s
readinessProbe 통과까지 :crit, m5, after m4, 31s5월 29일 실제 화면 초기화 API(/main/init)를 잴 때는 Pod가 생성되고 2/2 Ready가 되기까지 라운드마다 2분 47초가 걸렸다. 이때는 최소 Pod 수를 8개로 두고 시작했는데, 측정마다 같은 장면이 나왔다.
동시 사용자 5명으로 1분 30초를 준 확인용 측정에서도 HPA가 Pod를 8개에서 12개로 늘렸다. 새 Pod는 부하가 끝난 뒤에 Ready가 됐다.
동시 사용자 100명으로 1분 30초를 주자 HPA가 16개로 늘렸지만, 새 Pod는 하나도 Ready가 되지 못한 채 부하가 끝났다.
5분을 주자 부하 중반에 새 Pod 11개가 Ready에 들어갔다. 19개가 모두 Ready가 된 뒤에야 전체 CPU가 6.7코어에서 3.5코어로, HPA 지표가 92%에서 36%로 내려왔다.
Ready가 끝도 아니다. 새 Pod 11개가 한꺼번에 Ready에 들어간 순간 CPU가 잠깐 13.5코어까지 튀었다. JVM은 Ready 직후 30~60초 동안 JIT 워밍업으로 CPU를 많이 쓴다. 공통 DestinationRule에 warmupDurationSecs: 60s를 넣어 새 Pod가 60초에 걸쳐 트래픽을 서서히 받게 한 것도, 나중에 HPA scaleUp 안정화 창을 45초로 둔 것도 이 급등 때문이다. 안정화 창이 없으면 HPA는 기동 직후의 CPU를 실제 부하로 보고 Pod를 늘렸다가 곧 줄인다.
2~3분을 버티는 부하와 1분 안에 끝나는 급증
결과는 둘로 나뉜다. 부하가 2~3분 이상 이어지면 확장이 따라와 정상 처리한다. nginx 기준으로 확장이 다 끝난 뒤의 숫자는 아래와 같다. 반면 1분 미만의 짧은 급증에는 확장이 늦는다. 그 구간은 원래 떠 있던 Pod가 혼자 받고, 일시적으로 오류가 날 수 있다. 90초 측정의 1.88%가 그 경우다.
| 시나리오 | 구성 | 처리량 | 평균 응답 | 오류율 |
|---|---|---|---|---|
| 안정 운영 | nginx, HPA 2→10, 노드 6대 | 7,368 RPS | 125ms | 0% |
| 한계 | 위 조건에서 부하 2배 | 9,268 RPS | 377ms | 0.02% |
짧은 급증을 인프라 쪽에서 받는 방법은 Pod를 미리 띄워 두는 것뿐이다. 4월에 다른 조건은 두고 HPA 최소 Pod 수만 3개로 올려 쟀을 때, 초당 10건을 주는 첫 단계를 통과하지 못하던 측정이 그 단계를 다 받아냈고 평균 응답은 7.53초에서 1.99초로 줄었다. HPA를 더 공격적으로(목표 CPU 40%, 한 번에 8개) 바꾼 측정은 효과가 거의 없었다. CPU가 부하 30초 만에 146%까지 치솟아서 HPA가 몇 퍼센트에서 반응하느냐는 차이를 만들지 못했다. HPA가 아무리 빨리 명령해도 그 뒤의 2분은 줄지 않는다.
근본 해결은 앱 기동 시간을 줄이는 것이고, 그건 인프라가 아니라 앱 개발 영역이다. Spring lazy-initialization, CDS, GraalVM native image 같은 후보를 적어 두었지만 남은 과제로 넘겼다. 같은 2분 47초는 부하 때만 문제가 되지 않는다. Karpenter가 노드를 정리하며 Pod를 옮길 때도 그 앱의 처리 용량은 그만큼 줄어든다.