Step 13 — 스케줄링 제어

학습 목표: 파드가 "어느 노드에" 배치될지 제어하는 모든 수단(nodeSelector·nodeName·nodeAffinity·podAffinity/AntiAffinity·taint/toleration·topologySpreadConstraints)을 실습하고, 스케줄이 안 될 때 Pending 원인을 describe로 진단한다. / 선행 스텝: Step 12 / 예상 소요: 70분

Kubernetes 스케줄러는 파드를 노드에 배치할 때 두 단계를 거친다. 필터링(조건에 안 맞는 노드 제거) 후 스코어링(남은 노드 중 최적 선택)이다. 이 스텝의 도구들은 이 두 단계에 개입한다.

도구무엇을 하나강제성
nodeName스케줄러를 건너뛰고 노드 직접 지정절대(검증 없음)
nodeSelector라벨이 맞는 노드에만하드(필터)
nodeAffinity (required)표현식이 맞는 노드에만하드(필터)
nodeAffinity (preferred)가능하면 선호소프트(스코어)
podAffinity/AntiAffinity다른 파드 기준 같이/따로하드 or 소프트
taint + toleration노드가 파드를 밀어냄, 견디면 허용하드
topologySpreadConstraints도메인(노드/존)에 고르게 분산하드 or 소프트

⚠️ 모든 실습은 step13 네임스페이스에서 진행한다. 이 스텝에서만 노드에 테인트를 걸며, 워커 노드에만, 고유 키(step13-demo)로, NoSchedule 걸고 실습 직후 즉시 제거한다. control-plane 노드는 절대 건드리지 않는다.

먼저 클러스터 노드와 내장 라벨을 확인한다. kind는 노드마다 kubernetes.io/hostname 라벨을 기본으로 붙여 주므로, 대부분의 실습은 노드를 변경하지 않고 이 내장 라벨만으로 가능하다.

kubectl apply -f manifests/00-namespace.yaml
kubectl get nodes -L kubernetes.io/hostname

실행 결과

NAME                  STATUS   ROLES           AGE   VERSION   HOSTNAME
learn-control-plane   Ready    control-plane   14m   v1.36.1   learn-control-plane
learn-worker          Ready    <none>          14m   v1.36.1   learn-worker
learn-worker2         Ready    <none>          14m   v1.36.1   learn-worker2

13-1. nodeSelector — 라벨로 노드 고르기

가장 단순한 제어. 파드의 nodeSelector에 적은 라벨을 모두 가진 노드에만 배치된다. 하나라도 맞는 노드가 없으면 파드는 Pending에 빠진다.

# manifests/01-nodeselector.yaml
apiVersion: v1
kind: Pod
metadata:
  name: pin-worker
  namespace: step13
spec:
  nodeSelector:
    kubernetes.io/hostname: learn-worker   # learn-worker 에만 배치
  containers:
    - name: app
      image: nginx:1.27-alpine
      resources:
        requests: { cpu: 10m, memory: 16Mi }
kubectl apply -f manifests/01-nodeselector.yaml

nodeSelector는 뒤에서 볼 nodeName, nodeAffinity와 함께 확인한다(13-3 참고).

💡 실무 팁: nodeSelector는 "AND"만 된다. "SSD 또는 NVMe" 같은 OR, "메모리 큰 노드 선호" 같은 소프트 조건은 표현 못 한다. 조금이라도 복잡하면 nodeAffinity를 쓴다.


13-2. nodeName — 스케줄러 건너뛰기

nodeName을 직접 적으면 스케줄러가 개입하지 않는다. kubelet이 해당 노드에서 곧바로 파드를 띄운다. 리소스 여유·테인트를 검사하지 않기 때문에, 노드가 꽉 찼거나 테인트가 걸려 있어도 밀어붙이다 실패할 수 있다.

# manifests/02-nodename.yaml
apiVersion: v1
kind: Pod
metadata:
  name: direct-assign
  namespace: step13
spec:
  nodeName: learn-worker2      # 스케줄러 우회, 곧바로 이 노드
  containers:
    - name: app
      image: nginx:1.27-alpine
      resources:
        requests: { cpu: 10m, memory: 16Mi }
kubectl apply -f manifests/02-nodename.yaml

⚠️ 함정: nodeName은 디버깅·특수한 경우에만. 스케줄러의 리소스/테인트/어피니티 판단을 전부 무시하므로 프로덕션 워크로드에 쓰면 노드 과부하나 예기치 않은 실패로 이어진다.


13-3. nodeAffinity — required / preferred

nodeSelector의 상위 호환. 표현식(In, NotIn, Exists, Gt, Lt …)을 쓸 수 있고 하드(required)/소프트(preferred)를 나눌 수 있다.

