Skip to content
Published on

Kubernetes Pod Pending 상태 해결 — 스케줄러가 남긴 거절 사유 읽는 법

공유하기
Authors

들어가며 — 파드가 Pending에서 10분째 그대로입니다

배포는 성공했다고 나오는데 파드가 움직이지 않습니다.

kubectl get pod -n analytics

NAME                          READY   STATUS    RESTARTS   AGE
clickhouse-0                  0/1     Pending   0          11m
report-worker-7f4c8d9b6-hs2p  0/1     Pending   0          11m
report-worker-7f4c8d9b6-k9vt  1/1     Running   0          11m

Pending은 파드 오브젝트는 만들어졌지만 아직 노드가 정해지지 않았다는 뜻입니다. 컨테이너 문제가 아니라 배치 문제이므로, 로그를 봐도 아무것도 나오지 않습니다. 컨테이너가 아예 시작되지 않았기 때문입니다.

kubectl logs clickhouse-0 -n analytics

Error from server (BadRequest): container "clickhouse" in pod "clickhouse-0" is waiting to start: ContainerCreating

다행히 스케줄러는 왜 배치하지 못했는지를 매번 이벤트로 남깁니다. 그 한 문장이 진단의 전부입니다.

스케줄러의 거절 메시지 해독법

kubectl describe pod clickhouse-0 -n analytics | tail -6

Events:
  Type     Reason            Age                   From               Message
  ----     ------            ----                  ----               -------
  Warning  FailedScheduling  11m                   default-scheduler  0/5 nodes are available: 2 Insufficient cpu, 1 node(s) had untolerated taint {dedicated: batch}, 2 node(s) didn't match Pod's node affinity/selector. preemption: 0/5 nodes are available: 5 Preemption is not helpful for scheduling.

이 문장의 구조를 알아야 합니다.

  • 0/5 — 전체 5개 노드 중 0개가 가능. 앞 숫자가 통과한 노드 수입니다
  • 콜론 뒤는 노드별 탈락 사유의 집계입니다. 2 + 1 + 2 = 5로 노드 수와 정확히 맞습니다
  • 마지막 preemption: 절은 우선순위 기반 선점으로도 자리를 못 만들었다는 뜻입니다

두 번째 항목에 결정적인 함의가 있습니다. 스케줄러의 필터 체인은 노드마다 처음 실패한 플러그인에서 멈춥니다. 그래서 한 노드는 하나의 이유로만 집계됩니다. CPU도 부족하고 테인트도 있는 노드는 앞선 이유 하나만 보여 줍니다. 실무적으로 이 말은, 보고된 이유를 고쳤더니 곧바로 다음 이유가 나타나는 일이 정상이라는 뜻입니다. 한 번에 하나씩 벗겨 내야 합니다.

이벤트는 기본 보존 기간이 한 시간이라 오래된 Pending 파드에서는 사라져 있을 수 있습니다. 그럴 때는 파드를 다시 만들어 이벤트를 새로 뽑습니다.

kubectl get events -n analytics --field-selector reason=FailedScheduling --sort-by=.lastTimestamp | tail -3

LAST SEEN   TYPE      REASON             OBJECT             MESSAGE
3m41s       Warning   FailedScheduling   pod/clickhouse-0   0/5 nodes are available: 2 Insufficient cpu, ...

원인 분기와 진단

원인스케줄러 메시지진단 명령해결
CPU·메모리 요청량 초과Insufficient cpu, Insufficient memorykubectl describe node의 Allocated resourcesrequests 하향 또는 노드 증설
톨러레이션 없음had untolerated taintkubectl describe node 의 Taintstolerations 추가 또는 테인트 제거
셀렉터·어피니티 불일치didn't match Pod's node affinity/selectorkubectl get nodes --show-labels노드 라벨 부여 또는 조건 완화
PVC 미바인딩pod has unbound immediate PersistentVolumeClaimskubectl describe pvc스토리지클래스 존재와 프로비저너 확인
볼륨 존 불일치volume node affinity conflictPV의 nodeAffinity 확인같은 존에 노드 확보
파드 안티어피니티 자기 배제didn't match pod anti-affinity rules복제본 수와 토폴로지 수 비교preferred로 완화 또는 노드 추가
분산 제약 위반didn't match pod topology spread constraintskubectl get pods -o wide로 현재 분포 확인ScheduleAnyway로 완화, maxSkew 조정
노드당 파드 수 상한Too many podskubectl get node -o jsonpath 로 allocatable.pods 조회CNI 설정 변경 또는 노드 증설
노드 NotReady·cordonnode(s) were unschedulablekubectl get nodes노드 복구 또는 uncordon

