콘텐츠로 이동

M6. 스케줄링·리소스·node pool

NKS에서 web-app, api-service, ai-service를 운영할 때 AI가 “고사양 node pool에 배치하자”거나 “CPU를 2개 보장하자”고 제안해도, Pod가 어떤 Node를 후보로 삼는지와 그 Node에 실제로 들어갈 수 있는지는 별도 판단입니다. label·selector, taint·toleration, requests·limits의 역할을 분리해야 Pending 원인을 읽고 node pool 변경을 리뷰할 수 있습니다.

개념 정의의 정본은 Kubernetes Basicscgroups & Namespace입니다. 원본 경로는 각각 content/topics/L5/kubernetes-basics.mdx, content/topics/L4/cgroups-namespace.mdx이며, 이 모듈은 M3의 Pod template과 M5의 3서비스 설정 위에서 배치 후보 필터링과 resource fit을 실행으로 관찰하는 역할만 맡습니다.

Pod가 Node에 배치되기까지의 판단 순서
flowchart LR
Pod["새 Pod: 아직 nodeName 없음"] --> Filter["후보 Node 필터"]
Labels["node label + nodeSelector/affinity"] --> Filter
Taints["node taint + Pod toleration"] --> Filter
Filter --> Fit["requests 합계가 allocatable 안에 드는가"]
Fit --> Score["남은 후보를 점수화"]
Score --> Bind["Pod.spec.nodeName에 bind"]
Bind --> Runtime["kubelet: limits를 runtime/cgroup에 전달"]
장치붙는 곳스케줄링에서 하는 일하지 않는 일
Node labelNodeNode의 pool·zone·장비 특성을 기술Pod를 스스로 이동시키지 않음
nodeSelectorPod모든 key/value가 맞는 Node만 후보로 남김우선순위나 fallback을 표현하지 못함
node affinityPodrequired 또는 preferred 조건으로 후보·점수를 제어taint를 자동 허용하지 않음
taintNode일치하는 toleration이 없는 Pod를 밀어냄특정 Pod를 그 Node로 끌어오지 않음
tolerationPod대응 taint가 있는 Node도 후보가 될 수 있게 허용그 Node 배치를 보장하지 않음
requestsContainerscheduler가 Node fit을 계산할 때 더하는 예약 의도현재 사용량이나 성능 보장을 직접 측정하지 않음
limitsContainer실행 중 kubelet/runtime이 적용할 사용 상한scheduler의 기본 fit 기준이 아님
Node allocatableNode시스템 몫을 제외하고 Pod에 배분 가능한 양을 표시모든 Pod가 그 양을 동시에 실제 사용한다는 뜻이 아님

requests는 실제 사용량이 아니라 배치 약속입니다. Node CPU가 지금 5%만 사용 중이어도 이미 배치된 Pod들의 CPU request 합계와 새 Pod의 request가 allocatable을 넘으면 새 Pod는 Pending에 머뭅니다. 반대로 실행 중 container는 여유가 있으면 request보다 더 쓸 수 있습니다.

limits는 실행 단계의 경계입니다. Linux Node에서 CPU limit은 일반적으로 throttling으로, memory limit은 reclaim으로 해결되지 않을 때 OOM kill 가능성으로 이어집니다. 이 모듈은 값을 API와 QoS class에서 확인하되 의도적으로 부하나 OOM을 만들지 않습니다. runtime 장애 판독은 M11에서 다룹니다. 다만 admission 정책이 별도 기본값을 넣지 않는 환경에서 특정 resource의 limit만 적고 request를 생략하면 Kubernetes가 그 limit 값을 request로 복사할 수 있으므로, “request를 안 적었으니 scheduler 회계가 0”이라고 판단하면 안 됩니다.

로컬 배치 규칙과 NKS node pool의 경계
flowchart LR
Pool["NKS node pool: 같은 사양의 Node 묶음"] --> PoolLabel["pool 식별 Node label"]
Pool --> PoolTaint["선택적 taint"]
Spec["Deployment Pod template"] --> Selector["nodeSelector / affinity"]
Spec --> Toleration["필요한 toleration"]
Spec --> Resources["측정 기반 requests / limits"]
PoolLabel --> Scheduler["kube-scheduler"]
PoolTaint --> Scheduler
Selector --> Scheduler
Toleration --> Scheduler
Resources --> Scheduler
Scheduler --> Placement["적합한 Node에 Pod 배치"]