# manifests/03-nodeaffinity.yaml
apiVersion: v1
kind: Pod
metadata:
  name: affinity-required
  namespace: step13
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:      # 하드: 워커에만
        nodeSelectorTerms:
          - matchExpressions:
              - key: kubernetes.io/hostname
                operator: In
                values: ["learn-worker", "learn-worker2"]
      preferredDuringSchedulingIgnoredDuringExecution:     # 소프트: 가능하면 worker2
        - weight: 100
          preference:
            matchExpressions:
              - key: kubernetes.io/hostname
                operator: In
                values: ["learn-worker2"]
kubectl apply -f manifests/03-nodeaffinity.yaml
kubectl wait --for=condition=Ready pod/pin-worker pod/direct-assign pod/affinity-required -n step13 --timeout=90s
kubectl get pod -n step13 -o wide

실행 결과

NAME                READY   STATUS    RESTARTS   AGE   IP            NODE            NOMINATED NODE   READINESS GATES
affinity-required   1/1     Running   0          1s    10.244.2.33   learn-worker2   <none>           <none>
direct-assign       1/1     Running   0          1s    10.244.2.32   learn-worker2   <none>           <none>
pin-worker          1/1     Running   0          1s    10.244.1.27   learn-worker    <none>           <none>
  • pin-workerlearn-worker (13-1 nodeSelector)
  • direct-assignlearn-worker2 (13-2 nodeName)
  • affinity-requiredlearn-worker2 (required로 워커만, preferred 가중치로 worker2 선택)

💡 실무 팁: 이름 끝의 IgnoredDuringExecution은 "이미 실행 중인 파드는 조건이 깨져도 쫓아내지 않는다"는 뜻이다. RequiredDuringExecution(라벨이 바뀌면 축출)은 아직 구현되지 않았다.


13-4. podAffinity — 다른 파드 곁에 붙이기

노드가 아니라 다른 파드를 기준으로 배치한다. 캐시를 앱과 같은 노드에 두어 지연을 줄이는 식이다. topologyKey가 "같음"의 기준이다(kubernetes.io/hostname이면 "같은 노드").

# manifests/04-podaffinity.yaml (발췌)
apiVersion: v1
kind: Pod
metadata: { name: cache, namespace: step13, labels: { app: cache } }
spec:
  affinity:
    podAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchLabels: { app: web }        # web 파드가 있는
          topologyKey: kubernetes.io/hostname  # 같은 노드에
  containers:
    - name: cache
      image: redis:7-alpine
      resources: { requests: { cpu: 10m, memory: 16Mi } }
kubectl apply -f manifests/04-podaffinity.yaml
kubectl wait --for=condition=Ready pod -l app=web -n step13 --timeout=90s
kubectl wait --for=condition=Ready pod/cache -n step13 --timeout=90s
kubectl get pod -n step13 -l 'app in (web,cache)' -o wide

실행 결과

NAME                  READY   STATUS    RESTARTS   AGE   IP            NODE           NOMINATED NODE   READINESS GATES
cache                 1/1     Running   0          94s   10.244.1.34   learn-worker   <none>           <none>
web-799f4cc4d-6mj92   1/1     Running   0          94s   10.244.1.33   learn-worker   <none>           <none>

weblearn-worker에 떴으므로 cache도 같은 learn-worker에 따라붙었다.

⚠️ 함정: podAffinity를 required로 걸었는데 기준 파드가 아직 없으면 새 파드는 Pending이다. "닭이 먼저냐" 문제를 피하려면 기준 파드를 먼저 띄우거나 soft(preferred)로 완화한다.


13-5. podAntiAffinity — 서로 떼어 놓기

같은 앱의 복제본을 다른 노드에 흩어 놓아 노드 하나가 죽어도 전멸하지 않게 한다. 고가용성의 기본기다.

# manifests/05-podantiaffinity.yaml (발췌)
spec:
  affinity:
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchLabels: { app: spread-app }
          topologyKey: kubernetes.io/hostname   # 같은 노드에 두 개 금지
kubectl apply -f manifests/05-podantiaffinity.yaml
kubectl wait --for=condition=Ready pod -l app=spread-app -n step13 --timeout=90s
kubectl get pod -n step13 -l app=spread-app -o wide

실행 결과

NAME                          READY   STATUS    RESTARTS   AGE   IP            NODE            NOMINATED NODE   READINESS GATES
spread-app-5794f9dfdf-v5qtn   1/1     Running   0          94s   10.244.2.34   learn-worker2   <none>           <none>
spread-app-5794f9dfdf-xs6g7   1/1     Running   0          94s   10.244.1.35   learn-worker    <none>           <none>

복제본 2개가 서로 다른 워커에 하나씩 배치됐다.

⚠️ 함정: required podAntiAffinity + replicas가 워커 수보다 많으면 초과분은 영원히 Pending이다(노드 하나에 둘을 못 두니까). 이럴 땐 soft로 바꾸거나 topologySpread(13-7)를 쓴다.


13-6. taint / toleration — 노드가 파드를 밀어내기

