PDB 란
PDB(PodDisruptionBudget) = 자발적 중단(voluntary disruption) 으로 동시에 죽일 수 있는 파드 수의 상한을 선언하는 가드레일이다. selector 가 매칭하는 파드들에 적용된다.
- voluntary disruption =
kubectl drain, cluster autoscaler scale-down, 노드 그룹 업그레이드. 전부 eviction API(pods/eviction서브리소스) 경유. PDB는 eviction API에서 강제된다. - involuntary disruption = 하드웨어 장애, 커널 패닉, OOM kill. PDB가 보호하지 않는다.
minAvailable vs maxUnavailable
- 둘 중 하나만 지정 가능. 정수 또는 퍼센트.
minAvailable: N= healthy 파드가 항상 N개 이상이어야 한다. 퍼센트는 올림.maxUnavailable: N= unavailable 파드가 N개를 초과할 수 없다. 퍼센트는 내림.- 반올림 함정 =
replicas: 1+minAvailable: 1%도 올림으로 1 → 영구 블록. 단일 replica + PDB는 거의 항상 데드락.
동작 원리
PDB 는 두 컴포넌트가 함께 다룬다.
- eviction handler (
eviction.go, kube-apiserver) =pods/eviction요청을 처리한다. drain 이 이걸 호출한다. - disruption controller (
disruption.go, kube-controller-manager) = 모든 PDB 의.status를 상시 재계산한다. - 축출 차단 여부는 disruption controller 가 써둔 status 숫자
disruptionsAllowed로 정해지고, eviction handler 는 그 숫자만 읽는다.SyncFailed같은 condition 은 apiserver 가 보지 않는다.
drain 은 파드를 DELETE 하지 않고 pods/eviction 서브리소스를 호출한다. API server 는 PDB status 의 세 숫자로 축출 가부를 결정한다.
currentHealthy= Ready 상태 파드 수desiredHealthy= 요구되는 최소 healthy 수 (minAvailable또는expectedPods − maxUnavailable)expectedPods= 컨트롤러의 desired replica 수- 판정식:
disruptionsAllowed = currentHealthy − desiredHealthy> 0이면 허용 후 1 깎음. 교체 파드가 Ready 되면 다시 채워짐.<= 0이면 HTTP 429 +Cannot evict pod as it would violate the pod's disruption budget.
/scale 서브리소스로 expectedPods 계산
disruption controller 는 파드의 ownerReferences 를 타고 올라가 컨트롤러의 /scale 서브리소스에서 spec.replicas 를 읽어 expectedPods 를 만든다. /scale = autoscaling/v1 Scale 의 얇은 표준 인터페이스(spec.replicas, status.replicas/selector). kubectl scale, HPA 도 같은 엔드포인트를 쓴다.
/scale구현 = Deployment, ReplicaSet, StatefulSet, ReplicationController, scale subresource 켠 CRD.- 미구현 = Job, CronJob, DaemonSet. Job은
parallelism/completions이지 replica 가 아니고, DaemonSet 수는 노드 수에서 파생되기 때문. - selector 가 Job 파드를 잡으면 →
getScaleController가/scale조회 실패(jobs.batch ... does not implement the scale subresource) → PDB sync 전체 실패 →failSafe가 condition 을DisruptionAllowed: False / SyncFailed로 마킹하고 동시에disruptionsAllowed를 0 으로 강제한다. - 그 0 때문에 그 PDB가 고르는 모든 파드(정상 Deployment 파드 포함)의 축출이 429 로 거부된다.
- 비대칭 = Completed Job 파드 자체는 terminal 이라 PDB 검사를 우회(
canIgnorePDB)해 자유롭게 쫓겨난다. 그러나 selector 에 포착돼 있다는 사실만으로 PDB status 계산을 깨뜨려 형제 Deployment 파드들을 가둔다.
unhealthyPodEvictionPolicy (1.27 stable)
IfHealthyBudget(기본) = PDB 가 현재 건강할 때만 (currentHealthy >= desiredHealthy) 불건강 파드 축출 허용. PDB 가 이미 깨져 있으면 CrashLoop 파드조차 못 빼서 완전 교착.AlwaysAllow= 불건강(Ready 아님) 파드는 PDB 무시하고 항상 축출 허용. 만성 불건강 워크로드 교착 해소용.
eviction(drain)만 막는다 — 롤아웃은 아님
PDB 는 eviction API 경유 disruption 만 막는다. Deployment 롤아웃(파드 템플릿·이미지 변경)은 컨트롤러가 옛 파드를 직접 DELETE 하지 eviction API 를 안 쓴다 → PDB 가 게이트하지 않는다. 롤아웃 안전성은 Deployment 자신의 strategy.rollingUpdate(maxSurge/maxUnavailable)가 책임진다.
- 노드 drain·eviction = eviction API → PDB 적용.
- Deployment 롤아웃 = 직접
DELETE→ PDB 미적용,strategy가 지배. - 귀결:
maxUnavailable: 0PDB 는 롤아웃은 통과시키고 drain 만 deadlock 시킨다. 평상 배포(롤아웃)에선 결함이 안 드러나고 노드 업그레이드(drain)에서야 터진다.
EKS 노드 롤링 업그레이드
- managed node group update 단계 = setup → scale up(새 버전 노드 증설) → upgrade(구 노드 cordon + eviction API 축출) → scale down.
- 축출 단계 = 약 5초 간격 재시도, 노드당 ~15분 윈도우. 윈도우 안에 PDB 가 안 풀리면
PodEvictionFailure로 실패하거나,force옵션이면 eviction 대신 강제DELETE로 PDB 무시 후 진행. force= PDB 우회 (가용성/데이터 리스크). 최후 수단.- 노드그룹의
updateConfig.maxUnavailable[Percentage]는 노드 동시 처리량이지 파드 PDB 와 별개 노브다. - 한 노드의 drain 은 그 위에 얹힌 모든 파드의 PDB 중 가장 빡빡한 하나에 묶인다. PDB가 풀리지 않는 워크로드 파드가 노드에 하나만 있어도 그 노드 전체가 ~15분 잡힌다.
이 흐름이 topology 로 막혀 노드그룹 업그레이드가 ~4시간 걸린 사례: 06. K8s Cluster Maintenance > EKS 노드 그룹 업그레이드 지연 사례
진단
# 전체 PDB 한눈에 — ALLOWED DISRUPTIONS 가 0 인 PDB 가 범인
kubectl get pdb -A -o wide
# 상세 + status condition
kubectl describe pdb <pdb> -n <ns>
# selector 가 의도치 않은 파드(특히 Job)를 잡는지
kubectl get pods -n <ns> -l <selector> -o wide --show-labels
kubectl get pod <pod> -n <ns> -o jsonpath='{.metadata.ownerReferences}{"\n"}'DisruptionAllowed: False 의 reason / message 로 시나리오를 분류한다.
- 시나리오 1: 영구 블록 =
replicas <= minAvailable또는maxUnavailable: 0. ALLOWED DISRUPTIONS 가 계속 0. → PDB 값 자체가 잘못됨.maxUnavailable: 0은 drain 에서 빠져나올 길이 없다 — evict 하려면currentHealthy > expectedPods(여분 파드)가 필요한데 drain 은 여분을 미리 안 만든다(ReplicaSet 은 정확히replicas유지,maxSurge는 롤아웃 전용). 여분이 있어야 evict 하고 여분은 evict 해야 생기는 chicken-and-egg. - 시나리오 2: 교체 Ready 직렬화 = ALLOWED DISRUPTIONS 가 0 ↔ 양수로 깜빡임. 마이크로서비스 startup(JVM warmup, readinessProbe initialDelay, 이미지 풀) 이 느릴 때 흔함. 매 축출이 교체 파드 Ready 까지 5초 단위로 재시도되며 직렬화된다.
- 시나리오 3: SyncFailed (selector 가 Job 파드 매칭) = reason
SyncFailed, messagedoes not implement the scale subresource. PDB 가 사실상 고장난 상태. 흔히 db-migration / Helm hook Job 이 Deployment 와 같은app:라벨을 공유해 발생. Completed Job 파드가 남아 있어도 동일하게 깨진다.
429(eviction) 와 FailedScheduling 은 다른 사건이다. 429 는 “evict 를 못 한다”(PDB가 막음), FailedScheduling 은 “새 파드를 놓을 자리가 없다”(스케줄러가 거부). 시나리오 2 에서 교체 파드가 Ready 안 되는 이유가 느린 startup 이 아니라 아예 스케줄이 안 돼
Pending인 경우가 있다 — 흔히 topologySpreadConstraints 가 원인이다. Pending 인 교체본은currentHealthy를 못 채워disruptionsAllowed를 0 으로 묶고, 그게 다음 노드의 eviction 까지 전염시킨다(직렬이어도 노드를 건너 차단).
해결
- 시나리오 1 =
minAvailable < replicas로 낮추거나maxUnavailable로 전환. 단일 replica 면 replicas ≥ 2 + topologySpreadConstraints. - 시나리오 2 = 앱 startup 단축,
readinessProbe.initialDelaySeconds축소, replicas 늘려disruptionsAllowed버퍼 확보,maxUnavailable로 동시 축출 허용. 새 노드 Ready 후 drain 시작되도록 surge 용량 확보. - 시나리오 3 = selector 가 Job 파드를 안 잡게 한다. 두 방식이 있다.
exclusion — selector 에서 Job 라벨을 배제. Job 파드는 자동으로 batch.kubernetes.io/job-name(1.27+ prefix) 또는 legacy job-name 라벨을 단다. 버전 안 타게 둘 다 배제:
spec:
selector:
matchLabels:
app: foo
matchExpressions:
- { key: batch.kubernetes.io/job-name, operator: DoesNotExist }
- { key: job-name, operator: DoesNotExist }inclusion — Deployment 파드에 고유 라벨을 주고 selector 가 그것만 잡게 한다. 라벨은 반드시 파드 템플릿(spec.template.metadata.labels)에 둔다 — PDB 는 파드를 selector 하므로 오브젝트 metadata 에만 두면 못 본다. Job 파드는 그 값이 없어 자동 제외:
spec:
selector:
matchLabels:
app: foo
app.kubernetes.io/component: application트레이드오프 = exclusion 은 PDB 객체만 바뀌어 무중단, inclusion 은 파드 템플릿이 바뀌어 일회성 롤링 재시작을 치른다. 의미는 inclusion 이 깔끔하지만 재시작 회피가 우선이면 exclusion.
- 재발 방지 = Job 에
ttlSecondsAfterFinished추가해 Completed 파드가 자동 GC 되게 한다. - 만성 불건강 파드의 교착 =
unhealthyPodEvictionPolicy: AlwaysAllow. force업그레이드 = PDB 무시 강제DELETE. 최후 수단.
영향 PDB 만 중앙 패치 (in-place)
PDB 는 standalone policy/v1 객체다. 수정해도 워크로드는 재시작·중단되지 않는다. policy/v1 (1.21+, v1beta1 은 1.25 제거) 에서 spec(selector 포함) 은 in-place 변경 가능. 그래서 차트 fix 의 전파(각 팀 다음 평상 배포)와 별개로 라이브 PDB 만 중앙에서 일괄 패치해 업그레이드를 즉시 unblock 할 수 있다.
PATCH='{"spec":{"selector":{"matchExpressions":[
{"key":"batch.kubernetes.io/job-name","operator":"DoesNotExist"},
{"key":"job-name","operator":"DoesNotExist"}
]}}}'
SEL='.items[]
| select(any(.status.conditions[]?;
.type=="DisruptionAllowed" and .status=="False"
and (.reason=="SyncFailed"
or ((.message // "") | test("scale subresource")))))
| "\(.metadata.namespace) \(.metadata.name)"'
# 1) 백업 (롤백용)
kubectl get pdb -A -o yaml > pdb-backup-$(date +%F-%H%M).yaml
# 2) 영향 PDB 목록 검수
kubectl get pdb -A -o json | jq -r "$SEL"
# 3) dry-run
kubectl get pdb -A -o json | jq -r "$SEL" | while read -r ns name; do
kubectl patch pdb "$name" -n "$ns" --type=merge -p "$PATCH" --dry-run=server
done
# 4) 실제 패치
kubectl get pdb -A -o json | jq -r "$SEL" | while read -r ns name; do
echo ">> patching $ns/$name"
kubectl patch pdb "$name" -n "$ns" --type=merge -p "$PATCH"
done
# 5) 검증: 출력이 비면 회복
kubectl get pdb -A -o json | jq -r "$SEL"
kubectl get pdb -A -o wide--type=merge선택 이유 = JSON merge patch 는spec.selector객체에서 내가 준matchExpressions키만 교체하고 기존matchLabels는 그대로 둔다. 전체 selector 를 덮어쓰지 않으니 안전·idempotent.- selector 만 좁히면 Completed Job 파드를 안 지워도 sync 회복된다. controller 가 더는 Job 의
/scale을 안 묻기 때문.
Drift 주의 (Spinnaker/Helm 등)
ArgoCD/Flux 와 달리 Spinnaker 는 pipeline trigger 시에만 apply 하고 지속 reconcile 하지 않는다. 중앙 패치는 해당 워크로드가 낡은 차트로 다음 파이프라인을 돌리기 전까지만 유지된다. 차트 fix 가 평상 배포로 전파될 때까지의 bridge 다. 영구 fix 는 차트 측에서 닫는다.
주의점
- 단일 replica + PDB = 거의 항상 데드락. replicas ≥ 2 가 PDB 의 전제.
- 퍼센트 반올림 방향이 다르다.
minAvailable올림,maxUnavailable내림. - selector 는 좁게. 동일 라벨을 공유하는 Job/CronJob 이 끼면 PDB 전체 sync 가 실패해 정상 Deployment 파드까지 함께 막힌다.
- PDB 는 voluntary 만 막는다. involuntary(노드 장애·OOM)는 보호 못한다.
- 한 워크로드의 잘못된 PDB 하나가 해당 노드 전체 drain 을 ~15분 묶어 클러스터 전체 업그레이드를 늦춘다.
- Helm 차트에서 PDB 에
minAvailable: 0/maxUnavailable: null을 주입하면 Go 템플릿상0·null이 falsy →{{- if }}가드에 걸려 두 필드 다 렌더 안 됨 → 빈 spec PDB(no-op). cluster-autoscaler 공식 차트가 이 케이스다.
References
- Specifying a Disruption Budget for your Application | Kubernetes
- PodDisruptionBudget API v1 | Kubernetes
- Disruptions — PDB does not limit Deployment rollouts | Kubernetes
- Unhealthy Pod Eviction Policy | Kubernetes
- API Concepts — Subresources | Kubernetes
- Update a managed node group | Amazon EKS User Guide
- Deprecated API Migration Guide (policy/v1beta1 removed in 1.25) | Kubernetes
- Jobs — automatic pod labels | Kubernetes
- TTL after finished for Jobs | Kubernetes
- Update API Objects in Place Using kubectl patch | Kubernetes
- Pod Disruption Budgets: Pitfalls, Evictions & Kubernetes Upgrades | chkk.io
- PodDisruptionBudget confused: Labels matter | TBNL
- eviction.go
checkAndDecrement@v1.33.7 | kubernetes - disruption.go
getScaleController@v1.33.7 | kubernetes