로컬 k3d의 두 Node에 붙이는 workbook.cs-study/node-pool label은 학습용 fixture입니다. NKS 공식 문서가 설명하는 실제 node pool 식별 label은 ncloud.com/nks-nodepool이지만, 실제 pool 이름·사양·zone·taint는 승인된 non-production NKS에서 확인해야 합니다. k3d 결과를 NKS 실측으로 해석하지 않습니다. 특히 이 로컬 K3s server Node는 학습용 2노드 대비를 위해 Pod를 받는 general fixture로만 사용합니다. NKS의 managed control plane과 대응하지 않으며, NKS의 general/compute node pool은 모두 사용자 workload가 실행되는 worker Node 묶음입니다.

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

NKS node pool 생성·사양 변경·taint 편집과 Cluster Autoscaler 활성화는 cloud 비용과 실제 workload 이동을 일으키므로 이 모듈에서는 official-doc-only입니다. 실제 API 호출, console 변경과 production Node 조작은 excluded이며 예상 출력을 제시하지 않습니다. M8에서 Pod autoscaling과 Node autoscaling의 계층을 분리해 이어갑니다.

개념비유비유의 경계
label + nodeSelector설비 표식과 그 설비를 반드시 요구하는 작업 지시서표식은 용량이나 출입 허가까지 보장하지 않습니다.
taint + toleration제한 구역 표지와 출입 허가증허가증이 있어도 그 구역으로 반드시 배정되지는 않습니다.
request배치 전에 확보한다고 신고한 좌석 수실시간 사용량과 같지 않고 성능 SLO도 자동 보장하지 않습니다.
limit실행 중 넘지 못하게 둔 사용 상한상한을 지나치게 낮추면 throttling이나 OOM 위험이 생깁니다.
node pool같은 설비 사양으로 관리되는 작업 구역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 — 이 모듈에서는 사용하지 않음
container imagebusybox:1.37.0 + Setup의 고정 multi-platform digest
evidence 환경local-cluster; 고정 context + k8s-wb-m6-<UTC run ID>

모든 shell 명령 묶음은 executed-local입니다. NKS node pool과 실제 production resource sizing은 각각 official-doc-only, excluded이며 로컬 관측값을 운영 권장값으로 사용하지 않습니다.

  • 저장소 root에서 실행하며 M0~M5를 완료한 상태여야 합니다.
  • Docker Desktop과 M2의 cs-study-workbook cluster가 실행 중이어야 합니다.
  • cluster는 M2에서 만든 server 1개와 agent 1개, 총 Ready Node 2개여야 합니다.
  • 모든 workload 명령은 --context k3d-cs-study-workbook과 고유 namespace를 명시합니다.
  • Setup은 전용 prefix의 학습 label·taint가 기존에 없는지 확인하고, cleanup에서 모두 제거합니다.
  • 예상 소요 시간은 110분입니다.

두 Node에 학습용 pool label과 taint 만들기

섹션 제목: “두 Node에 학습용 pool label과 taint 만들기”

성공 전 중간 오류가 나면 EXIT trap이 namespace와 Node 변경을 되돌립니다. 기존 label이나 taint를 덮어쓰지 않고 전용 key가 비어 있을 때만 시작합니다. 아래 GENERAL_NODE가 가리키는 K3s server는 로컬에서만 schedulable한 두 번째 fixture이며 NKS managed control plane을 모사하지 않습니다.