리소스 부족 — 사용량이 아니라 requests가 기준입니다

가장 흔하고, 동시에 가장 자주 오진되는 갈래입니다.

kubectl top node

NAME           CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%
ip-10-0-1-14   842m         21%    4118Mi          31%
ip-10-0-2-77   1105m        27%    5203Mi          39%
ip-10-0-3-91   667m         16%    3877Mi          29%

노드마다 CPU가 20퍼센트대이고 메모리도 30퍼센트대입니다. 여기서 "자원은 남는데 왜 스케줄이 안 되지"라는 결론으로 가면 몇 시간을 낭비합니다. 스케줄러는 실제 사용량을 보지 않습니다. 노드에 이미 배치된 파드들의 requests 총합만 봅니다.

kubectl describe node ip-10-0-1-14 | grep -A10 "Allocated resources"

Allocated resources:
  (Total limits may be over 100 percent, i.e., overcommitted.)
  Resource           Requests       Limits
  --------           --------       ------
  cpu                3730m (93%)    6200m (155%)
  memory             11544Mi (89%)  15Gi (118%)
  ephemeral-storage  0 (0%)         0 (0%)
  pods               24             24

실제 사용량 21퍼센트, 예약량 93퍼센트입니다. 스케줄러 입장에서 이 노드는 거의 꽉 찬 노드입니다. 원인은 대개 requests를 과도하게 잡은 파드 몇 개입니다.

kubectl get pods -A --field-selector spec.nodeName=ip-10-0-1-14 \
  -o custom-columns='NS:.metadata.namespace,POD:.metadata.name,CPU:.spec.containers[*].resources.requests.cpu,MEM:.spec.containers[*].resources.requests.memory' \
  | sort -k3 -hr | head -6

NS          POD                            CPU     MEM
analytics   spark-executor-3               1500m   4Gi
analytics   spark-executor-1               1500m   4Gi
search      indexer-6c4d9f7b8-h2klp        500m    2Gi
payments    ledger-5f8b7c6d9-nm3xt         200m    1Gi

Spark 실행기 두 개가 CPU 3코어를 예약하고 실제로는 거의 쓰지 않습니다.

여기서 requests와 limits의 역할을 정확히 구분해야 합니다.

  • requests — 스케줄링의 유일한 기준입니다. 노드의 allocatable에서 이 값들을 빼서 남는 자리를 계산합니다. 컨테이너 실행 시점에는 CPU의 경우 cgroup 가중치로, 메모리의 경우 아무 강제력 없이 작용합니다
  • limits — 스케줄링에 전혀 관여하지 않습니다. 실행 중 상한일 뿐입니다. CPU limits는 스로틀링을, 메모리 limits는 OOM 킬을 유발합니다

그래서 limits를 아무리 크게 잡아도 스케줄이 되지 않는 일은 없고, 반대로 requests를 크게 잡으면 실제로 쓰지 않아도 노드를 점유합니다.

흔한 오답: requests를 아예 지워서 스케줄을 통과시키는 것. 파드는 뜨지만 QoS 클래스가 BestEffort가 되어 노드가 압박을 받는 순간 가장 먼저 축출됩니다. 게다가 그 파드는 스케줄러 계산에서 0으로 취급되므로 노드를 과밀하게 만들어 이웃 파드까지 위험해집니다. 올바른 해법은 실제 사용량을 근거로 requests를 재산정하는 것입니다.

kubectl top pod -A --containers --sort-by=cpu | head -6

NAMESPACE   POD                 NAME              CPU(cores)   MEMORY(bytes)
analytics   spark-executor-3    executor          180m         1204Mi
analytics   spark-executor-1    executor          166m         1189Mi
search      indexer-6c4...      indexer           410m         1802Mi

1500m을 예약하고 180m을 쓰고 있습니다. 400m 정도로 낮추면 노드 하나가 통째로 비워집니다.

노드가 받아 주지 않는 경우

테인트와 톨러레이션

kubectl get nodes -o custom-columns='NAME:.metadata.name,TAINTS:.spec.taints[*].key,EFFECT:.spec.taints[*].effect'