어피니티가 "파드가 노드를 고르는" 것이라면, 테인트는 "노드가 파드를 거부하는" 반대 방향이다. 노드에 테인트를 걸면, 그 테인트를 견디는(toleration) 파드만 그 노드에 들어올 수 있다. effectNoSchedule(새 파드 거부), PreferNoSchedule(가능하면 피함), NoExecute(기존 파드도 축출) 세 가지다.

control-plane 노드가 일반 파드로 붐비지 않는 이유가 바로 이것이다. control-plane에는 node-role.kubernetes.io/control-plane:NoSchedule 테인트가 기본으로 걸려 있다(Step 12에서 확인).

이번엔 워커에 고유 키로 테인트를 걸어 실험한다. 아래 스크립트는 테인트를 걸고 → 파드 배치를 확인하고 → 즉시 테인트를 제거한다.

# manifests/06-toleration.yaml (발췌)
# no-tol  : 톨러레이션 없음 → 테인트 걸린 learn-worker 를 피함
# with-tol: 톨러레이션 있음 + nodeSelector 로 learn-worker 강제
spec:
  nodeSelector: { kubernetes.io/hostname: learn-worker }
  tolerations:
    - key: step13-demo
      operator: Equal
      value: "true"
      effect: NoSchedule
# 1) 워커에만, 고유 키로, NoSchedule 로 테인트
kubectl taint nodes learn-worker step13-demo=true:NoSchedule
kubectl describe node learn-worker | grep -i taint

# 2) 두 파드 배치
kubectl apply -f manifests/06-toleration.yaml
kubectl wait --for=condition=Ready pod/no-tol pod/with-tol -n step13 --timeout=90s
kubectl get pod no-tol with-tol -n step13 -o wide

# 3) 실습 직후 즉시 원복 (★ 반드시)
kubectl taint nodes learn-worker step13-demo=true:NoSchedule-
kubectl describe node learn-worker | grep -i taint

실행 결과

node/learn-worker tainted
Taints:             step13-demo=true:NoSchedule
pod/no-tol created
pod/with-tol created
pod/no-tol condition met
pod/with-tol condition met
NAME       READY   STATUS    RESTARTS   AGE   IP            NODE            NOMINATED NODE   READINESS GATES
no-tol     1/1     Running   0          1s    10.244.2.71   learn-worker2   <none>           <none>
with-tol   1/1     Running   0          1s    10.244.1.69   learn-worker    <none>           <none>
node/learn-worker untainted
Taints:             <none>
  • no-tol: 톨러레이션이 없어 테인트를 못 견딤 → learn-worker를 피해 learn-worker2로.
  • with-tol: 테인트를 견딤 → nodeSelector로 강제한 learn-worker에 정상 배치.
  • 마지막 Taints: <none> → 테인트가 깨끗이 제거됐다.

⚠️ 함정 (원복 필수): 테인트 제거는 키 뒤에 붙이는 하이픈이다: kubectl taint nodes learn-worker step13-demo=true:NoSchedule-. 원복을 잊으면 이 노드는 계속 파드를 거부해 다른 스텝 실습이 이유 없이 Pending에 빠진다. 학습 클러스터는 여러 사람이 공유하므로 drain은 쓰지 말 것(다른 파드까지 쫓겨난다).

💡 실무 팁: 전용 노드(GPU 노드 등)를 만들 때 자주 쓴다. 노드에 gpu=true:NoSchedule 테인트 + 라벨을 걸고, GPU 워크로드에만 toleration과 nodeSelector를 줘서 "GPU 노드는 GPU 파드만"을 강제한다.


13-7. topologySpreadConstraints — 도메인에 고르게

antiAffinity가 "같은 노드 금지"라는 이분법이라면, topologySpread는 "도메인 간 편차(skew)를 N 이하로"라는 정량적 분산이다. 존/노드 단위로 고르게 퍼뜨릴 때 쓴다.

# manifests/07-topologyspread.yaml (발췌)
spec:
  topologySpreadConstraints:
    - maxSkew: 1
      topologyKey: kubernetes.io/hostname
      whenUnsatisfiable: DoNotSchedule
      nodeTaintsPolicy: Honor          # 견딜 수 없는 테인트 걸린 노드는 도메인에서 제외
      labelSelector:
        matchLabels: { app: topo }
kubectl apply -f manifests/07-topologyspread.yaml
kubectl wait --for=condition=Ready pod -l app=topo -n step13 --timeout=90s
kubectl get pod -n step13 -l app=topo -o wide

실행 결과

NAME                    READY   STATUS    RESTARTS   AGE   IP            NODE            NOMINATED NODE   READINESS GATES
topo-6db5c85664-bfxmv   1/1     Running   0          4s    10.244.1.61   learn-worker    <none>           <none>
topo-6db5c85664-kl5kl   1/1     Running   0          4s    10.244.2.62   learn-worker2   <none>           <none>
topo-6db5c85664-scdkn   1/1     Running   0          4s    10.244.2.61   learn-worker2   <none>           <none>
topo-6db5c85664-smxxg   1/1     Running   0          4s    10.244.1.62   learn-worker    <none>           <none>

