Published on

EKS 노드그룹 드레이닝 실패 - PDB·Pod 종료 해결

Authors

서로 다른 원인들이 한 화면에서는 모두 draining 으로만 보이기 때문에, EKS 노드그룹(Managed Node Group) 업데이트나 스케일다운이 멈추면 운영자는 방향을 잃기 쉽습니다. 특히 kubectl drain 은 내부적으로 eviction 을 사용하고, 이때 PodDisruptionBudget(PDB) 가 걸려 있거나 Pod 종료가 지연되면 노드는 계속 cordon 된 채로 남아 롤링 업데이트가 정지합니다.

이 글에서는 “노드 드레이닝 실패”를 PDB로 인해 eviction 자체가 거부되는 케이스와, eviction 은 됐는데 Pod 종료가 끝나지 않는 케이스로 나눠서 진단/해결합니다. 마지막에는 노드그룹 업데이트가 멈추지 않도록 사전 설계 체크리스트까지 정리합니다.

관련해서 배포 파이프라인에서 진행 상태가 멈추는 문제를 같이 겪는 경우가 많습니다. CI/CD 관점 디버깅은 GitHub Actions Kubernetes 배포 stuck in Progress 디버깅 도 함께 참고하면 좋습니다.

1) 드레이닝이 실제로 무엇을 하는지(핵심 동작)

EKS 노드그룹 업데이트 또는 스케일다운 시, 컨트롤 플레인/ASG/노드그룹 컨트롤러가 대략 다음을 수행합니다.

  1. 노드 cordon (스케줄 불가)
  2. 노드의 Evict 가능한 Pod들에 대해 eviction 요청
  3. Pod가 종료되면 다른 노드에 재스케줄
  4. 노드가 비워지면 인스턴스 종료

여기서 실패 패턴은 크게 두 가지입니다.

  • 패턴 A: eviction 요청이 거부됨
    • 대표 원인: PDB가 disruptionsAllowed=0 인 상태
  • 패턴 B: eviction 은 됐는데 Pod가 안 죽음
    • 대표 원인: 긴 terminationGracePeriodSeconds, 종료 훅(preStop) 지연, finalizer, 볼륨 detach 지연, 애플리케이션이 SIGTERM 을 무시

2) 증상 확인: “어디에서” 멈췄는지 빠르게 판별

2.1 노드 상태와 어떤 Pod가 남았는지

다음 명령으로 드레이닝 대상 노드와 잔류 Pod를 먼저 특정합니다.

kubectl get nodes -o wide
kubectl describe node `NODE_NAME`

# 해당 노드에 남아있는 Pod 확인
kubectl get pod -A -o wide --field-selector spec.nodeName=`NODE_NAME`

잔류 Pod를 보면 힌트가 바로 나옵니다.

  • kube-systemDaemonSet Pod만 남았다면 정상일 수 있습니다(드레인 옵션에 따라 DaemonSet은 무시).
  • 특정 네임스페이스의 Deployment/StatefulSet Pod가 남아있고 Terminating 이 길다면 패턴 B 가능성이 큽니다.

2.2 이벤트에서 eviction 거부(PDB) 확인

PDB가 원인일 때는 이벤트에 매우 직접적으로 나타납니다.

kubectl get events -A --sort-by=.lastTimestamp | tail -n 50

메시지 예시(환경마다 다르지만 키워드는 유사):

  • Cannot evict pod as it would violate the pod's disruption budget
  • eviction blocked by PDB

3) PDB 때문에 드레이닝이 실패하는 경우(패턴 A)

3.1 PDB 상태를 숫자로 확인

PDB는 “동시 중단 허용량”을 disruptionsAllowed 로 보여줍니다.

kubectl get pdb -A
kubectl describe pdb -n `NAMESPACE` `PDB_NAME`

특히 아래 항목을 확인합니다.

  • MinAvailable 또는 MaxUnavailable
  • Allowed disruptions (또는 DisruptionsAllowed)
  • CurrentHealthy, DesiredHealthy, ExpectedPods

Allowed disruptions: 0 이면, 해당 PDB가 선택한 Pod 중 하나라도 축출(evict)하면 규칙 위반이라는 뜻이라 드레이닝이 멈춥니다.

3.2 흔한 PDB 설계 실수

(1) 레플리카가 1인데 minAvailable: 1

예: 단일 레플리카 서비스에 아래처럼 설정하면, 노드 유지보수 시 절대 중단을 허용하지 않으므로 eviction 이 막힙니다.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-pdb
  namespace: prod
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app: api

해결 옵션:

  • 레플리카를 2 이상으로 늘리고 PDB 유지
  • 레플리카를 1로 유지해야 한다면, 유지보수 시간에 한시적으로 PDB 완화 또는 제거

(2) maxUnavailable: 0 의 함정