NAME           TAINTS                              EFFECT
ip-10-0-1-14   dedicated                           NoSchedule
ip-10-0-2-77   <none>                              <none>
ip-10-0-3-91   node.kubernetes.io/disk-pressure    NoSchedule
ip-10-0-4-22   nvidia.com/gpu                      NoSchedule

두 번째 노드만 테인트가 없습니다. 세 번째 노드의 테인트는 사람이 붙인 것이 아니라 kubelet이 자동으로 붙인 것입니다. disk-pressure, memory-pressure, pid-pressure, unreachable, not-ready 계열 테인트가 보이면 그건 배치 정책이 아니라 노드 장애 신호입니다. 톨러레이션을 붙여 우회할 것이 아니라 노드를 고쳐야 합니다.

의도적인 테인트라면 톨러레이션을 붙입니다.

spec:
  tolerations:
    - key: dedicated
      operator: Equal
      value: batch
      effect: NoSchedule

노드 셀렉터와 어피니티 불일치

메시지에 affinity/selector가 나오면 조건에 맞는 노드가 실제로 있는지부터 확인합니다.

kubectl get pod clickhouse-0 -n analytics -o jsonpath='{.spec.nodeSelector}'

{"disktype":"nvme","topology.kubernetes.io/zone":"ap-northeast-2c"}

kubectl get nodes -l disktype=nvme,topology.kubernetes.io/zone=ap-northeast-2c

No resources found

조건을 하나씩 빼 보면 어느 쪽이 문제인지 드러납니다.

kubectl get nodes -l disktype=nvme

NAME           STATUS   ROLES    AGE   VERSION
ip-10-0-1-14   Ready    <none>   9d    v1.31.4

kubectl get nodes -l topology.kubernetes.io/zone=ap-northeast-2c

No resources found

nvme 노드는 있지만 해당 존에 노드가 하나도 없습니다. 존 조건이 잘못됐거나 그 존의 노드 그룹이 0으로 축소된 것입니다.

노드당 파드 수 상한과 NotReady

메시지가 Too many pods이면 자원이 아니라 개수 문제입니다.

kubectl get nodes -o custom-columns='NAME:.metadata.name,MAXPODS:.status.allocatable.pods'

NAME           MAXPODS
ip-10-0-1-14   29
ip-10-0-2-77   29
ip-10-0-3-91   29

EKS에서 VPC CNI를 쓰면 노드가 붙일 수 있는 ENI와 IP 수로 상한이 결정되므로, 인스턴스 타입이 작으면 CPU와 메모리가 남아도 파드를 더 못 받습니다. 접두사 위임을 켜거나 인스턴스 타입을 키워야 합니다.

노드 자체가 빠진 경우도 흔합니다.

kubectl get nodes

NAME           STATUS                     ROLES    AGE   VERSION
ip-10-0-1-14   Ready                      <none>   9d    v1.31.4
ip-10-0-2-77   Ready,SchedulingDisabled   <none>   9d    v1.31.4
ip-10-0-3-91   NotReady                   <none>   9d    v1.31.4

SchedulingDisabled는 누군가 cordon한 상태입니다. 유지보수 후 uncordon을 잊은 경우가 대부분입니다.

스토리지와 배치 제약

PVC 미바인딩, 그리고 정상적인 Pending

파드가 Pending이고 PVC도 Pending이면 대부분 스토리지를 의심하지만, 여기에는 반드시 구분해야 할 두 상황이 있습니다.

먼저 진짜 문제인 경우입니다.

kubectl describe pvc clickhouse-data -n analytics | tail -5

Events:
  Type     Reason              Age                From                         Message
  ----     ------              ----               ----                         -------
  Warning  ProvisioningFailed  9m (x14 over 12m)  persistentvolume-controller  storageclass.storage.k8s.io "fast-ssd" not found

스토리지클래스 이름이 틀렸습니다. 클러스터를 옮기면서 이름이 달라진 전형적인 경우입니다.

kubectl get storageclass

NAME            PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
gp3 (default)   ebs.csi.aws.com         Delete          WaitForFirstConsumer   true                   61d
io2             ebs.csi.aws.com         Delete          WaitForFirstConsumer   true                   61d

이제 정상인 경우입니다.

kubectl describe pvc report-cache -n analytics | tail -4

Events:
  Type    Reason                Age                From                         Message
  ----    ----                  ----               ----                         -------
  Normal  WaitForFirstConsumer  12s (x8 over 2m)   persistentvolume-controller  waiting for first consumer to be created before binding