복제본 4개가 두 워커에 2/2로 균등 분배됐다.

⚠️ 함정 (실제로 겪은 것): nodeTaintsPolicy: Honor를 빼면 이 실습은 2개가 Pending에 빠진다. 기본값(Ignore)에서는 테인트가 걸려 파드가 갈 수 없는 control-plane도 "빈 도메인(0개)"으로 세기 때문이다. maxSkew:1이라 워커(2개)와 빈 control-plane(0개)의 편차가 1을 넘지 못하게 막아 버린다. Honor로 톨러레이트 못 하는 테인트 노드를 도메인 계산에서 빼면 워커 2개만 대상이 되어 2/2로 채워진다.

💡 실무 팁: whenUnsatisfiable: DoNotSchedule(하드)은 못 맞추면 Pending, ScheduleAnyway(소프트)는 "최대한 맞추되 안 되면 그냥 배치"다. 확실한 분산이 필요하면 하드, 절대 Pending은 싫으면 소프트.


13-8. 스케줄이 안 될 때 — Pending 진단

제약을 만족하는 노드가 없으면 파드는 Pending에 머문다. 왜인지는 kubectl describe의 Events에 정확히 적혀 있다.

# manifests/08-pending.yaml (발췌)
spec:
  nodeSelector:
    disktype: nvme-super-fast   # 이런 라벨을 가진 노드는 없다
kubectl apply -f manifests/08-pending.yaml
kubectl get pod unschedulable -n step13 -o wide
kubectl describe pod unschedulable -n step13 | sed -n '/Events:/,$p'

실행 결과

NAME            READY   STATUS    RESTARTS   AGE   IP       NODE     NOMINATED NODE   READINESS GATES
unschedulable   0/1     Pending   0          5s    <none>   <none>   <none>           <none>

Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  6s    default-scheduler  0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 node(s) didn't match Pod's node affinity/selector. ...preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.

0/3 nodes are available와 그 뒤 사유("didn't match Pod's node affinity/selector", "had untolerated taint(s)")가 핵심이다. 노드별 탈락 이유가 합산되어 나온다.

💡 실무 팁: Pending을 만나면 반사적으로 kubectl describe pod <name>의 Events부터 본다. 사유는 대부분 셋 중 하나다 — (1) 리소스 부족(Insufficient cpu/memory, Step 09), (2) 셀렉터/어피니티 불일치, (3) 견딜 수 없는 테인트.


정리 (표)

도구기준하드/소프트대표 용도
nodeName노드 이름 직접절대(우회)디버깅
nodeSelector노드 라벨(AND)하드단순 고정
nodeAffinity required노드 라벨 표현식하드특정 노드군 제한
nodeAffinity preferred노드 라벨 표현식소프트선호(안 되면 허용)
podAffinity다른 파드 위치하드/소프트같이 배치(지연↓)
podAntiAffinity다른 파드 위치하드/소프트흩어 배치(HA)
taint/toleration노드가 거부하드전용 노드
topologySpread도메인 편차하드/소프트존/노드 균등 분산

진단 명령: kubectl get pod -o wide(배치 확인), kubectl describe pod(Pending 사유), kubectl describe node <n> | grep -i taint(테인트 확인).

정리 확인 — 원복

# 네임스페이스 삭제(이 스텝의 모든 파드/디플로이먼트 제거)
kubectl delete namespace step13

# 노드에 step13 테인트 흔적이 없어야 한다(control-plane 기본 테인트만 남아야 정상)
kubectl describe nodes | grep -i taint

기대 출력: 두 워커는 Taints: <none>, control-plane만 node-role.kubernetes.io/control-plane:NoSchedule.

연습 과제 → challenge.md

다음 단계

Step 14 — 인그레스


실습 파일

이 스텝의 매니페스트는 manifests/ 아래에 실행 순서대로 번호가 붙어 있습니다. 00-namespace.yamlstep13 네임스페이스를 먼저 만든 뒤, 0108 순서대로 적용하며 각 스케줄링 도구가 파드를 어느 노드로 보내는지 kubectl get pod -o wide 로 관찰합니다. 마지막의 commands.sh 는 이 전 과정(테인트 부여와 원복, 정리까지)을 한 번에 재생하는 스크립트입니다. 06-toleration.yaml 만은 앞뒤로 kubectl taint 명령이 필요하다는 점에 주의합니다.

manifests/00-namespace.yaml

이 스텝의 모든 리소스가 들어갈 step13 네임스페이스를 만듭니다. 가장 먼저 적용해야 하며, 이후 매니페스트들은 모두 metadata.namespace: step13 을 하드코딩하고 있으므로 이 네임스페이스가 없으면 apply 자체가 실패합니다. 실습이 끝나면 kubectl delete namespace step13 한 줄로 여기 담긴 파드·디플로이먼트가 통째로 정리됩니다.