Terminal window
set -euo pipefail
rm -rf /tmp/k8s-wb-m6
mkdir -p /tmp/k8s-wb-m6
chmod 700 /tmp/k8s-wb-m6
node automation/k8s-workbook/preflight.mjs --require-cluster
test "$(kubectl config current-context)" = "k3d-cs-study-workbook"
NODE_COUNT="$(kubectl --context k3d-cs-study-workbook get nodes --no-headers | wc -l | tr -d ' ')"
test "$NODE_COUNT" = "2"
GENERAL_NODE="$(kubectl --context k3d-cs-study-workbook get nodes \
-l node-role.kubernetes.io/control-plane -o jsonpath='{.items[0].metadata.name}')"
test -n "$GENERAL_NODE"
COMPUTE_NODE="$(kubectl --context k3d-cs-study-workbook get nodes -o name \
| sed 's#node/##' | awk -v general="$GENERAL_NODE" '$0 != general {print}')"
test -n "$COMPUTE_NODE"
test "$(printf '%s\n' "$COMPUTE_NODE" | wc -l | tr -d ' ')" = "1"
for NODE in "$GENERAL_NODE" "$COMPUTE_NODE"; do
EXISTING_LABEL="$(kubectl --context k3d-cs-study-workbook get node "$NODE" \
-o jsonpath='{.metadata.labels.workbook\.cs-study/node-pool}')"
test -z "$EXISTING_LABEL"
done
EXISTING_TAINTS="$(kubectl --context k3d-cs-study-workbook get node "$COMPUTE_NODE" \
-o jsonpath='{range .spec.taints[*]}{.key}{"\n"}{end}' \
| awk '$0 == "workbook.cs-study/dedicated" {count++} END {print count+0}')"
test "$EXISTING_TAINTS" = "0"
NS="k8s-wb-m6-$(date -u +%Y%m%d%H%M%S)"
printf '%s' "$NS" > /tmp/k8s-wb-m6/namespace
printf '%s' "$GENERAL_NODE" > /tmp/k8s-wb-m6/general-node
printf '%s' "$COMPUTE_NODE" > /tmp/k8s-wb-m6/compute-node
if kubectl --context k3d-cs-study-workbook get namespace "$NS" >/dev/null 2>&1; then
echo "namespace collision: $NS" >&2
exit 1
fi
rollback() {
STATUS=$?
trap - EXIT
set +e
ROLLBACK_FAILED=0
kubectl --context k3d-cs-study-workbook delete namespace "$NS" \
--ignore-not-found --wait=true >/dev/null 2>&1
test $? = 0 || ROLLBACK_FAILED=1
for NODE in "$GENERAL_NODE" "$COMPUTE_NODE"; do
CURRENT_LABEL="$(kubectl --context k3d-cs-study-workbook get node "$NODE" \
-o jsonpath='{.metadata.labels.workbook\.cs-study/node-pool}' 2>/dev/null)"
GET_EXIT=$?
if test "$GET_EXIT" != 0; then
ROLLBACK_FAILED=1
elif test -n "$CURRENT_LABEL"; then
kubectl --context k3d-cs-study-workbook label node "$NODE" \
workbook.cs-study/node-pool- >/dev/null 2>&1
test $? = 0 || ROLLBACK_FAILED=1
fi
done
CURRENT_TAINTS="$(kubectl --context k3d-cs-study-workbook get node "$COMPUTE_NODE" \
-o jsonpath='{range .spec.taints[*]}{.key}{"\n"}{end}' 2>/dev/null \
| awk '$0 == "workbook.cs-study/dedicated" {count++} END {print count+0}')"
GET_EXIT=$?
if test "$GET_EXIT" != 0; then
ROLLBACK_FAILED=1
elif test "$CURRENT_TAINTS" != 0; then
kubectl --context k3d-cs-study-workbook taint node "$COMPUTE_NODE" \
workbook.cs-study/dedicated:NoSchedule- >/dev/null 2>&1
test $? = 0 || ROLLBACK_FAILED=1
fi
REMAINING_NAMESPACE="$(kubectl --context k3d-cs-study-workbook get namespace "$NS" \
--ignore-not-found -o name 2>/dev/null)"
test $? = 0 || ROLLBACK_FAILED=1
test -z "$REMAINING_NAMESPACE" || ROLLBACK_FAILED=1
REMAINING_LABELS="$(kubectl --context k3d-cs-study-workbook get nodes \
-l workbook.cs-study/node-pool -o name 2>/dev/null | awk 'END {print NR+0}')"
test $? = 0 || ROLLBACK_FAILED=1
test "$REMAINING_LABELS" = 0 || ROLLBACK_FAILED=1
REMAINING_TAINTS="$(kubectl --context k3d-cs-study-workbook get nodes \
-o jsonpath='{range .items[*].spec.taints[*]}{.key}{"\n"}{end}' 2>/dev/null \
| awk '$0 == "workbook.cs-study/dedicated" {count++} END {print count+0}')"
test $? = 0 || ROLLBACK_FAILED=1
test "$REMAINING_TAINTS" = 0 || ROLLBACK_FAILED=1
if test "$ROLLBACK_FAILED" != 0; then
echo 'rollback incomplete: preserve /tmp/k8s-wb-m6 and inspect cluster-scoped state' >&2
exit 70
fi
rm -rf /tmp/k8s-wb-m6
exit "$STATUS"
}
trap rollback EXIT
kubectl --context k3d-cs-study-workbook create namespace "$NS"
kubectl --context k3d-cs-study-workbook label node "$GENERAL_NODE" \
workbook.cs-study/node-pool=general >/dev/null
kubectl --context k3d-cs-study-workbook label node "$COMPUTE_NODE" \
workbook.cs-study/node-pool=compute >/dev/null
kubectl --context k3d-cs-study-workbook taint node "$COMPUTE_NODE" \
workbook.cs-study/dedicated=ai:NoSchedule >/dev/null
trap - EXIT
printf 'setup namespace=%s nodes=2 labels=2 compute_taints=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
namespace/k8s-wb-m6-<UTC 14자리 run ID> created
setup namespace=k8s-wb-m6-<UTC 14자리 run ID> nodes=2 labels=2 compute_taints=1

