M8. HPA·KEDA·Cluster Autoscaler
1. 왜 필요한가
섹션 제목: “1. 왜 필요한가”NKS로 옮길 web-app, api-service, ai-service는 부하의 모양이 다릅니다. CPU,
queue backlog, Node 용량을 한 종류의 autoscaling으로 해결하려 하면 replica는 늘었는데 Pod가
Pending이거나, Pod만 늘어 downstream을 더 압박하는 결정을 내리기 쉽습니다.
개념 정의의 정본은 Kubernetes Basics입니다. 원본 경로는
content/topics/L5/kubernetes-basics.mdx이며, 이 모듈은 HPA와 KEDA의 Pod층 scaling을
실행하고 Node층 신호와 분리해서 관찰하는 역할만 맡습니다. M6의 request·scheduler fit과
M7의 Helm release lifecycle을 선수지식으로 사용합니다.
정본은 AWS의 Node층 예시로 Karpenter를 사용하지만 NKS의 관리형 선택지는 Cluster Autoscaler입니다. Karpenter가 Pending Pod 요구에 맞는 Node 타입까지 동적으로 고르는 모델과, Cluster Autoscaler가 미리 정의한 node pool의 크기를 min/max 안에서 조정하는 모델은 같은 도구가 아닙니다. 이 모듈은 둘을 Pod층 다음의 Node 용량 계층이라는 공통 위치에만 대응시킵니다.
2. 핵심 개념
섹션 제목: “2. 핵심 개념”flowchart LR CPU["CPU metric + request"] --> HPA["HPA controller"] Event["backlog / external metric"] --> KEDA["KEDA ScaledObject"] KEDA --> GeneratedHPA["KEDA가 관리하는 HPA"] HPA --> Replicas["Deployment replicas"] GeneratedHPA --> Replicas Replicas --> Scheduler["Scheduler"] Scheduler --> Running["Pod Running: 현재 Node에 fit"] Scheduler --> Pending["Pod Pending: Insufficient resource"] Pending --> CA["Cluster Autoscaler"] CA --> NodePool["Node pool min/max 안에서 Node 수 조정"]
| 계층 | 입력 신호 | 바꾸는 대상 | 이 모듈에서 확인할 상태 |
|---|---|---|---|
| HPA | Pod CPU 사용량 ÷ CPU request | Deployment replica | HPA TARGETS, desired/ready replicas |
| KEDA | queue·stream·API 같은 event metric | 0↔1 활성화 + HPA를 통한 1↔N replica | ScaledObject Ready/Active, 생성된 HPA |
| Scheduler | 새 Pod의 request·배치 제약 | Pod를 기존 Node에 배치 | PodScheduled=False, Events |
| Cluster Autoscaler | 기존 Node에 fit하지 못한 Pending Pod | node pool의 Node 수 | CA status, node pool min/max, 새 Node Ready |
HPA의 CPU Utilization은 절대 CPU 사용량이 아니라 request 대비 비율입니다. 단순화한 계산은
다음과 같습니다.
desiredReplicas = ceil(currentReplicas × currentAverageUtilization / targetAverageUtilization)CPU request가 없으면 분모가 없으므로 HPA는 해당 Pod의 CPU utilization을 정의할 수 없습니다. 따라서 높은 CPU를 만들었다는 사실만으로 HPA가 동작하지 않습니다. M6에서 request가 scheduler의 예약 입력이었다면, 여기서는 HPA 비율 계산의 기준이기도 합니다.
KEDA는 HPA를 대체하는 별도 replica controller 하나로만 이해하면 안 됩니다. 0↔1 활성화는 KEDA
operator가 맡고, 1↔N scaling은 KEDA가 제공한 external metric과 생성한 HPA가 맡습니다. 이
모듈의 ConfigMap tasks는 실제 queue가 아닌 재현용 가상 backlog입니다. NKS에서는
ai-service가 실제로 소비하는 queue의 lag, 처리율, 재시도와 downstream 한도를 함께 봐야 합니다.
flowchart TD
Start["처리량이 부족하다"] --> Replica{"desired replica가 늘었나?"}
Replica -- "아니요" --> Metric["HPA TARGETS / ScaledObject metric·auth"]
Replica -- "예" --> Scheduled{"새 Pod가 Scheduled인가?"}
Scheduled -- "아니요" --> Capacity["Events + request + node pool CA"]
Scheduled -- "예" --> Ready{"Pod가 Ready인가?"}
Ready -- "아니요" --> Probe["M9 probe·startup 문제"]
Ready -- "예" --> Downstream["처리 시간·DB pool·외부 API quota"] | 예시 서비스 | 먼저 검토할 신호 | 승인 전에 물을 질문 |
|---|---|---|
web-app | CPU 또는 request rate | CPU가 실제 병목이며 replica별 request가 측정값에 맞는가? |
api-service | CPU만으로 부족할 수 있음 | DB connection pool·외부 API 대기가 latency 원인 아닌가? |
ai-service | queue backlog·oldest message age | worker를 늘려도 model/API quota와 처리율이 함께 늘어나는가? |
시점 의존 설명은 2026-07-15에 다음 공식 1차 자료로 확인했습니다.
- Horizontal Pod Autoscaling:
autoscaling/v2, metrics API, request 대비 CPU utilization과 replica 계산 - KEDA 배포: Helm 설치, Kubernetes 호환 범위와 KEDA 구성 요소
- KEDA workload scaling: 0↔1은 KEDA, 1↔N은 생성된 HPA가 담당하는 경계
- KEDA Kubernetes Resource scaler:
ConfigMap·Secret 값 기반
targetValue와activationTargetValue - Cluster Autoscaler FAQ: 기존 Node에 fit하지 못하는 Pending Pod와 request 기반 scale-up 판단
- Karpenter Concepts: Pending Pod의 scheduling constraint와 NodePool 제약을 함께 풀어 cloud instance를 직접 선택·생성하는 모델과 Cluster Autoscaler 비교
- NKS Cluster Autoscaler: node pool min/max, Pending Pod와 request 기반 증설, scale-down 제외 조건
실제 NKS Cluster Autoscaler 활성화·min/max 변경·Node 증감은 cloud 비용과 workload 이동을
일으키므로 official-doc-only입니다. 승인된 non-production NKS가 없어 provider-verified
결과를 만들지 않습니다. 이 모듈의 k3d에는 cloud node pool provider가 없으므로 Node 증설을
재현하지 않으며, k3d의 Node 수 불변을 NKS 동작으로 해석하지 않습니다. production NKS 자동
변경, VPA, custom metrics adapter 운영, KEDA 인증 Secret과 scaler별 심화 튜닝은 범위 밖입니다.
3. 직관 비유
섹션 제목: “3. 직관 비유”| 개념 | 비유 | 비유의 경계 |
|---|---|---|
| HPA | 계산대 줄과 직원의 작업률을 보고 같은 계산대를 더 여는 관리자 | CPU가 아닌 대기·외부 병목은 이 신호로 보이지 않습니다. |
| KEDA | 주문함에 쌓인 주문 수를 보고 작업조를 부르는 배차 담당자 | event source의 신뢰성과 처리 한도를 자동 해결하지 않습니다. |
| Cluster Autoscaler | 배치할 작업조가 생겼는데 자리가 없을 때 작업장을 늘리는 시설 담당자 | 높은 CPU 자체가 아니라 배치 불가능한 Pod 요구를 중심으로 봅니다. |
4. 핸즈온 랩
섹션 제목: “4. 핸즈온 랩”검증 환경
섹션 제목: “검증 환경”| 항목 | 검증값 |
|---|---|
| 기준일 | 2026-07-15 |
| host | macOS arm64 |
| Docker client / engine | 29.6.1 / 29.6.1 |
| k3d | 5.9.0 |
| K3s image / API server | rancher/k3s:v1.36.2-k3s1 / v1.36.2+k3s1 |
| kubectl | 1.36.1 |
| Helm | 4.2.3 |
| KEDA chart / app | 2.20.1 / 2.20.1 |
| workload image | busybox:1.37.0 + 고정 multi-platform digest |
| evidence 환경 | local-cluster; 고정 context + k8s-wb-m8-<UTC run ID> |
HPA·KEDA 설치와 scaling, Pending 재현은 executed-local입니다. 실제 NKS Node 증감은
official-doc-only, production 변경은 excluded이며 예상 출력을 두지 않습니다.
사전 조건
섹션 제목: “사전 조건”- 저장소 root에서 실행하며 M0~M7을 완료한 상태여야 합니다.
- Docker Desktop과 M2의
cs-study-workbookcluster가 실행 중이어야 합니다. - K3s의 Metrics Server와
metrics.k8s.ioAPI가 정상이어야 합니다. - KEDA chart와 component image를 처음 받는 환경은 인터넷 연결이 필요합니다.
- 고정 context 외 환경, 기존 KEDA CRD 또는 external metrics API가 있으면 Setup이 중단됩니다.
- 예상 소요 시간은 110분이며 metric 수집과 scale 수렴 대기 시간을 포함합니다.
Setup
섹션 제목: “Setup”격리 namespace와 고정 KEDA chart 준비하기
섹션 제목: “격리 namespace와 고정 KEDA chart 준비하기”Helm config도 /tmp/k8s-wb-m8 아래에 격리합니다. Setup 도중 실패하면 namespace와 임시 chart를
지우며, 기존 KEDA가 있는 cluster에서는 소유권 충돌을 피하려고 시작하지 않습니다.
set -euo pipefailrm -rf /tmp/k8s-wb-m8mkdir -p /tmp/k8s-wb-m8/helm-config /tmp/k8s-wb-m8/helm-cache /tmp/k8s-wb-m8/helm-datachmod 700 /tmp/k8s-wb-m8node automation/k8s-workbook/preflight.mjs --require-clustertest "$(kubectl config current-context)" = "k3d-cs-study-workbook"kubectl --context k3d-cs-study-workbook get --raw /apis/metrics.k8s.io/v1beta1/nodes \ >/tmp/k8s-wb-m8/node-metrics.jsontest "$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1])).items.length)' \ /tmp/k8s-wb-m8/node-metrics.json)" -ge 1test -z "$(kubectl --context k3d-cs-study-workbook get crd scaledobjects.keda.sh \ --ignore-not-found -o name)"test -z "$(kubectl --context k3d-cs-study-workbook get apiservice \ v1beta1.external.metrics.k8s.io --ignore-not-found -o name)"NS="k8s-wb-m8-$(date -u +%Y%m%d%H%M%S)"printf '%s' "$NS" >/tmp/k8s-wb-m8/namespaceif kubectl --context k3d-cs-study-workbook get namespace "$NS" >/dev/null 2>&1; then echo "namespace collision: $NS" >&2 rm -rf /tmp/k8s-wb-m8 exit 1firollback() { STATUS=$? trap - EXIT set +e kubectl --context k3d-cs-study-workbook delete namespace "$NS" \ --ignore-not-found --wait=true >/dev/null 2>&1 REMAINING="$(kubectl --context k3d-cs-study-workbook get namespace "$NS" \ --ignore-not-found -o name 2>/dev/null)" if test $? != 0 || test -n "$REMAINING"; then echo 'rollback incomplete: preserve /tmp/k8s-wb-m8 and inspect namespace' >&2 exit 70 fi rm -rf /tmp/k8s-wb-m8 exit "$STATUS"}trap rollback EXITHELM_CONFIG_HOME=/tmp/k8s-wb-m8/helm-config \HELM_CACHE_HOME=/tmp/k8s-wb-m8/helm-cache \HELM_DATA_HOME=/tmp/k8s-wb-m8/helm-data \ helm repo add kedacore https://kedacore.github.io/charts >/dev/nullHELM_CONFIG_HOME=/tmp/k8s-wb-m8/helm-config \HELM_CACHE_HOME=/tmp/k8s-wb-m8/helm-cache \HELM_DATA_HOME=/tmp/k8s-wb-m8/helm-data \ helm repo update >/dev/nullHELM_CONFIG_HOME=/tmp/k8s-wb-m8/helm-config \HELM_CACHE_HOME=/tmp/k8s-wb-m8/helm-cache \HELM_DATA_HOME=/tmp/k8s-wb-m8/helm-data \ helm pull kedacore/keda --version 2.20.1 --destination /tmp/k8s-wb-m8test -f /tmp/k8s-wb-m8/keda-2.20.1.tgzkubectl --context k3d-cs-study-workbook create namespace "$NS" >/dev/nulltrap - EXITprintf 'setup namespace=%s metrics_api=ready keda_chart=2.20.1\n' "$NS"예상 출력:
PASS platform: darwin/arm64PASS architecture: arm64PASS docker-cli: v29.6.1PASS docker-engine: v29.6.1PASS k3d: v5.9.0PASS kubectl-skew: v1.36.1PASS helm: v4.2.3PASS context: k3d-cs-study-workbookPASS kubernetes-server: v1.36.2+k3s1setup namespace=k8s-wb-m8-<UTC 14자리 run ID> metrics_api=ready keda_chart=2.20.1host architecture, Docker patch version, namespace와 chart download 시간은 변동 필드입니다.
Lab 1 — CPU request가 없는 HPA 실패 재현하기
섹션 제목: “Lab 1 — CPU request가 없는 HPA 실패 재현하기”CPU를 계속 쓰는 Pod라도 CPU request가 없으면 utilization의 분모가 없습니다. HPA가
ScalingActive=False와 FailedGetResourceMetric을 남기는지 확인하고 의도적으로 exit 1로
끝냅니다.
set -euo pipefailtest "$(kubectl config current-context)" = "k3d-cs-study-workbook"NS="$(cat /tmp/k8s-wb-m8/namespace)"kubectl --context k3d-cs-study-workbook -n "$NS" apply -f - <<'YAML'apiVersion: apps/v1kind: Deploymentmetadata: name: web-appspec: replicas: 1 selector: matchLabels: app: web-app template: metadata: labels: app: web-app spec: terminationGracePeriodSeconds: 3 containers: - name: app image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028 imagePullPolicy: IfNotPresent command: ["sh", "-c", "while true; do :; done"]---apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: name: web-appspec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: web-app minReplicas: 1 maxReplicas: 4 behavior: scaleDown: stabilizationWindowSeconds: 0 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 50YAMLkubectl --context k3d-cs-study-workbook -n "$NS" \ rollout status deployment/web-app --timeout=90s >/dev/nullREASON=""MESSAGE=""for _ in $(seq 1 60); do REASON="$(kubectl --context k3d-cs-study-workbook -n "$NS" get hpa web-app \ -o jsonpath='{.status.conditions[?(@.type=="ScalingActive")].reason}')" MESSAGE="$(kubectl --context k3d-cs-study-workbook -n "$NS" get hpa web-app \ -o jsonpath='{.status.conditions[?(@.type=="ScalingActive")].message}')" case "$MESSAGE" in *"missing request for cpu"*) test "$REASON" = "FailedGetResourceMetric" && break ;; esac sleep 2donetest "$REASON" = "FailedGetResourceMetric"case "$MESSAGE" in *"missing request for cpu"*) ;; *) echo 'missing-request evidence absent' >&2; exit 2 ;; esacprintf 'hpa=web-app scaling_active=false reason=%s cause=missing-cpu-request\n' "$REASON"exit 1예상 출력과 종료 코드:
hpa=web-app scaling_active=false reason=FailedGetResourceMetric cause=missing-cpu-requestexit code: 1Pod 이름, condition 갱신 시간과 message의 Pod 식별자는 변동 필드입니다. 관찰 포인트는 CPU 부하 자체가 아니라 request 부재 때문에 HPA metric이 정의되지 않는다는 점입니다.
Lab 2 — request를 추가해 HPA scale-out 확인하기
섹션 제목: “Lab 2 — request를 추가해 HPA scale-out 확인하기”CPU request와 limit을 추가하면 같은 부하를 request 대비 utilization로 계산할 수 있습니다. HPA의 desired replica와 실제 Ready replica를 함께 확인합니다.
set -euo pipefailtest "$(kubectl config current-context)" = "k3d-cs-study-workbook"NS="$(cat /tmp/k8s-wb-m8/namespace)"kubectl --context k3d-cs-study-workbook -n "$NS" set resources deployment/web-app \ --requests=cpu=25m,memory=16Mi --limits=cpu=100m,memory=32Mi >/dev/nullkubectl --context k3d-cs-study-workbook -n "$NS" \ rollout status deployment/web-app --timeout=90s >/dev/nullDESIRED=""READY=""for _ in $(seq 1 90); do DESIRED="$(kubectl --context k3d-cs-study-workbook -n "$NS" get hpa web-app \ -o jsonpath='{.status.desiredReplicas}')" READY="$(kubectl --context k3d-cs-study-workbook -n "$NS" get deployment web-app \ -o jsonpath='{.status.readyReplicas}')" test "$DESIRED" = "4" && test "$READY" = "4" && break sleep 2doneCURRENT_CPU="$(kubectl --context k3d-cs-study-workbook -n "$NS" get hpa web-app \ -o jsonpath='{.status.currentMetrics[0].resource.current.averageUtilization}')"ACTIVE="$(kubectl --context k3d-cs-study-workbook -n "$NS" get hpa web-app \ -o jsonpath='{.status.conditions[?(@.type=="ScalingActive")].status}')"test "$DESIRED" = "4"test "$READY" = "4"test "$ACTIVE" = "True"test "$CURRENT_CPU" -gt 50printf 'hpa scale=out current_cpu=%s%% target_cpu=50%% desired=%s ready=%s\n' \ "$CURRENT_CPU" "$DESIRED" "$READY"예상 출력:
hpa scale=out current_cpu=<50보다 큰 변동값>% target_cpu=50% desired=4 ready=4CPU utilization, scale 시작 시간과 Pod 이름은 변동 필드입니다. 관찰 포인트는 HPA가 Pod를 직접 복제하지 않고 Deployment의 replica 목표를 바꾼다는 점입니다.
Lab 3 — 부하를 낮춰 HPA scale-in 확인하기
섹션 제목: “Lab 3 — 부하를 낮춰 HPA scale-in 확인하기”Pod command를 idle 상태로 바꾸고, 학습 시간을 줄이기 위해 scale-down stabilization을 0초로 둔 fixture가 1 replica로 수렴하는지 확인합니다. 이 값은 production 권장값이 아닙니다.
set -euo pipefailtest "$(kubectl config current-context)" = "k3d-cs-study-workbook"NS="$(cat /tmp/k8s-wb-m8/namespace)"kubectl --context k3d-cs-study-workbook -n "$NS" patch deployment web-app \ --type=strategic \ -p '{"spec":{"template":{"spec":{"containers":[{"name":"app","command":["sh","-c","sleep 3600"]}]}}}}' \ >/dev/nullkubectl --context k3d-cs-study-workbook -n "$NS" \ rollout status deployment/web-app --timeout=90s >/dev/nullDESIRED=""READY=""for _ in $(seq 1 120); do DESIRED="$(kubectl --context k3d-cs-study-workbook -n "$NS" get hpa web-app \ -o jsonpath='{.status.desiredReplicas}')" READY="$(kubectl --context k3d-cs-study-workbook -n "$NS" get deployment web-app \ -o jsonpath='{.status.readyReplicas}')" test "$DESIRED" = "1" && test "$READY" = "1" && break sleep 2doneCURRENT_CPU="$(kubectl --context k3d-cs-study-workbook -n "$NS" get hpa web-app \ -o jsonpath='{.status.currentMetrics[0].resource.current.averageUtilization}')"test "$DESIRED" = "1"test "$READY" = "1"test "$CURRENT_CPU" -le 50printf 'hpa scale=in current_cpu=%s%% target_cpu=50%% desired=%s ready=%s\n' \ "$CURRENT_CPU" "$DESIRED" "$READY"예상 출력:
hpa scale=in current_cpu=<0~50 변동값>% target_cpu=50% desired=1 ready=1metric scrape와 scale 수렴 시간은 변동 필드입니다. production에서는 순간 하락에 흔들리지 않도록 stabilization, scale policy와 애플리케이션 종료 시간을 함께 정합니다.
Lab 4 — Helm으로 KEDA 2.20.1 설치하기
섹션 제목: “Lab 4 — Helm으로 KEDA 2.20.1 설치하기”M7에서 배운 고정 chart와 Helm 4의 --rollback-on-failure를 사용합니다. KEDA는 CRD, operator,
external metrics API와 admission webhook을 설치하므로 기존 KEDA가 없는 전용 학습 cluster에서만
실행합니다.
set -euo pipefailtest "$(kubectl config current-context)" = "k3d-cs-study-workbook"NS="$(cat /tmp/k8s-wb-m8/namespace)"helm install keda-m8 /tmp/k8s-wb-m8/keda-2.20.1.tgz \ --kube-context k3d-cs-study-workbook -n "$NS" \ --set-string watchNamespace="$NS" \ --set image.pullPolicy=IfNotPresent \ --rollback-on-failure --wait=watcher --timeout=180s >/dev/nullfor DEPLOYMENT in keda-operator keda-operator-metrics-apiserver keda-admission-webhooks; do kubectl --context k3d-cs-study-workbook -n "$NS" \ rollout status "deployment/$DEPLOYMENT" --timeout=90s >/dev/nulldoneSTATUS_OUTPUT="$(helm status keda-m8 --kube-context k3d-cs-study-workbook -n "$NS")"STATUS="$(printf '%s\n' "$STATUS_OUTPUT" | awk '$1 == "STATUS:" {print $2}')"REVISION="$(printf '%s\n' "$STATUS_OUTPUT" | awk '$1 == "REVISION:" {print $2}')"AVAILABLE="$(kubectl --context k3d-cs-study-workbook get apiservice \ v1beta1.external.metrics.k8s.io \ -o jsonpath='{.status.conditions[?(@.type=="Available")].status}')"CRD="$(kubectl --context k3d-cs-study-workbook get crd scaledobjects.keda.sh -o name)"READY_DEPLOYMENTS="$(kubectl --context k3d-cs-study-workbook -n "$NS" get deployment \ -l app.kubernetes.io/instance=keda-m8 \ -o jsonpath='{range .items[*]}{.status.readyReplicas}/{.spec.replicas}{"\n"}{end}' \ | awk '$1 == "1/1" {count++} END {print count+0}')"test "$STATUS" = "deployed"test "$REVISION" = "1"test "$AVAILABLE" = "True"test "$CRD" = "customresourcedefinition.apiextensions.k8s.io/scaledobjects.keda.sh"test "$READY_DEPLOYMENTS" = "3"printf 'keda release=keda-m8 chart=2.20.1 status=%s revision=%s deployments=%s/3 external_metrics=%s\n' \ "$STATUS" "$REVISION" "$READY_DEPLOYMENTS" "$AVAILABLE"예상 출력:
keda release=keda-m8 chart=2.20.1 status=deployed revision=1 deployments=3/3 external_metrics=TruePod 이름, image pull 시간과 certificate rotation 시간은 변동 필드입니다. 관찰 포인트는 KEDA가 Deployment 하나가 아니라 cluster-scoped API와 controller 묶음이라는 점입니다.
Lab 5 — 가상 backlog 0에서 worker를 0으로 줄이기
섹션 제목: “Lab 5 — 가상 backlog 0에서 worker를 0으로 줄이기”ConfigMap tasks=0은 실제 queue가 아닌 재현용 event fixture입니다. KEDA가 ScaledObject를 읽어
HPA를 생성하고 ai-service를 0 replica로 활성화 해제하는지 확인합니다.
set -euo pipefailtest "$(kubectl config current-context)" = "k3d-cs-study-workbook"NS="$(cat /tmp/k8s-wb-m8/namespace)"kubectl --context k3d-cs-study-workbook -n "$NS" delete hpa web-app --wait=true >/dev/nullkubectl --context k3d-cs-study-workbook -n "$NS" delete deployment web-app --wait=true >/dev/nullkubectl --context k3d-cs-study-workbook -n "$NS" apply -f - <<'YAML'apiVersion: v1kind: ConfigMapmetadata: name: ai-backlogdata: tasks: "0"---apiVersion: apps/v1kind: Deploymentmetadata: name: ai-servicespec: replicas: 1 selector: matchLabels: app: ai-service template: metadata: labels: app: ai-service spec: terminationGracePeriodSeconds: 3 containers: - name: worker image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028 imagePullPolicy: IfNotPresent command: ["sh", "-c", "sleep 3600"] resources: requests: cpu: 25m memory: 16Mi limits: cpu: 100m memory: 32Mi---apiVersion: keda.sh/v1alpha1kind: ScaledObjectmetadata: name: ai-servicespec: scaleTargetRef: name: ai-service pollingInterval: 2 cooldownPeriod: 5 minReplicaCount: 0 maxReplicaCount: 4 advanced: horizontalPodAutoscalerConfig: behavior: scaleDown: stabilizationWindowSeconds: 0 policies: - type: Percent value: 100 periodSeconds: 15 triggers: - type: kubernetes-resource metadata: resourceKind: ConfigMap resourceName: ai-backlog key: tasks format: number targetValue: "1" activationTargetValue: "0"YAMLkubectl --context k3d-cs-study-workbook -n "$NS" \ rollout status deployment/ai-service --timeout=90s >/dev/nullfor _ in $(seq 1 90); do REPLICAS="$(kubectl --context k3d-cs-study-workbook -n "$NS" get deployment ai-service \ -o jsonpath='{.spec.replicas}')" ACTIVE="$(kubectl --context k3d-cs-study-workbook -n "$NS" get scaledobject ai-service \ -o jsonpath='{.status.conditions[?(@.type=="Active")].status}')" READY="$(kubectl --context k3d-cs-study-workbook -n "$NS" get scaledobject ai-service \ -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}')" test "$REPLICAS" = "0" && test "$ACTIVE" = "False" && test "$READY" = "True" && break sleep 2doneHPA_TARGET="$(kubectl --context k3d-cs-study-workbook -n "$NS" get hpa \ keda-hpa-ai-service -o jsonpath='{.spec.scaleTargetRef.name}')"HPA_OWNER_KIND="$(kubectl --context k3d-cs-study-workbook -n "$NS" get hpa \ keda-hpa-ai-service -o jsonpath='{.metadata.ownerReferences[0].kind}')"HPA_OWNER_NAME="$(kubectl --context k3d-cs-study-workbook -n "$NS" get hpa \ keda-hpa-ai-service -o jsonpath='{.metadata.ownerReferences[0].name}')"HPA_TARGET_KIND="$(kubectl --context k3d-cs-study-workbook -n "$NS" get hpa \ keda-hpa-ai-service -o jsonpath='{.spec.scaleTargetRef.kind}')"test "$REPLICAS" = "0"test "$ACTIVE" = "False"test "$READY" = "True"test "$HPA_TARGET" = "ai-service"test "$HPA_OWNER_KIND/$HPA_OWNER_NAME" = "ScaledObject/ai-service"test "$HPA_TARGET_KIND/$HPA_TARGET" = "Deployment/ai-service"printf 'keda phase=idle tasks=0 active=%s replicas=%s generated_hpa=keda-hpa-ai-service\n' \ "$ACTIVE" "$REPLICAS"printf 'hpa_owner=%s/%s scale_target=%s/%s\n' \ "$HPA_OWNER_KIND" "$HPA_OWNER_NAME" "$HPA_TARGET_KIND" "$HPA_TARGET"예상 출력:
keda phase=idle tasks=0 active=False replicas=0 generated_hpa=keda-hpa-ai-servicehpa_owner=ScaledObject/ai-service scale_target=Deployment/ai-servicecondition 갱신 시간과 scale-to-zero 수렴 시간은 변동 필드입니다. 생성된 HPA는 ScaledObject가 소유하므로 운영에서 직접 수정하지 않고 ScaledObject 원본을 변경합니다.
Lab 6 — backlog를 4로 바꿔 KEDA 0→4 scaling 확인하기
섹션 제목: “Lab 6 — backlog를 4로 바꿔 KEDA 0→4 scaling 확인하기”가상 backlog를 4로 바꿉니다. KEDA가 먼저 0→1을 활성화하고, 생성된 HPA가 1→4를 조정한 뒤 실제 Pod 4개가 Ready인지 확인합니다.
set -euo pipefailtest "$(kubectl config current-context)" = "k3d-cs-study-workbook"NS="$(cat /tmp/k8s-wb-m8/namespace)"kubectl --context k3d-cs-study-workbook -n "$NS" patch configmap ai-backlog \ --type=merge -p '{"data":{"tasks":"4"}}' >/dev/nullDESIRED=""READY_REPLICAS=""ACTIVE=""for _ in $(seq 1 120); do DESIRED="$(kubectl --context k3d-cs-study-workbook -n "$NS" get hpa \ keda-hpa-ai-service -o jsonpath='{.status.desiredReplicas}')" READY_REPLICAS="$(kubectl --context k3d-cs-study-workbook -n "$NS" get deployment \ ai-service -o jsonpath='{.status.readyReplicas}')" ACTIVE="$(kubectl --context k3d-cs-study-workbook -n "$NS" get scaledobject ai-service \ -o jsonpath='{.status.conditions[?(@.type=="Active")].status}')" test "$DESIRED" = "4" && test "$READY_REPLICAS" = "4" && test "$ACTIVE" = "True" && break sleep 2doneTASKS="$(kubectl --context k3d-cs-study-workbook -n "$NS" get configmap ai-backlog \ -o jsonpath='{.data.tasks}')"test "$TASKS" = "4"test "$DESIRED" = "4"test "$READY_REPLICAS" = "4"test "$ACTIVE" = "True"printf 'keda phase=active tasks=%s active=%s hpa_desired=%s ready=%s\n' \ "$TASKS" "$ACTIVE" "$DESIRED" "$READY_REPLICAS"예상 출력:
keda phase=active tasks=4 active=True hpa_desired=4 ready=40→1과 1→4의 중간 replica, Pod 이름과 metric poll 시간은 변동 필드입니다. replica가 늘었다고 실제 queue 처리율이나 downstream quota가 늘었다고 결론 내리면 안 됩니다.
Lab 7 — backlog를 비워 KEDA 4→0 scaling 확인하기
섹션 제목: “Lab 7 — backlog를 비워 KEDA 4→0 scaling 확인하기”tasks=0으로 되돌리고 cooldown과 HPA scale-down을 거쳐 worker가 0개가 되는지 확인합니다.
set -euo pipefailtest "$(kubectl config current-context)" = "k3d-cs-study-workbook"NS="$(cat /tmp/k8s-wb-m8/namespace)"kubectl --context k3d-cs-study-workbook -n "$NS" patch configmap ai-backlog \ --type=merge -p '{"data":{"tasks":"0"}}' >/dev/nullREPLICAS=""ACTIVE=""for _ in $(seq 1 120); do REPLICAS="$(kubectl --context k3d-cs-study-workbook -n "$NS" get deployment ai-service \ -o jsonpath='{.spec.replicas}')" ACTIVE="$(kubectl --context k3d-cs-study-workbook -n "$NS" get scaledobject ai-service \ -o jsonpath='{.status.conditions[?(@.type=="Active")].status}')" test "$REPLICAS" = "0" && test "$ACTIVE" = "False" && break sleep 2doneTASKS="$(kubectl --context k3d-cs-study-workbook -n "$NS" get configmap ai-backlog \ -o jsonpath='{.data.tasks}')"test "$TASKS" = "0"test "$REPLICAS" = "0"test "$ACTIVE" = "False"printf 'keda phase=drained tasks=%s active=%s replicas=%s\n' "$TASKS" "$ACTIVE" "$REPLICAS"예상 출력:
keda phase=drained tasks=0 active=False replicas=0scale-in과 Pod 종료 시간은 변동 필드입니다. 실제 queue는 재시도 중 메시지, in-flight 작업과 visibility timeout 때문에 단순 길이 0만으로 안전한 종료를 보장하지 않을 수 있습니다.
Lab 8 — Node 용량 부족 신호와 로컬 경계 확인하기
섹션 제목: “Lab 8 — Node 용량 부족 신호와 로컬 경계 확인하기”현재 Node 어느 곳에도 들어갈 수 없는 CPU request를 만들어 Pending과 Insufficient cpu를
관찰합니다. k3d에는 cloud node pool provider와 Cluster Autoscaler가 없으므로 Node 수는 그대로이며,
Ready 대기는 의도적으로 exit 1입니다.
set -euo pipefailtest "$(kubectl config current-context)" = "k3d-cs-study-workbook"NS="$(cat /tmp/k8s-wb-m8/namespace)"NODES_BEFORE="$(kubectl --context k3d-cs-study-workbook get nodes --no-headers | wc -l | tr -d ' ')"CA_PODS="$(kubectl --context k3d-cs-study-workbook get pods -A \ -l app.kubernetes.io/name=cluster-autoscaler --no-headers 2>/dev/null | wc -l | tr -d ' ')"test "$CA_PODS" = "0"kubectl --context k3d-cs-study-workbook -n "$NS" apply -f - <<'YAML'apiVersion: apps/v1kind: Deploymentmetadata: name: capacity-probespec: replicas: 1 selector: matchLabels: app: capacity-probe template: metadata: labels: app: capacity-probe spec: containers: - name: probe image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028 imagePullPolicy: IfNotPresent command: ["sh", "-c", "sleep 3600"] resources: requests: cpu: "100000" memory: 16Mi limits: cpu: "100000" memory: 32MiYAMLREASON=""MESSAGE=""for _ in $(seq 1 30); do REASON="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod \ -l app=capacity-probe -o jsonpath='{.items[0].status.conditions[?(@.type=="PodScheduled")].reason}')" MESSAGE="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod \ -l app=capacity-probe -o jsonpath='{.items[0].status.conditions[?(@.type=="PodScheduled")].message}')" test "$REASON" = "Unschedulable" && break sleep 1donecase "$MESSAGE" in *"Insufficient cpu"*) ;; *) echo 'expected Insufficient cpu evidence' >&2; exit 2 ;; esacsleep 5NODES_AFTER="$(kubectl --context k3d-cs-study-workbook get nodes --no-headers | wc -l | tr -d ' ')"test "$REASON" = "Unschedulable"test "$NODES_AFTER" = "$NODES_BEFORE"set +ekubectl --context k3d-cs-study-workbook -n "$NS" \ wait --for=condition=Available deployment/capacity-probe --timeout=5s \ >/tmp/k8s-wb-m8/capacity-ready.log 2>&1READY_EXIT=$?set -etest "$READY_EXIT" = "1"printf 'capacity pod=Pending reason=%s cause=Insufficient-cpu nodes=%s->%s local_ca=absent ready_exit=%s\n' \ "$REASON" "$NODES_BEFORE" "$NODES_AFTER" "$READY_EXIT"exit 1예상 출력과 종료 코드:
capacity pod=Pending reason=Unschedulable cause=Insufficient-cpu nodes=2->2 local_ca=absent ready_exit=1exit code: 1Pod 이름과 scheduler message는 변동 필드입니다. NKS에서는 이 지점부터 node pool의 CA 활성화,
min/max, quota, Pod selector·taint 적합성과 cluster-autoscaler-status를 확인합니다. Node가 늘어도
새 Node가 Ready되고 Pod가 배치될 때까지 복구 완료가 아닙니다.
Cleanup
섹션 제목: “Cleanup”Pending fixture를 먼저 지운 뒤 ScaledObject, Helm release, namespace와 KEDA cluster-scoped 리소스가 모두 사라졌는지 확인합니다. 공유 k3d cluster와 Metrics Server는 유지합니다.
set -euo pipefailtest "$(kubectl config current-context)" = "k3d-cs-study-workbook"NS="$(cat /tmp/k8s-wb-m8/namespace)"kubectl --context k3d-cs-study-workbook -n "$NS" delete deployment capacity-probe \ --ignore-not-found --wait=true >/dev/nullkubectl --context k3d-cs-study-workbook -n "$NS" delete scaledobject ai-service \ --ignore-not-found --wait=true >/dev/nullfor _ in $(seq 1 30); do HPA_REMAINING="$(kubectl --context k3d-cs-study-workbook -n "$NS" get hpa \ keda-hpa-ai-service --ignore-not-found -o name)" test -z "$HPA_REMAINING" && break sleep 1donetest -z "$HPA_REMAINING"helm uninstall keda-m8 --kube-context k3d-cs-study-workbook -n "$NS" --wait >/dev/nullkubectl --context k3d-cs-study-workbook delete namespace "$NS" --wait=true >/dev/nulltest -z "$(kubectl --context k3d-cs-study-workbook get namespace "$NS" \ --ignore-not-found -o name)"test -z "$(helm list --kube-context k3d-cs-study-workbook -A \ --filter '^keda-m8$' -q)"test -z "$(kubectl --context k3d-cs-study-workbook get crd \ scaledobjects.keda.sh scaledjobs.keda.sh triggerauthentications.keda.sh \ clustertriggerauthentications.keda.sh cloudeventsources.eventing.keda.sh \ clustercloudeventsources.eventing.keda.sh --ignore-not-found -o name)"test -z "$(kubectl --context k3d-cs-study-workbook get apiservice \ v1beta1.external.metrics.k8s.io --ignore-not-found -o name)"CLUSTER_SCOPED_REMAINING="$(kubectl --context k3d-cs-study-workbook get \ clusterrole,clusterrolebinding,apiservice,validatingwebhookconfiguration \ -l app.kubernetes.io/instance=keda-m8 -o name 2>/dev/null)"test -z "$CLUSTER_SCOPED_REMAINING"NODE_READY="$(kubectl --context k3d-cs-study-workbook get nodes --no-headers \ | awk '$2 == "Ready" {count++} END {print count+0}')"test "$NODE_READY" = "2"rm -rf /tmp/k8s-wb-m8test ! -e /tmp/k8s-wb-m8printf 'cleanup namespace=0 release=0 hpa=0 keda_cluster_scoped=0 nodes_ready=%s cluster=retained\n' \ "$NODE_READY"예상 출력:
cleanup namespace=0 release=0 hpa=0 keda_cluster_scoped=0 nodes_ready=2 cluster=retained삭제 수렴 시간은 변동 필드입니다. cleanup이 중간에 실패하면 남은 KEDA CRD나 APIService를 다른 소유자의 리소스로 가정해 무조건 삭제하지 말고 Helm release와 label을 먼저 확인합니다.
5. 관찰 포인트
섹션 제목: “5. 관찰 포인트”- CPU 부하가 있어도 request가 없으면 HPA
ScalingActive=False입니다. - request를 추가하면 같은 Pod가 request 대비 CPU 비율을 제공하고 HPA가 1→4로 늘립니다.
- 부하를 낮추면 HPA가 min replica 1로 줄지만, 실제 production 안정화 시간은 별도 결정입니다.
- KEDA ScaledObject를 만들면
keda-hpa-*가 생기며 0↔1과 1↔N의 소유자가 나뉩니다. - backlog 0→4→0에서
ai-service가 0→4→0으로 움직입니다. - replica가 생겨도 Node에 fit하지 못하면
Pending이며, Pod층 scaling 성공과 용량 확보 성공은 같은 판정이 아닙니다. - cleanup 뒤 module namespace, Helm release, HPA와 KEDA cluster-scoped 리소스가 모두 0입니다.
6. 셀프체크 Q&A
섹션 제목: “6. 셀프체크 Q&A”Q1. api-service p95 latency가 높지만 HPA CPU는 20%입니다. max replica를 2배로 올리는 PR을 승인하시겠습니까?
섹션 제목: “Q1. api-service p95 latency가 높지만 HPA CPU는 20%입니다. max replica를 2배로 올리는 PR을 승인하시겠습니까?”답: 바로 승인하지 않습니다. CPU가 병목이라는 증거가 없으므로 DB connection pool, 외부 API 대기, timeout·retry와 request rate를 먼저 봅니다. 필요한 신호가 queue나 request rate라면 custom metric 또는 KEDA 후보를 검토하고, replica 증가가 downstream 한도를 넘지 않는지도 확인합니다.
Q2. ai-service의 generated HPA는 desired replica 8을 만들었지만 5개 Pod가 Pending입니다. 어디부터 보시겠습니까?
섹션 제목: “Q2. ai-service의 generated HPA는 desired replica 8을 만들었지만 5개 Pod가 Pending입니다. 어디부터 보시겠습니까?”답: ScaledObject는 event source와 scaling 설정의 원본이고, 그 owner가 만든 HPA가 1↔N의
desired replica를 계산했으므로 먼저 Deployment의 desired·Ready 수와 Pending Pod의 Scheduler
Events를 분리해서 봅니다. Insufficient cpu, selector·taint·affinity와 request를 확인한 뒤 NKS
node pool의 CA 활성화, min/max, quota와 해당 Pod를 받을 수 있는 pool template을 확인합니다.
KEDA maxReplicaCount만 더 올리면 Pending만 늘 수 있습니다.
Q3. queue backlog 0을 확인하자마자 worker를 0개로 줄이는 설정을 승인하시겠습니까?
섹션 제목: “Q3. queue backlog 0을 확인하자마자 worker를 0개로 줄이는 설정을 승인하시겠습니까?”답: in-flight 작업, visibility timeout, retry와 graceful shutdown이 안전한지 확인하기 전에는 승인하지 않습니다. backlog 길이 0이 처리 중 작업 0을 뜻하지 않을 수 있으므로 cooldown, terminationGracePeriod, idempotency와 처리 완료 metric을 함께 검토합니다.
7. 흔한 함정
섹션 제목: “7. 흔한 함정”함정 1 — CPU request를 용량 예약으로만 보기
섹션 제목: “함정 1 — CPU request를 용량 예약으로만 보기”CPU utilization HPA는 실제 사용량을 request로 나눕니다. request가 없으면 metric이 정의되지 않고, 지나치게 낮은 request는 같은 사용량을 과도한 utilization로 보이게 합니다. request는 scheduler와 HPA 양쪽에 영향을 주므로 측정 근거가 필요합니다.
함정 2 — KEDA가 만든 HPA를 직접 수정하기
섹션 제목: “함정 2 — KEDA가 만든 HPA를 직접 수정하기”KEDA의 1↔N scaling은 생성된 HPA를 사용하지만 그 HPA의 desired state owner는 ScaledObject입니다. 긴급 수정도 ScaledObject 또는 선언 원본에 남기지 않으면 reconciliation으로 되돌아가거나 Git과 drift가 생깁니다.
함정 3 — replica 증가를 처리량 증가로 간주하기
섹션 제목: “함정 3 — replica 증가를 처리량 증가로 간주하기”새 Pod가 Pending이거나 readiness에 실패하면 serving capacity는 늘지 않습니다. 모두 Running이어도 DB pool, model API quota와 queue partition 수가 병목이면 처리량은 그대로일 수 있습니다. Pod, Node, application 처리율을 순서대로 확인합니다.
8. 다음 모듈 연결 고리
섹션 제목: “8. 다음 모듈 연결 고리”다음 M9에서는 autoscaling으로 새로 생긴 Pod가 실제 트래픽을 받을 준비가 되었는지 probe로 판정하고, 그 신호를 rollout·rollback과 연결해 무중단 배포 경계를 확인합니다.