apiVersion: v1
kind: Namespace
metadata:
  name: step13

manifests/01-nodeselector.yaml

13-1 에서 적용하는, 가장 단순한 배치 제어 예제입니다.

  • nodeSelector: { kubernetes.io/hostname: learn-worker }learn-worker 노드에만 배치되도록 못 박습니다. kind가 모든 노드에 기본으로 붙여 주는 내장 라벨을 쓰기 때문에 kubectl label node 로 노드를 손볼 필요가 없습니다.
  • nodeSelector 는 적힌 라벨을 전부(AND) 만족하는 노드만 남기는 하드 필터라서, learn-worker 가 없거나 꽉 차 있으면 파드는 그대로 Pending 입니다.
  • requests: { cpu: 10m, memory: 16Mi } 로 요청량을 아주 작게 잡아 두 워커 어디에도 리소스 때문에 못 뜨는 일이 없게 했습니다. 즉 이 실습에서 배치 결과를 좌우하는 변수는 오직 스케줄링 제약뿐입니다.
  • 결과 확인은 13-3 에서 pin-worker / direct-assign / affinity-required 세 파드를 한꺼번에 -o wide 로 봅니다.
apiVersion: v1
kind: Pod
metadata:
  name: pin-worker
  namespace: step13
  labels:
    app: pin-worker
spec:
  # 스케줄러가 이 라벨을 가진 노드에만 배치한다. kind가 노드에 기본 부여하는
  # 내장 라벨(kubernetes.io/hostname)을 사용하므로 노드를 변경할 필요가 없다.
  nodeSelector:
    kubernetes.io/hostname: learn-worker
  containers:
    - name: app
      image: nginx:1.27-alpine
      resources:
        requests:
          cpu: 10m
          memory: 16Mi

manifests/02-nodename.yaml

13-2 의 "스케줄러 건너뛰기" 예제입니다.

  • nodeName: learn-worker2 를 적으면 스케줄러가 아예 개입하지 않고, 해당 노드의 kubelet 이 파드를 곧바로 실행합니다.
  • 이 경로에는 리소스 여유 검사도, 테인트 검사도, 어피니티 판단도 없습니다. 그래서 노드가 꽉 찼거나 테인트가 걸려 있어도 밀어붙이다 실패할 수 있습니다.
  • 노드 이름을 오타로 적으면 그 파드는 아무 kubelet 도 집어가지 않아 Pending 상태로 방치됩니다(스케줄러가 사유를 남겨 주지도 않습니다).
  • 실무에서는 디버깅 같은 특수한 경우가 아니면 쓰지 않는, "이렇게도 된다"를 보여 주기 위한 파일입니다.
apiVersion: v1
kind: Pod
metadata:
  name: direct-assign
  namespace: step13
spec:
  # nodeName을 직접 지정하면 스케줄러를 건너뛰고 kubelet이 곧바로 실행한다.
  # 리소스 검사/테인트 검사가 생략되므로 실무에서는 거의 쓰지 않는다.
  nodeName: learn-worker2
  containers:
    - name: app
      image: nginx:1.27-alpine
      resources:
        requests:
          cpu: 10m
          memory: 16Mi

manifests/03-nodeaffinity.yaml

13-3 에서 하드/소프트 조건을 한 파드에 함께 걸어 봅니다.

  • requiredDuringSchedulingIgnoredDuringExecutionmatchExpressionskubernetes.io/hostname In ["learn-worker", "learn-worker2"] 를 요구합니다. control-plane 을 제외한 워커 두 대로 후보를 좁히는 하드 필터입니다.
  • preferredDuringSchedulingIgnoredDuringExecutionweight: 100 으로 learn-worker2 를 선호하게 두었습니다. 소프트 조건이라 worker2 가 안 되면 worker 로도 갑니다. 실행 결과에서 affinity-requiredlearn-worker2 에 뜨는 이유가 이 가중치입니다.
  • nodeSelector 와 달리 In/NotIn/Exists/Gt/Lt 같은 연산자를 쓸 수 있고 OR·선호를 표현할 수 있다는 점이 핵심 차이입니다.
  • 이름의 IgnoredDuringExecution 은 "이미 실행 중인 파드는 조건이 깨져도 축출하지 않는다"는 뜻입니다.
apiVersion: v1
kind: Pod
metadata:
  name: affinity-required
  namespace: step13
spec:
  affinity:
    nodeAffinity:
      # required: 조건을 만족하는 노드가 없으면 Pending. 워커 노드에만 배치.
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
          - matchExpressions:
              - key: kubernetes.io/hostname
                operator: In
                values: ["learn-worker", "learn-worker2"]
      # preferred: 가능하면 learn-worker2를 선호(가중치 100). 안 되면 다른 노드도 허용.
      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 100
          preference:
            matchExpressions:
              - key: kubernetes.io/hostname
                operator: In
                values: ["learn-worker2"]
  containers:
    - name: app
      image: nginx:1.27-alpine
      resources:
        requests:
          cpu: 10m
          memory: 16Mi