host architecture, Docker patch version, namespace run ID와 두 Node 이름은 변동 필드입니다.

Lab 1 — label·selector와 request/limit를 함께 읽기

섹션 제목: “Lab 1 — label·selector와 request/limit를 함께 읽기”

web-app fixture를 general label이 있는 Node에만 배치합니다. request와 limit가 서로 다르므로 이 Pod의 QoS class는 Burstable입니다.

Terminal window
set -euo pipefail
test "$(kubectl config current-context)" = "k3d-cs-study-workbook"
NS="$(cat /tmp/k8s-wb-m6/namespace)"
GENERAL_NODE="$(cat /tmp/k8s-wb-m6/general-node)"
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:
nodeSelector:
workbook.cs-study/node-pool: general
terminationGracePeriodSeconds: 3
containers:
- name: app
image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028
imagePullPolicy: IfNotPresent
command: ["sh", "-c", "sleep 3600"]
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
YAML
kubectl --context k3d-cs-study-workbook -n "$NS" \
rollout status deployment/web-app --timeout=90s >/dev/null
POD="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod -l app=web-app \
-o jsonpath='{.items[0].metadata.name}')"
NODE="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod "$POD" \
-o jsonpath='{.spec.nodeName}')"
QOS="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod "$POD" \
-o jsonpath='{.status.qosClass}')"
REQUESTS="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod "$POD" \
-o jsonpath='{.spec.containers[0].resources.requests.cpu}/{.spec.containers[0].resources.requests.memory}')"
LIMITS="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod "$POD" \
-o jsonpath='{.spec.containers[0].resources.limits.cpu}/{.spec.containers[0].resources.limits.memory}')"
test "$NODE" = "$GENERAL_NODE"
test "$QOS" = "Burstable"
test "$REQUESTS" = "25m/32Mi"
test "$LIMITS" = "100m/64Mi"
printf 'placement app=web-app pool=general qos=%s requests=%s limits=%s\n' \
"$QOS" "$REQUESTS" "$LIMITS"

예상 출력:

deployment.apps/web-app created
placement app=web-app pool=general qos=Burstable requests=25m/32Mi limits=100m/64Mi

관찰 포인트는 workload label app=web-app가 Deployment와 Pod를 연결하고, Node label을 읽는 nodeSelector가 별도의 배치 후보를 제한한다는 점입니다.

Lab 2 — toleration이 없어서 Pending이 되는 실패 재현

섹션 제목: “Lab 2 — toleration이 없어서 Pending이 되는 실패 재현”

compute Node를 정확히 선택해도 그 Node의 NoSchedule taint를 허용하지 않으면 bind되지 않습니다. 이 묶음은 의도적으로 exit 1을 반환합니다.

Terminal window
set -euo pipefail
test "$(kubectl config current-context)" = "k3d-cs-study-workbook"
NS="$(cat /tmp/k8s-wb-m6/namespace)"
kubectl --context k3d-cs-study-workbook -n "$NS" apply -f - <<'YAML'
apiVersion: v1
kind: Pod
metadata:
name: ai-service
labels:
app: ai-service
spec:
nodeSelector:
workbook.cs-study/node-pool: compute
terminationGracePeriodSeconds: 3
containers:
- name: worker
image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028
imagePullPolicy: IfNotPresent
command: ["sh", "-c", "sleep 3600"]
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: 500m
memory: 128Mi
YAML
REASON=""
MESSAGE=""
for _ in $(seq 1 30); do
REASON="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod ai-service \
-o jsonpath='{.status.conditions[?(@.type=="PodScheduled")].reason}')"
MESSAGE="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod ai-service \
-o jsonpath='{.status.conditions[?(@.type=="PodScheduled")].message}')"
test "$REASON" = "Unschedulable" && break
sleep 1
done
test "$REASON" = "Unschedulable"
case "$MESSAGE" in
*"untolerated taint"*) ;;
*) echo "expected untolerated taint evidence" >&2; exit 2 ;;
esac
set +e
kubectl --context k3d-cs-study-workbook -n "$NS" \
wait --for=condition=Ready pod/ai-service --timeout=5s \
> /tmp/k8s-wb-m6/taint-ready.log 2>&1
READY_EXIT=$?
set -e
test "$READY_EXIT" = "1"
grep -q 'timed out waiting for the condition' /tmp/k8s-wb-m6/taint-ready.log
printf 'pod=ai-service phase=Pending reason=%s cause=untolerated-taint ready_exit=%s\n' \
"$REASON" "$READY_EXIT"
exit 1

