파드 Pending — volume node affinity 충돌

Kubernetes5분조회 –

StatefulSet 파드 하나가 6분째 Pending 에서 안 넘어갔습니다. 노드는 12대 전부 Ready 상태입니다.

데이터를 디스크에 들고 있어야 하는 앱이라 파드마다 EBS 볼륨을 PVC 로 하나씩 붙여 씁니다. 파드가 죽고 새로 떠도 같은 볼륨이 다시 붙어야 데이터가 이어지니, 파드와 볼륨이 한 쌍으로 묶여 다닙니다.

노드 12대는 세 가용영역에 나눠 뒀고, 한 영역이 통째로 나가도 서비스가 살아 있게 하려는 배치라 파드가 어느 노드에 앉든 상관없다는 전제로 운영해 왔습니다. 그래서 스케줄이 안 되는 상황은 대체로 자원이 모자란 경우였고, 노드를 늘리면 풀렸습니다.

그 클러스터에서 파드가 이렇게 멈춰 있었습니다.

$ kubectl get pod app-0
NAME    READY   STATUS    RESTARTS   AGE
app-0   0/1     Pending   0          6m

Pending 은 스케줄러가 파드를 놓을 노드를 아직 못 정했다는 뜻이고, 6분이면 일시적인 지연으로 보기는 어렵습니다.

스케줄러의 거절 사유 — 개수까지 기록

$ kubectl describe pod app-0
Events:
  Warning  FailedScheduling  0/12 nodes are available:
    9 node(s) had volume node affinity conflict,
    3 Insufficient memory.

12대를 전부 후보로 놓고 하나씩 떨어뜨리면서 이유별 개수를 남깁니다. 9대는 볼륨, 3대는 메모리. 원인이 두 개입니다.

taint 상태 확인 — 원인 아님

$ kubectl get nodes -o json | jq -r '.items[] | "\(.metadata.name) \(.spec.taints // [])"'

taint가 붙은 노드는 3대. 개수는 맞아떨어지지만 그 3대는 Insufficient memory로 잡힌 쪽이고, 원인이 taint였다면 메시지는 node(s) had untolerated taint로 나옵니다.

EBS 볼륨은 가용영역을 못 넘음

EBS 볼륨은 만들어진 가용영역에 묶여서 다른 존의 노드에는 붙지 않습니다. EBS CSI 드라이버가 PV를 만들 때 그 존을 nodeAffinity에 박아두기 때문에, 파드가 이 PV를 쓰면 스케줄러는 해당 존의 노드만 후보로 봅니다.

$ kubectl get pv pvc-8f3a... -o jsonpath='{.spec.nodeAffinity}' | jq
{
  "required": {
    "nodeSelectorTerms": [
      {
        "matchExpressions": [
          {
            "key": "topology.ebs.csi.aws.com/zone",
            "operator": "In",
            "values": ["ap-northeast-2a"]
          }
        ]
      }
    ]
  }
}

ap-northeast-2a 하나로 고정.

0/12의 내역 — 존별 노드 수와 거절 사유

$ kubectl get nodes -L topology.kubernetes.io/zone --no-headers | awk '{print $NF}' | sort | uniq -c
   3 ap-northeast-2a
   5 ap-northeast-2b
   4 ap-northeast-2c

존별 노드 수를 거절 사유와 붙여 놓으면 0/12 의 내역이 그대로 맞아떨어집니다.

존 노드 후보에서 빠진 이유
ap-northeast-2a 3대 볼륨은 붙는데 메모리가 모자람 → Insufficient memory
ap-northeast-2b 5대 볼륨이 못 붙는 존 → volume node affinity conflict
ap-northeast-2c 4대 볼륨이 못 붙는 존 → volume node affinity conflict

볼륨이 묶인 2a 는 3대뿐이고 그 3대가 전부 메모리가 꽉 찬 상태였습니다. 나머지 9대는 존이 달라서 애초에 후보가 못 됩니다.

존을 붙잡는 건 파드가 아니라 PVC

한 번 바인딩된 PVC 는 파드가 재생성돼도 그대로 재사용되고, PVC 를 지울 때까지 처음 잡힌 존에 묶여 있습니다. 파드를 지웠다 다시 띄워도 그 존을 벗어나지 못하니, 오토스케일링이나 노드 교체로 그 존의 여유가 사라지면 클러스터 전체에 자리가 남아도 이 파드만 못 뜹니다.

응급 처치 — 해당 존에 노드 추가

$ aws autoscaling set-desired-capacity \
    --auto-scaling-group-name <asg-name> \
    --desired-capacity 4

2a에 자리가 생기면 Pending 파드가 바로 스케줄됩니다.

근본 대응 — volumeBindingMode

Immediate면 PVC가 생기는 시점에 볼륨이 만들어지고 존이 확정돼서, 파드가 어느 노드로 갈지 정해지기 전에 존이 먼저 잡히는 구조가 됩니다.

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3
provisioner: ebs.csi.aws.com
volumeBindingMode: WaitForFirstConsumer   # Immediate 가 아니라 이것
parameters:
  type: gp3

WaitForFirstConsumer는 이 순서를 뒤집습니다. 스케줄러가 파드를 놓을 노드를 먼저 정하고 그 노드가 속한 존에 볼륨을 만들기 때문에, 충돌 조건 자체가 없어집니다.

존별 노드 수도 같이 맞춰야 하고, 그 존 노드가 전부 차 있으면 같은 상황이 그대로 재현되니 존마다 여유 노드가 남도록 ASG min 을 올렸습니다.

흔한 오답 — PVC 재생성

“PVC를 지우고 다시 만들라”는 답이 자주 보입니다. 존이 다시 잡히니 파드는 뜨지만, StorageClass의 reclaimPolicy가 기본값 Delete면 PVC를 지울 때 PV와 EBS 볼륨까지 같이 삭제되고, 안에 있던 데이터도 사라집니다.

StatefulSet의 PVC는 대부분 지우면 안 되는 대상이라, 노드를 한 대 늘리는 쪽이 안전합니다.

이미 바인딩된 PV에는 소급 적용 안 됨

StorageClass 를 WaitForFirstConsumer 로 바꿔도 이미 Immediate 로 만들어져 바인딩된 PV 에는 소급되지 않습니다. nodeAffinity 는 PV 를 만드는 시점에 확정되고 그 뒤로는 수정할 수 없어서, 존을 옮기려면 스냅샷으로 다른 존에 새 볼륨을 만들고 PVC 를 교체해야 하는데 그 사이 파드를 내려야 하니 다운타임이 따라옵니다.

이미 돌고 있는 워크로드는 그 존의 노드 여유를 유지하는 쪽으로 두고, StorageClass 변경은 앞으로 새로 만들어지는 볼륨에만 적용된다고 보면 됩니다.

참고

  1. 불러오는 중