maxUnavailable: 0 은 “항상 0개만 비가용”이므로 사실상 강제 무중단입니다. 레플리카가 충분치 않으면 동일하게 막힙니다.

(3) selector 라벨이 너무 넓어서 엉뚱한 Pod까지 묶임

PDB selector 가 app: api 정도로만 되어 있고, 서로 다른 워크로드가 같은 라벨을 공유하면 예상보다 많은 Pod에 PDB가 적용되어 ExpectedPods 가 커지고 DesiredHealthy 가 높아져 eviction 이 막힐 수 있습니다.

3.3 즉시 복구(운영 중단 최소화) 전략

운영 중 즉시 드레이닝을 진행해야 한다면 아래 중 하나를 선택합니다.

(A) 레플리카를 먼저 늘려 disruptionsAllowed 를 확보

kubectl scale deploy -n `NAMESPACE` `DEPLOYMENT` --replicas=2
kubectl get pdb -n `NAMESPACE` `PDB_NAME` -o yaml | sed -n '1,120p'

(B) PDB를 일시적으로 완화

예를 들어 minAvailable 을 낮추거나 maxUnavailable 을 늘립니다.

kubectl patch pdb -n `NAMESPACE` `PDB_NAME` --type merge -p '{"spec":{"minAvailable":0}}'

유지보수 후 원복을 반드시 자동화(예: GitOps/Helm 값)로 관리하세요. 수동 패치는 잊으면 사고로 이어집니다.

(C) 최후 수단: PDB 삭제

kubectl delete pdb -n `NAMESPACE` `PDB_NAME`

삭제는 곧 “자발적 중단 보호를 포기”한다는 의미이므로, 트래픽/가용성 영향도를 감수할 때만 사용합니다.

4) Pod가 종료되지 않아 드레이닝이 실패하는 경우(패턴 B)

PDB가 아니면 대부분은 “Pod가 Terminating 에서 안 끝남”입니다.

4.1 어떤 Pod가 왜 안 죽는지: describe 로 원인 수집

kubectl get pod -n `NAMESPACE` -o wide | grep Terminating
kubectl describe pod -n `NAMESPACE` `POD_NAME`

확인 포인트:

  • terminationGracePeriodSeconds 가 과도하게 큰가
  • preStop 훅이 오래 걸리거나 외부 의존성(예: DB, HTTP 콜)에 묶이는가
  • Readiness 가 내려가지 않아 트래픽이 계속 들어오는가(서비스 디스커버리/프록시 설정)
  • Finalizers 가 남아있는가

4.2 애플리케이션이 SIGTERM 을 제대로 처리하는지

쿠버네티스는 기본적으로 컨테이너 프로세스에 SIGTERM 을 보내고, grace 기간이 지나면 SIGKILL 로 강제 종료합니다.

하지만 다음 케이스에서 종료가 길어집니다.

  • 메인 프로세스가 PID 1 로서 시그널 처리를 제대로 못함(특히 쉘 스크립트 래핑)
  • 종료 시 큐 드레인/플러시를 너무 길게 수행
  • preStop 에서 무한 대기 또는 재시도 루프

Node.js 예시(종료 훅 최소 구현):

process.on('SIGTERM', async () => {
  server.close(() => {
    process.exit(0)
  })

  // 안전장치: 너무 오래 걸리면 강제 종료
  setTimeout(() => process.exit(1), 25000)
})

4.3 preStop 훅이 드레이닝을 붙잡는 전형 패턴

예: sleep 60 같은 preStop 은 롤링 업데이트에서는 유용할 수 있지만, 노드 드레인에서는 대량 Pod가 동시에 걸려 전체 시간이 폭증합니다.

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 30"]
terminationGracePeriodSeconds: 45

권장:

  • preStop 은 “짧고 결정적”이어야 합니다.
  • 트래픽 차단은 readiness 전환과 서비스/인그레스 계층에서 처리하고, 컨테이너 내부 sleep 으로 버티지 않게 설계합니다.

4.4 Finalizer 때문에 영원히 Terminating

리소스(특히 PVC, 커스텀 리소스, 일부 네트워크/보안 플러그인)가 finalizer 를 제거하지 못하면 Pod가 삭제되지 않습니다.

확인:

kubectl get pod -n `NAMESPACE` `POD_NAME` -o jsonpath='{.metadata.finalizers}'

정말 최후의 수단으로 finalizer 제거를 고려할 수 있지만, 이는 “정리 작업을 건너뛰는 것”이라 스토리지/네트워크 리소스 누수로 이어질 수 있습니다.

kubectl patch pod -n `NAMESPACE` `POD_NAME` --type json -p='[{"op":"remove","path":"/metadata/finalizers"}]'

4.5 강제 삭제는 언제 쓰나(그리고 무엇이 위험한가)

드레인이 막혔다고 무조건 강제 삭제를 하면 데이터 손상이나 요청 유실이 발생할 수 있습니다. 그래도 불가피하다면 영향 범위를 알고 실행해야 합니다.

