문제의 시작

PDB는 이름만 보면 예산을 절대 넘지 않게 막아주는 장치처럼 느껴진다. 하지만 Kubernetes는 더 중요한 pod를 띄우기 위해 낮은 우선순위 pod를 밀어낼 수 있고, drain 상황에서도 기대와 다른 결과가 나올 수 있다. 이 글은 PriorityClass와 PDB를 함께 쓸 때 생기는 보장 착각을 정리한 기록이다.

쿠버네티스를 쓰다 보면 PriorityClass랑 PodDisruptionBudget(PDB)을 동시에 설정하는 경우가 많다. PriorityClass는 “이 파드는 중요하다”라는 우선순위를 주는 장치고, PDB는 “최소 몇 개는 반드시 살아 있어야 한다”는 제약 조건을 주는 장치다.

처음엔 이렇게 생각하기 쉽다.

“PDB를 걸어놨으니까 최소 개수는 보장되겠지?”

하지만 실제 운영에서는 PDB가 있어도 파드가 날아가는 경우가 있다. 특히 스케줄러 프리엠션(preemption) 상황이나 노드 drain 작업에서는 PDB가 절대적으로 보장되지 않는다. 이번 글에서는 왜 이런 일이 발생하는지, 스케줄러 아키텍처와 코드 레벨에서 어떻게 처리하는지, 그리고 실제 예시로 어떻게 확인할 수 있는지를 정리한다.

PriorityClass와 PDB의 역할

PriorityClass

높은 PriorityClass 값을 가진 파드는 스케줄러가 더 중요하게 취급한다. 클러스터 리소스가 부족하면, 높은 PriorityClass 파드를 띄우기 위해 낮은 PriorityClass 파드를 강제로 내쫓는다. 이 과정을 Preemption이라 한다.

PDB (PodDisruptionBudget)

drain, 롤링 업데이트 같은 자발적(voluntary) disruption에서 동시에 너무 많은 파드가 내려가는 걸 막는다. 예를 들어 minAvailable: 2면 최소 두 개는 반드시 살아 있어야 한다. 하지만 노드 장애나 프리엠션 같은 상황은 보장하지 않는다.

스케줄러 코드 안에서 보는 PDB 처리

스케줄러는 사실 PDB를 완전히 무시하지 않는다. pkg/scheduler/framework/plugins/defaultpreemption/default_preemption.go를 보면, 희생자(victim)를 고를 때 PDB를 확인하는 코드가 들어 있다:

// SelectVictimsOnNode finds minimum set of pods on the given node that should be preempted in order to make enough room
// for "pod" to be scheduled.
func (pl *DefaultPreemption) SelectVictimsOnNode(
	ctx context.Context,
	state fwk.CycleState,
	pod *v1.Pod,
	nodeInfo fwk.NodeInfo,
	pdbs []*policy.PodDisruptionBudget) ([]*v1.Pod, int, *fwk.Status) {
	logger := klog.FromContext(ctx)
	var potentialVictims []fwk.PodInfo
	removePod := func(rpi fwk.PodInfo) error {
		if err := nodeInfo.RemovePod(logger, rpi.GetPod()); err != nil {
			return err
		}
		status := pl.fh.RunPreFilterExtensionRemovePod(ctx, state, pod, rpi, nodeInfo)
		if !status.IsSuccess() {
			return status.AsError()
		}
		return nil
	}
	addPod := func(api fwk.PodInfo) error {
		nodeInfo.AddPodInfo(api)
		status := pl.fh.RunPreFilterExtensionAddPod(ctx, state, pod, api, nodeInfo)
		if !status.IsSuccess() {
			return status.AsError()
		}
		return nil
	}
	// As the first step, remove all pods eligible for preemption from the node and
	// check if the given pod can be scheduled without them present.
	for _, pi := range nodeInfo.GetPods() {
		if pl.isPreemptionAllowed(nodeInfo, pi, pod) {
			potentialVictims = append(potentialVictims, pi)
			if err := removePod(pi); err != nil {
				return nil, 0, fwk.AsStatus(err)
			}
		}
	}

즉, 가능하다면 PDB를 존중한다. 하지만 모든 노드에서 PDB를 깨지 않고는 방법이 없는 경우에는, PDB를 위반해서라도 프리엠션을 실행한다.

공식 문서에서도 이렇게 적혀 있다:

PodDisruptionBudget은 지원되지만 보장되지는 않는다. 스케줄러는 가능하면 PDB를 어기지 않으려 하지만, 대체 희생자를 찾을 수 없으면 PDB를 깨고라도 낮은 우선순위 파드를 제거한다.

Kubernetes Docs

Preemption 상황에서 PDB 깨지는 사례

  1. PriorityClass 만들기
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: low-priority
value: 1000
globalDefault: false
description: "낮은 우선순위 파드"

---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: high-priority
value: 100000
globalDefault: false
description: "높은 우선순위 파드"
  1. Deployment + PDB 만들기
apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo-deploy
spec:
  replicas: 3
  selector:
    matchLabels:
      app: demo
  template:
    metadata:
      labels:
        app: demo
    spec:
      priorityClassName: low-priority
      containers:
      - name: nginx
        image: nginx

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: demo-pdb
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: demo
  1. 높은 PriorityClass 파드 투입
apiVersion: v1
kind: Pod
metadata:
  name: important-pod
spec:
  priorityClassName: high-priority
  containers:
  - name: busy
    image: busybox
    command: ["sh", "-c", "sleep 3600"]
    resources:
      requests:
        cpu: "500m"
        memory: "512Mi"

만약 클러스터 자원이 부족하다면, 스케줄러는 demo-deploy 파드 중 하나를 희생시켜 important-pod를 띄운다. 이 과정에서 PDB(minAvailable: 2) 조건이 깨질 수 있다.

kubectl describe pdb demo-pdb를 보면 CurrentHealthy: 2가 아닌 1로 떨어져 있을 수 있다.

Drain 상황에서 PDB 깨지는 사례

이번에는 drain을 실행해보자.

  1. drain-demo Deployment와 PDB
apiVersion: apps/v1
kind: Deployment
metadata:
  name: drain-demo
spec:
  replicas: 3
  selector:
    matchLabels:
      app: drain-demo
  template:
    metadata:
      labels:
        app: drain-demo
    spec:
      priorityClassName: low-priority
      containers:
      - name: nginx
        image: nginx

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: drain-demo-pdb
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: drain-demo
  1. 노드 drain 실행
kubectl drain <노드> --ignore-daemonsets --delete-emptydir-data
  1. 결과 확인

kubectl get pods를 찍으면 파드가 1개만 남는 걸 볼 수도 있다.

kubectl describe pdb drain-demo-pdb 결과:

Status:
  Current Healthy:   1
  Desired Healthy:   2
  Disruptions Allowed: 0

즉, drain 과정에서 PDB 조건을 깨고 파드가 날아간다.

왜 이런 일이 생기는가?

PDB는 drain/업데이트 보호 장치지만 절대적 보장은 아님

drain 자체가 자발적 disruption이긴 해도, 노드를 비우는 작업에서는 PDB 조건을 완전히 지킬 수 없는 경우가 생긴다.

비동기 컨트롤러 구조

PDB 컨트롤러와 스케줄러가 따로 돌기 때문에, 상태 업데이트 타이밍이 어긋나면 스케줄러가 잘못된 정보를 보고 결정을 내린다.

철학적 선택

쿠버네티스는 “서비스 가용성을 최대한 보장하면서도, 더 중요한 파드를 띄우는 게 우선”이라는 철학을 따른다. 그래서 PDB 위반도 허용하는 것이다.

운영 시 주의할 점

  • PDB는 절대 보호막이 아니다. 정말 중요한 워크로드는 PriorityClass를 높이고, PDB는 보조 장치로 써야 한다.

  • Drain 전략 설계가 필요하다. 운영 환경에서는 kubectl drain이 PDB를 무시할 수 있다는 걸 전제로, 배포/업데이트 전략을 짜야 한다.

  • 모니터링은 필수다. kubectl get pdbdisruptionsAllowed, currentHealthy 값을 주기적으로 체크해야 한다.

  • 사전 실습이 필요하다. 일부러 preemption, drain을 발생시켜 서비스 영향도를 미리 확인하는 게 중요하다.

가용성 설계 기준

PDB는 중요한 안전장치지만 절대 보호막은 아니다. Scheduling, preemption, drain, controller update timing을 함께 이해해야 실제 가용성 전략을 세울 수 있다. 중요한 workload일수록 PDB 하나에 기대기보다 priority, replica placement, rollout 전략, monitoring을 같이 설계해야 한다.

  • PriorityClass는 파드의 우선순위를 정하고, 필요하면 낮은 PriorityClass 파드를 제거한다.
  • PDB는 자발적 디스럽션을 제한하지만, preemption이나 drain 같은 상황에서는 보장되지 않는다.
  • 쿠버네티스의 기본 철학은 “중요한 파드를 먼저 띄운다”이며, 이 과정에서 PDB를 깨는 것도 허용한다.
  • 진짜 보호하고 싶은 파드는 PriorityClass + PDB 조합으로 설계하고, drain 전략까지 함께 고민해야 한다.