이건 오류가 아닙니다. VOLUMEBINDINGMODE가 WaitForFirstConsumer인 스토리지클래스는 파드가 노드에 배치된 다음에 그 노드와 같은 존에 볼륨을 만듭니다. 파드가 먼저 배치되어야 볼륨이 생기므로, PVC가 Pending인 것은 설계대로입니다.

문제는 여기서 생깁니다. 파드가 다른 이유로 배치되지 못하면 PVC도 영원히 Pending에 머뭅니다. 그러면 PVC를 보고 스토리지를 고치려 들지만, 실제로 고쳐야 하는 것은 파드의 스케줄링 실패입니다. PVC가 WaitForFirstConsumer로 대기 중이면 PVC는 무시하고 파드의 FailedScheduling 메시지만 보십시오.

이미 볼륨이 생성된 뒤에 노드가 교체되면 존이 어긋날 수 있습니다.

kubectl describe pod clickhouse-0 -n analytics | grep FailedScheduling

  Warning  FailedScheduling  4m  default-scheduler  0/5 nodes are available: 5 node(s) had volume node affinity conflict.

볼륨은 ap-northeast-2a에 있는데 그 존에 스케줄 가능한 노드가 없다는 뜻입니다.

kubectl get pv -o custom-columns='NAME:.metadata.name,ZONE:.spec.nodeAffinity.required.nodeSelectorTerms[*].matchExpressions[*].values' | head -3

NAME                                       ZONE
pvc-8f3c1d5b-2e7a-94c0-f13d-68b5a2e9c47f   [ap-northeast-2a]

안티어피니티에 의한 자기 배제

복제본 3개에 호스트 단위 필수 안티어피니티를 걸었는데 노드가 2개면, 세 번째 파드는 영원히 Pending입니다.

spec:
  affinity:
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchLabels:
              app: report-worker
          topologyKey: kubernetes.io/hostname
kubectl describe pod report-worker-7f4c8d9b6-hs2p -n analytics | grep FailedScheduling

  Warning  FailedScheduling  9m  default-scheduler  0/2 nodes are available: 2 node(s) didn't match pod anti-affinity rules.

이 제약은 필요할 때가 분명히 있지만, 복제본 수가 토폴로지 수를 넘는 순간 조용히 배포를 막습니다. 가용성이 목적이라면 대개 preferred로 충분합니다.

spec:
  affinity:
    podAntiAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 100
          podAffinityTerm:
            labelSelector:
              matchLabels:
                app: report-worker
            topologyKey: kubernetes.io/hostname

topologySpreadConstraints

분산 제약은 안티어피니티보다 부드럽지만 whenUnsatisfiable을 DoNotSchedule로 두면 똑같이 막힙니다.

spec:
  topologySpreadConstraints:
    - maxSkew: 1
      topologyKey: topology.kubernetes.io/zone
      whenUnsatisfiable: DoNotSchedule
      labelSelector:
        matchLabels:
          app: report-worker

존이 3개인데 한 존의 노드가 전부 꽉 차면, 다른 존에 자리가 있어도 스큐 1을 지킬 수 없어 배치가 거부됩니다.

kubectl get pods -n analytics -l app=report-worker -o wide

NAME                            READY   STATUS    NODE           ZONE
report-worker-7f4c8d9b6-k9vt    1/1     Running   ip-10-0-1-14   2a
report-worker-7f4c8d9b6-m2xd    1/1     Running   ip-10-0-2-77   2b
report-worker-7f4c8d9b6-hs2p    0/1     Pending   <none>         <none>

가용성이 절대 조건이 아니라면 ScheduleAnyway로 바꾸는 것이 실용적입니다. 이때 제약은 점수 계산에만 반영되고 배치를 막지 않습니다.

Cluster Autoscaler가 노드를 안 늘리는 이유

노드가 부족하면 오토스케일러가 늘려 주리라 기대하지만, 늘지 않는 경우가 자주 있습니다. 이때도 이유는 이벤트에 남습니다.

kubectl describe pod clickhouse-0 -n analytics | grep -A2 NotTriggerScaleUp

  Normal   NotTriggerScaleUp  2m17s (x21 over 9m)  cluster-autoscaler  pod didn't trigger scale-up: 1 max node group size reached, 2 node(s) had untolerated taint {dedicated: batch}, 1 node(s) had volume node affinity conflict