manifests/04-podaffinity.yaml

13-4 용 파일로, 하나의 파일에 두 리소스--- 로 이어져 있습니다.

  • 앞부분은 app: web 라벨을 단 replicas: 1 짜리 Deployment 이고, 뒷부분이 podAffinity 를 가진 cache 파드입니다.
  • cachelabelSelector: { matchLabels: { app: web } } + topologyKey: kubernetes.io/hostname 으로 "app=web 파드가 있는 노드와 같은 노드"를 요구합니다. topologyKey 가 hostname 이므로 "같음"의 단위가 노드입니다.
  • 한 파일에 담겨 있어 kubectl apply 는 Deployment → cache 순으로 생성하지만, 그것이 스케줄 순서까지 보장하지는 않습니다. cache 가 먼저 스케줄 대상이 되면 기준이 될 app=web 파드가 아직 없어 잠시 Pending 에 빠졌다가, 스케줄러의 재시도로 web 이 배치된 뒤에야 같은 노드로 따라붙습니다. 그래서 강의 본문의 명령도 kubectl wait ... -l app=web 으로 web 이 Ready 되기를 먼저 기다린 뒤 cache 를 확인합니다.
  • 이 "닭이 먼저냐" 문제는 challenge.md 과제 5에서 일부러 재현해 봅니다.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: step13
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: web
          image: nginx:1.27-alpine
          resources:
            requests:
              cpu: 10m
              memory: 16Mi
---
apiVersion: v1
kind: Pod
metadata:
  name: cache
  namespace: step13
  labels:
    app: cache
spec:
  affinity:
    podAffinity:
      # web 파드가 있는 "같은 노드"에 배치한다(topologyKey = hostname).
      requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchLabels:
              app: web
          topologyKey: kubernetes.io/hostname
  containers:
    - name: cache
      image: redis:7-alpine
      resources:
        requests:
          cpu: 10m
          memory: 16Mi

manifests/05-podantiaffinity.yaml

13-5 의 고가용성 배치 예제입니다.

  • replicas: 2 Deployment 에 podAntiAffinityrequired 로 걸어, 같은 app: spread-app 라벨을 가진 파드끼리는 topologyKey: kubernetes.io/hostname 기준으로 한 노드에 둘이 못 올라가게 합니다.
  • 워커가 정확히 2대이므로 복제본 2개가 learn-worker / learn-worker2 에 하나씩 나뉘어 뜹니다.
  • 여기서 replicas 를 3 이상으로 올리면 세 번째부터는 갈 노드가 없어 영원히 Pending 이 됩니다(노드 하나에 둘을 못 두므로). 이것이 하드 anti-affinity 의 대표적 함정이고, challenge.md 과제 2에서 soft(preferred)로 완화하는 해법을 다룹니다.
  • 04번의 podAffinity 와 셀렉터·topologyKey 구조가 똑같고 podAffinitypodAntiAffinity 키만 뒤집힌 형태라, 둘을 나란히 비교해 보면 이해가 빠릅니다.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: spread-app
  namespace: step13
spec:
  replicas: 2
  selector:
    matchLabels:
      app: spread-app
  template:
    metadata:
      labels:
        app: spread-app
    spec:
      affinity:
        podAntiAffinity:
          # 같은 app=spread-app 파드끼리는 같은 노드에 두지 않는다.
          # 워커가 2개이므로 replicas=2가 서로 다른 워커에 하나씩 뜬다.
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchLabels:
                  app: spread-app
              topologyKey: kubernetes.io/hostname
      containers:
        - name: app
          image: nginx:1.27-alpine
          resources:
            requests:
              cpu: 10m
              memory: 16Mi

manifests/06-toleration.yaml

13-6 용 파일이며, 적용 전에 반드시 테인트를 걸어야 의미가 있습니다: kubectl taint nodes learn-worker step13-demo=true:NoSchedule.

  • 두 파드를 대조합니다. no-tol 은 톨러레이션도 nodeSelector 도 없는 평범한 파드라, 테인트가 걸린 learn-worker 를 스케줄러가 걸러내고 learn-worker2 로 보냅니다.
  • with-toltolerationskey: step13-demo / operator: Equal / value: "true" / effect: NoSchedule 을 적어 테인트를 견디고, 동시에 nodeSelector: { kubernetes.io/hostname: learn-worker } 로 그 노드를 강제합니다. toleration 만으로는 그 노드로 "간다"는 보장이 없고 "갈 수 있다"만 뜻하기 때문에 nodeSelector 를 함께 쓴 것입니다(허용이지 유인이 아님).
  • 테인트의 키·값·effect 가 톨러레이션과 정확히 일치해야 견딜 수 있습니다. 하나라도 어긋나면 with-tol 은 nodeSelector 때문에 갈 곳이 없어 Pending 이 됩니다.
  • ⚠️ 원복 필수: 실습 직후 kubectl taint nodes learn-worker step13-demo=true:NoSchedule- (끝의 하이픈)로 반드시 테인트를 제거합니다. 잊으면 이후 다른 스텝의 파드들이 이유 없이 이 노드를 피하거나 Pending 에 빠집니다.