예상 출력:

pod/ai-service created
pod=ai-service phase=Pending reason=Unschedulable cause=untolerated-taint ready_exit=1

Unschedulable 수렴 시간과 원본 event message는 변동 필드입니다. 실패는 image pull이나 application crash보다 앞선 scheduler 단계에서 발생합니다.

Lab 3 — selector와 toleration을 함께 사용해 복구

섹션 제목: “Lab 3 — selector와 toleration을 함께 사용해 복구”

Pod의 주요 배치 필드는 실행 중 고쳐 쓰는 대상이 아니므로 실패한 Pod를 삭제하고 올바른 template으로 다시 만듭니다. toleration만 추가하지 않고 compute selector도 유지합니다.

Terminal window
set -euo pipefail
test "$(kubectl config current-context)" = "k3d-cs-study-workbook"
NS="$(cat /tmp/k8s-wb-m6/namespace)"
COMPUTE_NODE="$(cat /tmp/k8s-wb-m6/compute-node)"
kubectl --context k3d-cs-study-workbook -n "$NS" delete pod ai-service --wait=true >/dev/null
kubectl --context k3d-cs-study-workbook -n "$NS" apply -f - <<'YAML'
apiVersion: v1
kind: Pod
metadata:
name: ai-service
labels:
app: ai-service
spec:
nodeSelector:
workbook.cs-study/node-pool: compute
tolerations:
- key: workbook.cs-study/dedicated
operator: Equal
value: ai
effect: NoSchedule
terminationGracePeriodSeconds: 3
containers:
- name: worker
image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028
imagePullPolicy: IfNotPresent
command: ["sh", "-c", "sleep 3600"]
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: 500m
memory: 128Mi
YAML
kubectl --context k3d-cs-study-workbook -n "$NS" \
wait --for=condition=Ready pod/ai-service --timeout=90s >/dev/null
NODE="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod ai-service \
-o jsonpath='{.spec.nodeName}')"
SELECTOR="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod ai-service \
-o jsonpath='{.spec.nodeSelector.workbook\.cs-study/node-pool}')"
TOLERATION="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod ai-service \
-o jsonpath='{.spec.tolerations[?(@.key=="workbook.cs-study/dedicated")].value}')"
test "$NODE" = "$COMPUTE_NODE"
test "$SELECTOR" = "compute"
test "$TOLERATION" = "ai"
printf 'recovery app=ai-service pool=compute selector=%s toleration=%s ready=true\n' \
"$SELECTOR" "$TOLERATION"

예상 출력:

pod/ai-service created
recovery app=ai-service pool=compute selector=compute toleration=ai ready=true

관찰 포인트는 selector가 compute요구하고 toleration이 그 Node의 taint를 허용해 두 조건을 모두 만족한 뒤에야 배치된다는 점입니다.

Lab 4 — 사용량이 아니라 request 때문에 Pending이 되는 실패 재현

섹션 제목: “Lab 4 — 사용량이 아니라 request 때문에 Pending이 되는 실패 재현”

general Node의 실제 CPU 사용량을 읽지 않고, 확실히 allocatable을 넘는 가상 request를 제출합니다. 100000 CPU는 교육용 실패 fixture이며 운영 권장값이 아닙니다.

Terminal window
set -euo pipefail
test "$(kubectl config current-context)" = "k3d-cs-study-workbook"
NS="$(cat /tmp/k8s-wb-m6/namespace)"
kubectl --context k3d-cs-study-workbook -n "$NS" apply -f - <<'YAML'
apiVersion: v1
kind: Pod
metadata:
name: api-service
labels:
app: api-service
spec:
nodeSelector:
workbook.cs-study/node-pool: general
terminationGracePeriodSeconds: 3
containers:
- name: api
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 api-service \
-o jsonpath='{.status.conditions[?(@.type=="PodScheduled")].reason}')"
MESSAGE="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod api-service \
-o jsonpath='{.status.conditions[?(@.type=="PodScheduled")].message}')"
test "$REASON" = "Unschedulable" && break
sleep 1
done
test "$REASON" = "Unschedulable"
case "$MESSAGE" in
*"Insufficient cpu"*) ;;
*) echo "expected Insufficient cpu evidence" >&2; exit 2 ;;
esac
set +e
kubectl --context k3d-cs-study-workbook -n "$NS" \
wait --for=condition=Ready pod/api-service --timeout=5s \
> /tmp/k8s-wb-m6/request-ready.log 2>&1
READY_EXIT=$?
set -e
test "$READY_EXIT" = "1"
grep -q 'timed out waiting for the condition' /tmp/k8s-wb-m6/request-ready.log
printf 'pod=api-service phase=Pending reason=%s cause=Insufficient-cpu request=100000 ready_exit=%s\n' \
"$REASON" "$READY_EXIT"
exit 1