kubectl delete pod -n `NAMESPACE` `POD_NAME` --grace-period=0 --force

주의:

  • Stateful 워크로드(예: DB, 큐, 인덱서)는 강제 종료 시 복구 시간이 길어질 수 있습니다.
  • PVC detach 지연이나 스토리지 드라이버 이슈가 있으면, 다음 노드에서 재기동이 더 늦어질 수 있습니다.

이미지 풀 문제로 재스케줄 후 기동이 실패하면 드레인 이후 서비스가 내려갈 수 있습니다. 이 경우 EKS ImagePullBackOff 403 - ECR 권한·토큰 만료 해결 도 함께 점검하세요.

5) kubectl drain 을 직접 실행할 때의 안전한 옵션

운영자가 수동으로 노드를 비워야 한다면 보통 아래 형태를 씁니다.

kubectl drain `NODE_NAME` \
  --ignore-daemonsets \
  --delete-emptydir-data \
  --grace-period=60 \
  --timeout=10m

옵션 해설:

  • --ignore-daemonsets: DaemonSet Pod는 보통 노드에 상주해야 하므로 드레인 대상에서 제외
  • --delete-emptydir-data: emptyDir 는 노드 로컬 데이터라 이동 불가, 삭제 동의가 필요
  • --grace-period: Pod 종료 유예(0은 강제에 가까움)
  • --timeout: 무한 대기를 방지

주의할 점은, kubectl drain 이 성공해도 “재스케줄된 Pod가 정상 기동했는지”는 별도 확인이 필요하다는 것입니다. 드레인 자체는 비우는 작업이고, 이후 스케줄링/이미지 풀/노드 리소스 부족 문제는 다른 층위입니다. 재스케줄이 안 되는 경우는 K8s Pod Pending(0/노드) - 스케줄 불가 원인·해결 을 참고해 원인을 좁히면 빠릅니다.

6) EKS Managed Node Group 업데이트가 멈출 때 체크리스트

6.1 PDB 관점

  • 레플리카 1인 워크로드에 minAvailable: 1 을 걸어두지 않았는가
  • maxUnavailable: 0 을 광범위하게 적용하지 않았는가
  • selector 라벨이 과도하게 넓지 않은가
  • 유지보수 시점에 레플리카를 임시 확장할 여지가 있는가

6.2 종료(termination) 관점

  • terminationGracePeriodSeconds 가 현실적인가(보통 수십 초 단위)
  • preStop 훅이 외부 의존성 때문에 지연될 가능성이 있는가
  • 애플리케이션이 SIGTERM 을 즉시 받아들이고 readiness 를 빨리 내리는가
  • finalizer 가 붙는 리소스(스토리지/네트워크/CRD)가 있는가

6.3 노드/클러스터 운영 관점

  • 노드그룹 업데이트 시 surge(여분 노드)로 여유를 두고 있는가
  • 클러스터 오토스케일러가 있다면, PDB/스케줄 제약 때문에 축출이 막히지 않는가
  • 중요한 워크로드는 topology spread, anti-affinity 로 단일 노드에 몰리지 않게 했는가

7) 실전: “드레인 실패”를 10분 안에 수습하는 순서

아래 순서로 보면 대부분의 케이스를 빠르게 정리할 수 있습니다.

  1. 잔류 Pod 목록 확보: kubectl get pod -A -o wide --field-selector spec.nodeName=...
  2. 이벤트 확인: PDB 거부 메시지 여부
  3. PDB 확인: kubectl get pdb -Adescribe
  4. PDB가 원인이면: 레플리카 확장 또는 PDB 일시 완화
  5. PDB가 아니면: Terminating Pod describe 로 preStop, grace, finalizer 확인
  6. 불가피하면: 영향도를 계산하고 제한적으로 강제 삭제
  7. 재스케줄 후: Pending/ImagePullBackOff/CrashLoopBackOff 여부 확인

마무리

EKS 노드그룹 드레이닝 실패는 “EKS가 이상하다”기보다, 대부분 PDB가 의도대로 너무 강하게 설정되었거나, Pod 종료 경로가 설계대로 끝나지 않는 것에서 시작합니다.

핵심은 두 가지입니다.

  • PDB는 무중단을 보장하는 장치가 아니라, “자발적 중단을 제어”하는 장치입니다. 레플리카/분산이 뒷받침되지 않으면 오히려 유지보수를 막습니다.
  • 종료 로직은 기능 요구사항만큼 중요합니다. SIGTERM 처리, readiness 전환, preStop, grace time 을 함께 설계해야 노드 업데이트가 예측 가능한 시간이 됩니다.

위 체크리스트대로 한 번만 정리해두면, 다음 노드그룹 업데이트부터는 draining 에서 멈추는 일이 눈에 띄게 줄어듭니다.