# learn-worker 에 step13-demo=true:NoSchedule 테인트를 건 상태에서 아래 두 파드를 비교한다.
# no-tol : 톨러레이션이 없어 테인트를 못 견딤 → learn-worker 를 피해 learn-worker2 로 간다.
# with-tol : 테인트를 견딤 → learn-worker 에도 배치될 수 있다(nodeSelector 로 강제).
apiVersion: v1
kind: Pod
metadata:
  name: no-tol
  namespace: step13
spec:
  containers:
    - name: app
      image: nginx:1.27-alpine
      resources:
        requests:
          cpu: 10m
          memory: 16Mi
---
apiVersion: v1
kind: Pod
metadata:
  name: with-tol
  namespace: step13
spec:
  # 테인트가 걸린 learn-worker 로 강제 배치해 톨러레이션이 실제로 작동함을 보인다.
  nodeSelector:
    kubernetes.io/hostname: learn-worker
  tolerations:
    - key: step13-demo
      operator: Equal
      value: "true"
      effect: NoSchedule
  containers:
    - name: app
      image: nginx:1.27-alpine
      resources:
        requests:
          cpu: 10m
          memory: 16Mi

manifests/07-topologyspread.yaml

13-7 의 정량적 분산 예제입니다.

  • replicas: 4maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: DoNotSchedule 로 배치해, 노드별 파드 개수 차이가 1을 넘지 않게 합니다. 결과는 두 워커에 2/2 균등 분배입니다.
  • 학습 포인트는 nodeTaintsPolicy: Honor 입니다. 이 줄을 빼면(기본값 Ignore) 파드 2개가 Pending 에 빠집니다. 톨러레이션이 없어 갈 수도 없는 control-plane 노드까지 "0개짜리 빈 도메인"으로 세어 버려서, 워커에 2개가 차는 순간 2 - 0 = 2 > maxSkew(1) 이 되어 나머지를 막기 때문입니다.
  • Honor 를 주면 견딜 수 없는 테인트가 걸린 노드는 도메인 계산에서 제외되어, 워커 2대만 대상이 되고 4개가 2/2 로 깔끔히 들어갑니다.
  • whenUnsatisfiableScheduleAnyway 로 바꾸면 소프트가 되어 못 맞춰도 Pending 없이 배치됩니다(challenge.md 과제 4).
apiVersion: apps/v1
kind: Deployment
metadata:
  name: topo
  namespace: step13
spec:
  replicas: 4
  selector:
    matchLabels:
      app: topo
  template:
    metadata:
      labels:
        app: topo
    spec:
      topologySpreadConstraints:
        # 노드(hostname)별 파드 개수 차이를 최대 1로 유지한다.
        # nodeTaintsPolicy: Honor → 톨러레이트하지 않는 테인트가 걸린 노드
        # (control-plane)는 분배 대상 도메인에서 제외한다. 이걸 빼면 비어 있는
        # control-plane 도메인 때문에 워커를 2개까지만 채우고 나머지는 Pending 이 된다.
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          nodeTaintsPolicy: Honor
          labelSelector:
            matchLabels:
              app: topo
      containers:
        - name: app
          image: nginx:1.27-alpine
          resources:
            requests:
              cpu: 10m
              memory: 16Mi

manifests/08-pending.yaml

13-8 의 진단 실습을 위해 일부러 스케줄이 안 되게 만든 파드입니다.

  • nodeSelector: { disktype: nvme-super-fast } 는 클러스터의 어떤 노드도 가지고 있지 않은 라벨입니다. 따라서 필터링 단계에서 모든 노드가 탈락하고, 파드는 Pending 에 영원히 머뭅니다(이미지 문제도, 리소스 문제도 아닙니다).
  • 적용 후 kubectl describe pod unschedulable -n step13 의 Events 에서 0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 node(s) didn't match Pod's node affinity/selector 를 읽는 것이 이 파일의 목적입니다. 워커 2대는 라벨 불일치로, control-plane 1대는 기본 테인트로 탈락했음을 사유별로 합산해 보여 줍니다.
  • 삭제하지 않으면 계속 Pending 으로 남지만, 정리 단계의 kubectl delete namespace step13 으로 함께 사라지므로 별도 조치는 필요 없습니다.
apiVersion: v1
kind: Pod
metadata:
  name: unschedulable
  namespace: step13