예상 출력:

pod/api-service created
pod=api-service phase=Pending reason=Unschedulable cause=Insufficient-cpu request=100000 ready_exit=1

Node allocatable 값, scheduler message와 수렴 시간은 환경에 따른 변동 필드입니다. 고정 증거는 request가 후보 Node의 allocatable보다 커서 Insufficient cpu가 발생했다는 것입니다.

Lab 5 — right-sized fixture로 복구하고 QoS 확인

섹션 제목: “Lab 5 — right-sized fixture로 복구하고 QoS 확인”

실제 운영값을 추측하지 않고 로컬 랩에서 들어갈 수 있는 작은 fixture로 교체합니다. production request·limit는 부하 테스트, latency, throttling과 OOM 관측을 근거로 별도 결정해야 합니다.

Terminal window
set -euo pipefail
test "$(kubectl config current-context)" = "k3d-cs-study-workbook"
NS="$(cat /tmp/k8s-wb-m6/namespace)"
GENERAL_NODE="$(cat /tmp/k8s-wb-m6/general-node)"
kubectl --context k3d-cs-study-workbook -n "$NS" delete pod api-service --wait=true >/dev/null
kubectl --context k3d-cs-study-workbook -n "$NS" apply -f - <<'YAML'
apiVersion: v1
kind: Pod
metadata:
name: api-service
labels:
app: api-service
spec:
nodeSelector:
workbook.cs-study/node-pool: general
terminationGracePeriodSeconds: 3
containers:
- name: api
image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028
imagePullPolicy: IfNotPresent
command: ["sh", "-c", "sleep 3600"]
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
cpu: 200m
memory: 64Mi
YAML
kubectl --context k3d-cs-study-workbook -n "$NS" \
wait --for=condition=Ready pod/api-service --timeout=90s >/dev/null
NODE="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod api-service \
-o jsonpath='{.spec.nodeName}')"
QOS="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod api-service \
-o jsonpath='{.status.qosClass}')"
REQUESTS="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod api-service \
-o jsonpath='{.spec.containers[0].resources.requests.cpu}/{.spec.containers[0].resources.requests.memory}')"
LIMITS="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod api-service \
-o jsonpath='{.spec.containers[0].resources.limits.cpu}/{.spec.containers[0].resources.limits.memory}')"
test "$NODE" = "$GENERAL_NODE"
test "$QOS" = "Burstable"
test "$REQUESTS" = "50m/32Mi"
test "$LIMITS" = "200m/64Mi"
printf 'recovery app=api-service pool=general qos=%s requests=%s limits=%s ready=true\n' \
"$QOS" "$REQUESTS" "$LIMITS"

예상 출력:

pod/api-service created
recovery app=api-service pool=general qos=Burstable requests=50m/32Mi limits=200m/64Mi ready=true

Lab 6 — 세 서비스의 배치 의도를 한 번에 리뷰

섹션 제목: “Lab 6 — 세 서비스의 배치 의도를 한 번에 리뷰”

동적 Pod 이름이나 Node 이름 대신 각 Node의 학습용 pool label을 읽어 선언과 결과가 맞는지 검증합니다.

Terminal window
set -euo pipefail
test "$(kubectl config current-context)" = "k3d-cs-study-workbook"
NS="$(cat /tmp/k8s-wb-m6/namespace)"
WEB_NODE="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod \
-l app=web-app -o jsonpath='{.items[0].spec.nodeName}')"
AI_SERVICE_NODE="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod ai-service \
-o jsonpath='{.spec.nodeName}')"
API_SERVICE_NODE="$(kubectl --context k3d-cs-study-workbook -n "$NS" get pod api-service \
-o jsonpath='{.spec.nodeName}')"
WEB_POOL="$(kubectl --context k3d-cs-study-workbook get node "$WEB_NODE" \
-o jsonpath='{.metadata.labels.workbook\.cs-study/node-pool}')"
AI_SERVICE_POOL="$(kubectl --context k3d-cs-study-workbook get node "$AI_SERVICE_NODE" \
-o jsonpath='{.metadata.labels.workbook\.cs-study/node-pool}')"
API_SERVICE_POOL="$(kubectl --context k3d-cs-study-workbook get node "$API_SERVICE_NODE" \
-o jsonpath='{.metadata.labels.workbook\.cs-study/node-pool}')"
test "$WEB_POOL" = "general"
test "$AI_SERVICE_POOL" = "compute"
test "$API_SERVICE_POOL" = "general"
printf 'placement web-app=%s ai-service=%s api-service=%s\n' \
"$WEB_POOL" "$AI_SERVICE_POOL" "$API_SERVICE_POOL"
printf 'decision selector=eligible-set toleration=taint-permission request=scheduler-fit limit=runtime-boundary\n'