세 가지를 동시에 말하고 있습니다. 한 노드 그룹은 최대 크기에 도달했고, 두 그룹은 이 파드가 견디지 못하는 테인트를 가지며, 한 그룹은 볼륨 존이 맞지 않습니다. 오토스케일러는 새 노드를 띄웠다고 가정했을 때 이 파드가 배치 가능한지를 미리 시뮬레이션하므로, 배치 불가로 판정되면 노드를 늘리지 않습니다.

컨트롤러 쪽 로그와 상태 컨피그맵도 함께 봅니다.

kubectl -n kube-system logs deployment/cluster-autoscaler --tail=200 | grep -i "no expansion\|max size\|scale_up"

I0726 09:52:14.118  scale_up.go:300] Pod analytics/clickhouse-0 can't be scheduled on eks-general-2c. Predicate checking error: node(s) had volume node affinity conflict
I0726 09:52:14.119  scale_up.go:472] No expansion options
kubectl -n kube-system get configmap cluster-autoscaler-status \
  -o jsonpath='{.data.status}' | head -12

Cluster-autoscaler status at 2026-07-26 09:52:14:
Cluster-wide:
  Health:      Healthy
  ScaleUp:     NoActivity (ready=5 registered=5)
  ScaleDown:   NoCandidates

NodeGroups:
  Name:        eks-general-2a
  Health:      Healthy (ready=3 cloudProviderTarget=3 minSize=1 maxSize=3)
  ScaleUp:     NoActivity

maxSize와 cloudProviderTarget이 같으면 상한에 걸린 것입니다.

흔한 오답: Pending이 보이면 무조건 노드를 추가하는 것. 안티어피니티, 분산 제약, 볼륨 존 불일치는 노드를 늘려도 풀리지 않거나, 엉뚱한 존에 노드가 늘어 비용만 증가합니다. NotTriggerScaleUp 메시지를 먼저 읽고 그 이유를 제거해야 합니다.

재발 방지

Pending은 대개 requests 산정이 근거 없이 이루어진 결과입니다. 세 가지 장치로 줄일 수 있습니다.

첫째, 네임스페이스에 기본값과 상한을 강제합니다. requests를 빼먹은 파드가 들어오는 것을 막습니다.

apiVersion: v1
kind: LimitRange
metadata:
  name: default-requests
  namespace: analytics
spec:
  limits:
    - type: Container
      default:
        cpu: 500m
        memory: 512Mi
      defaultRequest:
        cpu: 100m
        memory: 256Mi
      max:
        cpu: 4
        memory: 16Gi

둘째, VPA를 권고 모드로 돌려 실제 사용량 기반 권장치를 확보합니다. 자동 적용까지 하지 않아도 숫자를 보는 것만으로 가치가 있습니다.

kubectl get vpa spark-executor -n analytics -o jsonpath='{.status.recommendation.containerRecommendations[0]}'

{"containerName":"executor","lowerBound":{"cpu":"180m","memory":"1000Mi"},"target":{"cpu":"320m","memory":"1400Mi"},"upperBound":{"cpu":"1200m","memory":"3Gi"}}

셋째, Pending 지속 시간을 알림으로 잡습니다. 배치 실패는 애플리케이션 지표에 전혀 나타나지 않으므로 별도 규칙이 필요합니다.

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: scheduling-alerts
  namespace: monitoring
spec:
  groups:
    - name: scheduling
      rules:
        - alert: PodPendingTooLong
          expr: kube_pod_status_phase{phase="Pending"} == 1
          for: 15m
          labels:
            severity: warning
          annotations:
            summary: "{{ $labels.namespace }}/{{ $labels.pod }} has been Pending for 15m"
        - alert: NodeRequestsNearlyFull
          expr: sum by (node) (kube_pod_container_resource_requests{resource="cpu"}) / on(node) kube_node_status_allocatable{resource="cpu"} > 0.9
          for: 30m
          labels:
            severity: warning

마치며 — 스케줄러는 이미 답을 말했습니다

Pending 진단에서 추측이 필요한 순간은 거의 없습니다. FailedScheduling 이벤트의 한 문장에 노드 수와 탈락 사유가 집계되어 있고, 각 사유는 위 표의 한 행에 대응합니다. 필터 체인이 노드당 첫 실패에서 멈추기 때문에 이유가 순차적으로 드러난다는 점만 기억하면, 한 번에 하나씩 벗겨 내는 작업이 됩니다.

기억할 한 문장은 이것입니다. 스케줄링은 실제 사용량이 아니라 requests의 산술로 결정되며, kubectl top이 한가해 보인다고 해서 노드에 자리가 있는 것은 아닙니다.