콘텐츠로 이동

M8. HPA·KEDA·Cluster Autoscaler

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 용량 계층이라는 공통 위치에만 대응시킵니다.

metric에서 Pod와 Node 용량까지
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 수 조정"]
계층입력 신호바꾸는 대상이 모듈에서 확인할 상태
HPAPod CPU 사용량 ÷ CPU requestDeployment replicaHPA TARGETS, desired/ready replicas
KEDAqueue·stream·API 같은 event metric0↔1 활성화 + HPA를 통한 1↔N replicaScaledObject Ready/Active, 생성된 HPA
Scheduler새 Pod의 request·배치 제약Pod를 기존 Node에 배치PodScheduled=False, Events
Cluster Autoscaler기존 Node에 fit하지 못한 Pending Podnode 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-appCPU 또는 request rateCPU가 실제 병목이며 replica별 request가 측정값에 맞는가?
api-serviceCPU만으로 부족할 수 있음DB connection pool·외부 API 대기가 latency 원인 아닌가?
ai-servicequeue backlog·oldest message ageworker를 늘려도 model/API quota와 처리율이 함께 늘어나는가?

시점 의존 설명은 2026-07-15에 다음 공식 1차 자료로 확인했습니다.

실제 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별 심화 튜닝은 범위 밖입니다.

개념비유비유의 경계
HPA계산대 줄과 직원의 작업률을 보고 같은 계산대를 더 여는 관리자CPU가 아닌 대기·외부 병목은 이 신호로 보이지 않습니다.
KEDA주문함에 쌓인 주문 수를 보고 작업조를 부르는 배차 담당자event source의 신뢰성과 처리 한도를 자동 해결하지 않습니다.
Cluster Autoscaler배치할 작업조가 생겼는데 자리가 없을 때 작업장을 늘리는 시설 담당자높은 CPU 자체가 아니라 배치 불가능한 Pod 요구를 중심으로 봅니다.
항목검증값
기준일2026-07-15
hostmacOS arm64
Docker client / engine29.6.1 / 29.6.1
k3d5.9.0
K3s image / API serverrancher/k3s:v1.36.2-k3s1 / v1.36.2+k3s1
kubectl1.36.1
Helm4.2.3
KEDA chart / app2.20.1 / 2.20.1
workload imagebusybox: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-workbook cluster가 실행 중이어야 합니다.
  • K3s의 Metrics Server와 metrics.k8s.io API가 정상이어야 합니다.
  • KEDA chart와 component image를 처음 받는 환경은 인터넷 연결이 필요합니다.
  • 고정 context 외 환경, 기존 KEDA CRD 또는 external metrics API가 있으면 Setup이 중단됩니다.
  • 예상 소요 시간은 110분이며 metric 수집과 scale 수렴 대기 시간을 포함합니다.

격리 namespace와 고정 KEDA chart 준비하기

섹션 제목: “격리 namespace와 고정 KEDA chart 준비하기”

Helm config도 /tmp/k8s-wb-m8 아래에 격리합니다. Setup 도중 실패하면 namespace와 임시 chart를 지우며, 기존 KEDA가 있는 cluster에서는 소유권 충돌을 피하려고 시작하지 않습니다.