예상 출력:

placement web-app=general ai-service=compute api-service=general
decision selector=eligible-set toleration=taint-permission request=scheduler-fit limit=runtime-boundary

이 매핑은 개념 확인용 가상 topology입니다. 실제 예시 서비스가 어느 NKS node pool을 써야 하는지는 workload 측정, 장애 격리 목적, zone, 비용과 autoscaling 정책을 함께 검토해야 합니다.

namespace와 Node 변경을 모두 원복합니다.

Terminal window
set -euo pipefail
test "$(kubectl config current-context)" = "k3d-cs-study-workbook"
NS="$(cat /tmp/k8s-wb-m6/namespace)"
GENERAL_NODE="$(cat /tmp/k8s-wb-m6/general-node)"
COMPUTE_NODE="$(cat /tmp/k8s-wb-m6/compute-node)"
kubectl --context k3d-cs-study-workbook delete namespace "$NS" --wait=false >/dev/null
kubectl --context k3d-cs-study-workbook wait --for=delete \
"namespace/$NS" --timeout=120s >/dev/null
kubectl --context k3d-cs-study-workbook label node "$GENERAL_NODE" \
workbook.cs-study/node-pool- >/dev/null
kubectl --context k3d-cs-study-workbook label node "$COMPUTE_NODE" \
workbook.cs-study/node-pool- >/dev/null
kubectl --context k3d-cs-study-workbook taint node "$COMPUTE_NODE" \
workbook.cs-study/dedicated:NoSchedule- >/dev/null
if kubectl --context k3d-cs-study-workbook get namespace "$NS" >/dev/null 2>&1; then
echo "cleanup failed: namespace remains" >&2
exit 1
fi
LABEL_COUNT="$(kubectl --context k3d-cs-study-workbook get nodes \
-l workbook.cs-study/node-pool -o name | awk 'END {print NR+0}')"
TAINT_COUNT="$(kubectl --context k3d-cs-study-workbook get nodes \
-o jsonpath='{range .items[*].spec.taints[*]}{.key}{"\n"}{end}' \
| awk '$0 == "workbook.cs-study/dedicated" {count++} END {print count+0}')"
test "$LABEL_COUNT" = "0"
test "$TAINT_COUNT" = "0"
rm -rf /tmp/k8s-wb-m6
test ! -e /tmp/k8s-wb-m6
READY_NODES="$(kubectl --context k3d-cs-study-workbook get nodes --no-headers \
| awk '$2 == "Ready" {count++} END {print count+0}')"
test "$READY_NODES" = "2"
k3d cluster list | awk '$1 == "cs-study-workbook" && $2 == "1/1" && $3 == "1/1" \
{found=1} END {exit found ? 0 : 1}'
printf 'cleanup namespace=0 workloads=0 labels=0 taints=0 fixture=0 nodes_ready=%s cluster_retained=true\n' \
"$READY_NODES"

예상 출력:

cleanup namespace=0 workloads=0 labels=0 taints=0 fixture=0 nodes_ready=2 cluster_retained=true

namespace 삭제 시간은 변동 필드입니다. 학습용 workload뿐 아니라 cluster-scoped Node label과 taint까지 0인지 확인해야 cleanup이 끝납니다.

명령 뒤보게 되는 것운영 판단
Setup의 label·taint 적용Node 2개가 general/compute로 구분되고 compute만 taintednode pool 성격과 출입 제한은 서로 다른 설정입니다.
Lab 1의 Deployment ReadynodeSelector=general, QoS Burstableworkload label과 Node label을 혼동하지 않습니다.
Lab 2의 Ready wait exit 1Unschedulable, untolerated taintimage나 app보다 scheduler event를 먼저 봅니다.
Lab 3의 재생성selector와 toleration을 모두 만족해 compute에 배치toleration만으로 routing하지 않습니다.
Lab 4의 Ready wait exit 1Insufficient cpu실제 사용률이 아니라 request 합과 allocatable을 봅니다.
Lab 5의 right-sized 재생성같은 general 후보에서 Ready로 복구운영값은 fixture 복사가 아니라 측정으로 정합니다.
Cleanupnamespace·label·taint·fixture 모두 0cluster-scoped 변경도 반드시 원복합니다.