spec:
  # 존재하지 않는 라벨을 요구한다 → 어떤 노드도 매칭되지 않아 영원히 Pending.
  nodeSelector:
    disktype: nvme-super-fast
  containers:
    - name: app
      image: nginx:1.27-alpine
      resources:
        requests:
          cpu: 10m
          memory: 16Mi

commands.sh

13-1 부터 13-8 까지, 그리고 정리까지 전 과정을 순서대로 재생하는 실행 스크립트입니다.

  • 맨 앞에서 kubectl config current-context 로 컨텍스트를 확인합니다(kind-learn 이어야 합니다). set -euo pipefailcd "$(dirname "$0")" 덕분에 어느 위치에서 실행하든 manifests/ 상대경로가 맞고, 중간에 하나라도 실패하면 즉시 중단됩니다.
  • 각 단계마다 kubectl applykubectl wait --for=condition=Ready ... --timeout=90skubectl get pod -o wide 순으로 진행하므로, 파드가 Ready 되기를 기다린 뒤 배치된 노드를 확인하는 흐름이 그대로 담겨 있습니다.
  • 13-6 구간은 kubectl taint nodes learn-worker step13-demo=true:NoSchedule 로 테인트를 걸고 실습한 뒤, 같은 블록 안에서 하이픈으로 끝나는 ...:NoSchedule- 명령으로 즉시 원복합니다. 다만 set -e 상태에서 중간에 실패하면 원복 줄에 도달하지 못하므로, 스크립트가 도중에 죽었다면 테인트가 남아 있는지 직접 확인해야 합니다.
  • ⚠️ 마지막 줄들은 파괴적입니다. kubectl delete namespace step13 으로 이 스텝의 모든 리소스를 지우고, kubectl describe nodes | grep -i taint 로 워커에 테인트 흔적이 남지 않았는지 검증합니다. 중간 상태를 천천히 관찰하고 싶다면 스크립트를 통째로 돌리지 말고 블록별로 나눠 실행하는 편이 좋습니다.
#!/usr/bin/env bash
export PATH="/opt/homebrew/bin:$PATH"
# Step 13 — 스케줄링 제어. 각 명령을 순서대로 실행하며 결과를 관찰한다.
# 컨텍스트 확인 (kind-learn 이어야 함)
kubectl config current-context

set -euo pipefail
cd "$(dirname "$0")"

echo "### 0) 네임스페이스 + 노드 라벨 확인"
kubectl apply -f manifests/00-namespace.yaml
kubectl get nodes -L kubernetes.io/hostname

echo "### 13-1~3) nodeSelector / nodeName / nodeAffinity"
kubectl apply -f manifests/01-nodeselector.yaml
kubectl apply -f manifests/02-nodename.yaml
kubectl apply -f manifests/03-nodeaffinity.yaml
kubectl wait --for=condition=Ready pod/pin-worker pod/direct-assign pod/affinity-required -n step13 --timeout=90s
kubectl get pod -n step13 -o wide

echo "### 13-4) podAffinity (cache 를 web 곁에)"
kubectl apply -f manifests/04-podaffinity.yaml
kubectl wait --for=condition=Ready pod -l app=web -n step13 --timeout=90s
kubectl wait --for=condition=Ready pod/cache -n step13 --timeout=90s
kubectl get pod -n step13 -l 'app in (web,cache)' -o wide

echo "### 13-5) podAntiAffinity (복제본 흩기)"
kubectl apply -f manifests/05-podantiaffinity.yaml
kubectl wait --for=condition=Ready pod -l app=spread-app -n step13 --timeout=90s
kubectl get pod -n step13 -l app=spread-app -o wide

echo "### 13-6) taint / toleration  (★ 워커에만, 실습 직후 즉시 원복)"
kubectl taint nodes learn-worker step13-demo=true:NoSchedule
kubectl describe node learn-worker | grep -i taint
kubectl apply -f manifests/06-toleration.yaml
kubectl wait --for=condition=Ready pod/no-tol pod/with-tol -n step13 --timeout=90s
kubectl get pod no-tol with-tol -n step13 -o wide
kubectl taint nodes learn-worker step13-demo=true:NoSchedule-   # 원복
kubectl describe node learn-worker | grep -i taint || echo "(no taints)"

echo "### 13-7) topologySpreadConstraints (2/2 균등)"
kubectl apply -f manifests/07-topologyspread.yaml
kubectl wait --for=condition=Ready pod -l app=topo -n step13 --timeout=90s
kubectl get pod -n step13 -l app=topo -o wide

echo "### 13-8) Pending 진단"
kubectl apply -f manifests/08-pending.yaml
sleep 5
kubectl get pod unschedulable -n step13 -o wide
kubectl describe pod unschedulable -n step13 | sed -n '/Events:/,$p'

echo "### 정리 — 네임스페이스 삭제 + 노드 테인트 원복 확인"
kubectl delete namespace step13
kubectl describe nodes | grep -i taint