Terminal window
set -euo pipefail
rm -rf /tmp/k8s-wb-m8
mkdir -p /tmp/k8s-wb-m8/helm-config /tmp/k8s-wb-m8/helm-cache /tmp/k8s-wb-m8/helm-data
chmod 700 /tmp/k8s-wb-m8
node automation/k8s-workbook/preflight.mjs --require-cluster
test "$(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.json
test "$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1])).items.length)' \
/tmp/k8s-wb-m8/node-metrics.json)" -ge 1
test -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/namespace
if 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 1
fi
rollback() {
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 EXIT
HELM_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/null
HELM_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/null
HELM_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-m8
test -f /tmp/k8s-wb-m8/keda-2.20.1.tgz
kubectl --context k3d-cs-study-workbook create namespace "$NS" >/dev/null
trap - EXIT
printf 'setup namespace=%s metrics_api=ready keda_chart=2.20.1\n' "$NS"

예상 출력:

PASS platform: darwin/arm64
PASS architecture: arm64
PASS docker-cli: v29.6.1
PASS docker-engine: v29.6.1
PASS k3d: v5.9.0
PASS kubectl-skew: v1.36.1
PASS helm: v4.2.3
PASS context: k3d-cs-study-workbook
PASS kubernetes-server: v1.36.2+k3s1
setup namespace=k8s-wb-m8-<UTC 14자리 run ID> metrics_api=ready keda_chart=2.20.1

host architecture, Docker patch version, namespace와 chart download 시간은 변동 필드입니다.

Lab 1 — CPU request가 없는 HPA 실패 재현하기

섹션 제목: “Lab 1 — CPU request가 없는 HPA 실패 재현하기”

CPU를 계속 쓰는 Pod라도 CPU request가 없으면 utilization의 분모가 없습니다. HPA가 ScalingActive=FalseFailedGetResourceMetric을 남기는지 확인하고 의도적으로 exit 1로 끝냅니다.

Terminal window
set -euo pipefail
test "$(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/v1
kind: Deployment
metadata:
name: web-app
spec:
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/v2
kind: HorizontalPodAutoscaler
metadata:
name: web-app
spec:
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: 50
YAML
kubectl --context k3d-cs-study-workbook -n "$NS" \
rollout status deployment/web-app --timeout=90s >/dev/null
REASON=""
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 2
done
test "$REASON" = "FailedGetResourceMetric"
case "$MESSAGE" in *"missing request for cpu"*) ;; *) echo 'missing-request evidence absent' >&2; exit 2 ;; esac
printf '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-request
exit code: 1

Pod 이름, 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를 함께 확인합니다.

Terminal window
set -euo pipefail
test "$(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/null
kubectl --context k3d-cs-study-workbook -n "$NS" \
rollout status deployment/web-app --timeout=90s >/dev/null
DESIRED=""
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 2
done
CURRENT_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 50
printf '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=4

CPU 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 권장값이 아닙니다.

Terminal window
set -euo pipefail
test "$(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/null
kubectl --context k3d-cs-study-workbook -n "$NS" \
rollout status deployment/web-app --timeout=90s >/dev/null
DESIRED=""
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 2
done
CURRENT_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 50
printf '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=1

metric 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에서만 실행합니다.

Terminal window
set -euo pipefail
test "$(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/null
for 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/null
done
STATUS_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=True

Pod 이름, 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로 활성화 해제하는지 확인합니다.

Terminal window
set -euo pipefail
test "$(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/null
kubectl --context k3d-cs-study-workbook -n "$NS" delete deployment web-app --wait=true >/dev/null
kubectl --context k3d-cs-study-workbook -n "$NS" apply -f - <<'YAML'
apiVersion: v1
kind: ConfigMap
metadata:
name: ai-backlog
data:
tasks: "0"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-service
spec:
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/v1alpha1
kind: ScaledObject
metadata:
name: ai-service
spec:
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"
YAML
kubectl --context k3d-cs-study-workbook -n "$NS" \
rollout status deployment/ai-service --timeout=90s >/dev/null
for _ 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 2
done
HPA_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-service
hpa_owner=ScaledObject/ai-service scale_target=Deployment/ai-service

condition 갱신 시간과 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인지 확인합니다.

Terminal window
set -euo pipefail
test "$(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/null
DESIRED=""
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 2
done
TASKS="$(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=4

0→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개가 되는지 확인합니다.

Terminal window
set -euo pipefail
test "$(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/null
REPLICAS=""
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 2
done
TASKS="$(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=0

scale-in과 Pod 종료 시간은 변동 필드입니다. 실제 queue는 재시도 중 메시지, in-flight 작업과 visibility timeout 때문에 단순 길이 0만으로 안전한 종료를 보장하지 않을 수 있습니다.