장애를 볼 때는 다음 순서로 층을 좁힙니다.

  1. PodScheduled=Falsereason=Unschedulable인지 봅니다.
  2. message에서 selector/affinity 불일치, untolerated taint, Insufficient cpu/memory를 구분합니다.
  3. 후보 Node의 label·taint·allocatable과 이미 배치된 request 합을 확인합니다.
  4. Pod template을 고치고 새 Pod가 bind·Ready가 되는지 검증합니다.
  5. NKS node pool 증설은 Pod spec 결함을 배제한 뒤 M8의 autoscaling 계층에서 판단합니다.

Q1. AI가 “ai-service에 compute taint toleration만 넣으면 compute pool 전용 배치가 된다”고 제안했습니다. 승인하시겠습니까?

섹션 제목: “Q1. AI가 “ai-service에 compute taint toleration만 넣으면 compute pool 전용 배치가 된다”고 제안했습니다. 승인하시겠습니까?”

답: 그대로는 승인하지 않습니다. toleration은 tainted Node를 후보로 허용할 뿐 그 Node로 끌어오지 않습니다. compute pool 전용이어야 한다면 검증된 pool label에 대한 nodeSelector나 required node affinity도 필요합니다. 반대로 전용이 아니라 선호라면 preferred affinity와 fallback 비용을 검토해야 합니다.

Q2. Node CPU 사용률이 낮은데 새 api-service Pod가 Insufficient cpu로 Pending입니다. 무엇을 먼저 비교해야 합니까?

섹션 제목: “Q2. Node CPU 사용률이 낮은데 새 api-service Pod가 Insufficient cpu로 Pending입니다. 무엇을 먼저 비교해야 합니까?”

답: 현재 CPU 사용률이 아니라 새 Pod의 CPU request, 후보 Node의 allocatable, 이미 배치된 Pod request 합을 비교합니다. selector·taint 때문에 후보가 한 Node로 좁아졌는지도 함께 봅니다. Pod request가 잘못 과대 설정됐다면 template을 수정하고, 정당한 수요인데 모든 후보가 차면 그때 node pool 용량이나 M8의 Cluster Autoscaler를 검토합니다.

Q3. AI가 세 서비스의 CPU request와 limit를 모두 같은 값으로 복사해 넣었습니다. 어떤 근거를 요구해야 합니까?

섹션 제목: “Q3. AI가 세 서비스의 CPU request와 limit를 모두 같은 값으로 복사해 넣었습니다. 어떤 근거를 요구해야 합니까?”

답: 서비스별 평상·peak 사용량, latency와 queue 지표, CPU throttling, memory working set과 OOM 이력, replica 전략을 요구합니다. request는 배치와 autoscaling 기준에 영향을 주고 limit는 runtime 상한이므로 역할이 다릅니다. 이 랩의 작은 숫자는 재현 fixture일 뿐 NKS 운영값의 근거가 아닙니다.

함정 1 — toleration을 특정 pool routing으로 이해

섹션 제목: “함정 1 — toleration을 특정 pool routing으로 이해”

toleration은 허용만 하므로 다른 untainted Node에도 배치될 수 있습니다. selector/affinity와 함께 의도를 표현합니다.

함정 2 — 실제 사용률만 보고 Pending을 해석

섹션 제목: “함정 2 — 실제 사용률만 보고 Pending을 해석”

scheduler는 기본적으로 request 합과 allocatable을 봅니다. PodScheduled event와 후보 Node의 request 회계를 확인합니다.

함정 3 — NKS의 개별 Node에 수동 label만 추가

섹션 제목: “함정 3 — NKS의 개별 Node에 수동 label만 추가”

node pool 교체·upgrade 뒤 Node와 설정이 사라질 수 있습니다. provider의 node pool label·taint 설정을 정본으로 관리합니다.

control plane 내부 scheduler plugin 튜닝, CNI topology 구현, Terraform으로 NKS node pool 작성, production NKS 변경은 범위 밖입니다. Pod priority/preemption, topology spread와 admission 기반 default resource 정책은 이 워크북의 심화 항목으로만 남깁니다.

배치 가능한 Pod template을 만들었으므로 M7에서는 같은 manifest를 Helm chart와 values로 패키징하고 release 단위 upgrade·rollback을 실측합니다.