Lab 8 — Node 용량 부족 신호와 로컬 경계 확인하기

섹션 제목: “Lab 8 — Node 용량 부족 신호와 로컬 경계 확인하기”

현재 Node 어느 곳에도 들어갈 수 없는 CPU request를 만들어 PendingInsufficient cpu를 관찰합니다. k3d에는 cloud node pool provider와 Cluster Autoscaler가 없으므로 Node 수는 그대로이며, Ready 대기는 의도적으로 exit 1입니다.

Terminal window
set -euo pipefail
test "$(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/v1
kind: Deployment
metadata:
name: capacity-probe
spec:
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: 32Mi
YAML
REASON=""
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 1
done
case "$MESSAGE" in *"Insufficient cpu"*) ;; *) echo 'expected Insufficient cpu evidence' >&2; exit 2 ;; esac
sleep 5
NODES_AFTER="$(kubectl --context k3d-cs-study-workbook get nodes --no-headers | wc -l | tr -d ' ')"
test "$REASON" = "Unschedulable"
test "$NODES_AFTER" = "$NODES_BEFORE"
set +e
kubectl --context k3d-cs-study-workbook -n "$NS" \
wait --for=condition=Available deployment/capacity-probe --timeout=5s \
>/tmp/k8s-wb-m8/capacity-ready.log 2>&1
READY_EXIT=$?
set -e
test "$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=1
exit code: 1

Pod 이름과 scheduler message는 변동 필드입니다. NKS에서는 이 지점부터 node pool의 CA 활성화, min/max, quota, Pod selector·taint 적합성과 cluster-autoscaler-status를 확인합니다. Node가 늘어도 새 Node가 Ready되고 Pod가 배치될 때까지 복구 완료가 아닙니다.

Pending fixture를 먼저 지운 뒤 ScaledObject, Helm release, namespace와 KEDA cluster-scoped 리소스가 모두 사라졌는지 확인합니다. 공유 k3d cluster와 Metrics Server는 유지합니다.

Terminal window
set -euo pipefail
test "$(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/null
kubectl --context k3d-cs-study-workbook -n "$NS" delete scaledobject ai-service \
--ignore-not-found --wait=true >/dev/null
for _ 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 1
done
test -z "$HPA_REMAINING"
helm uninstall keda-m8 --kube-context k3d-cs-study-workbook -n "$NS" --wait >/dev/null
kubectl --context k3d-cs-study-workbook delete namespace "$NS" --wait=true >/dev/null
test -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-m8
test ! -e /tmp/k8s-wb-m8
printf '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을 먼저 확인합니다.

  1. CPU 부하가 있어도 request가 없으면 HPA ScalingActive=False입니다.
  2. request를 추가하면 같은 Pod가 request 대비 CPU 비율을 제공하고 HPA가 1→4로 늘립니다.
  3. 부하를 낮추면 HPA가 min replica 1로 줄지만, 실제 production 안정화 시간은 별도 결정입니다.
  4. KEDA ScaledObject를 만들면 keda-hpa-*가 생기며 0↔1과 1↔N의 소유자가 나뉩니다.
  5. backlog 0→4→0에서 ai-service가 0→4→0으로 움직입니다.
  6. replica가 생겨도 Node에 fit하지 못하면 Pending이며, Pod층 scaling 성공과 용량 확보 성공은 같은 판정이 아닙니다.
  7. cleanup 뒤 module namespace, Helm release, HPA와 KEDA cluster-scoped 리소스가 모두 0입니다.

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을 함께 검토합니다.

함정 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 처리율을 순서대로 확인합니다.

다음 M9에서는 autoscaling으로 새로 생긴 Pod가 실제 트래픽을 받을 준비가 되었는지 probe로 판정하고, 그 신호를 rollout·rollback과 연결해 무중단 배포 경계를 확인합니다.