Search

Kubernetes Descheduler - AZ 균형 배포를 위한 Pod 재배치 전략

왜 Descheduler가 필요한가?

최근 가전 사용 성수기에 접어들면서 HPA 발생 빈도가 잦아졌습니다.
이 상황에서 이슈가 발생했으며, 원인은 Locality LB를 사용하는 상황에서 Pod들의 AZ 불균형 스케줄링으로 인한 것이었고 이는 topologySpreadConstraints를 수정함으로써 해결했습니다.
그러나 Scale Out 상황에는 문제가 없지만, Scale In 상황에서는 여전히 Pod의 AZ 불균형 상황이 발생하는 것을 확인했습니다.

Scale Out 상황

Scale In 상황

topologySpreadConstraints 까지 잘 설정했는데 왜 이런 차이가 발생하는 걸까요?
그 이유는 kubernetes scheduler의 작동 방식에 있습니다.

Scheduler의 작동 방식

쿠버네티스에서 스케줄링은 새로 생성된 Pod 또는 보류 중인 파드를 노드에 바인딩하는 프로세스이며, kube-scheduler라는 쿠버네티스 구성 요소에 의해 수행됩니다.
Kube-scheduler selects an optimal node to run newly created or not yet scheduled (unscheduled) pods.
스케줄러는 Pod를 어디에 스케줄링할 수 있는지, 그리고 어디에 스케줄링할 수 없는지 결정하며, 이 결정은 조건자(predicate)와 우선순위(priority)라고 하는 일련의 규칙으로 구성된 구성 가능한 정책에 따라 결정됩니다.
위 상황으로 다시 생각해보면, Scale Out 상황에는 Pod를 새로 생성하고 스케줄하기 때문에 문제가 없지만,
Pod를 종료하는 과정에는 관여하지 않기 때문에 여전히 Pod의 AZ 불균형한 배치를 만들 수 있는 가능성을 만들게 됩니다.
정리하면 아래 세가지 이유로 인해 Scale In 상황에서 우리가 설정한 Pod의 배치가 잘 되지 않을 수 있습니다.
스케줄러는 Scale In에 관여하지 않음
Pod 종료는 topologySpreadConstraints를 준수하지 않음
한 번 스케줄된 Pod는 노드 pressure등의 노드 장애나 수동으로 옮기지 않는 한 이동하지 않음
graph LR
    A[초기 배치: 균형잡힌 상태] --> B[오토스케일링, 노드 장애, 새 배포 등...]
    B --> C[불균형 상태 누적]
    C --> D[고가용성 저하, 리소스 비효율]
Mermaid
복사
위 같은 문제로 인해 Pod를 재배치시켜줄 descheduler가 필요합니다.

Descheduler란?

스케줄러의 결정은 스케줄링을 위해 새로운 파드가 나타나는 시점에 쿠버네티스 클러스터 상태에 따라 큰 영향을 받습니다.
쿠버네티스 클러스터는 매우 동적이고 시간이 지남에 따라 상태가 변하기 때문에, 다음과 같은 여러 가지 이유로 이미 실행 중인 파드를 다른 노드로 옮기고 싶을 수 있습니다.
일부 노드의 활용도가 낮거나 높은 경우
기존 스케줄링이 더 이상 유효하지 않은 경우
노드에 Taint 또는 Label이 추가되거나 제거되면 pod/node affinity, topologySpreadConstraints 같은 요구 사항이 더 이상 충족되지 않음
일부 노드의 문제로 Pod가 다른 노드로 이동한 경우
클러스터에 새로운 노드가 추가되는 경우
결과적으로 클러스터 내에 우리가 원하지 않는 상태로 스케줄링된 Pod가 발생할 수 있습니다.
Descheduler는 설정한 디스케줄링 정책에 따라 이동 가능한 Pod를 찾아 제거(Evict)합니다.
여기서 또 한가지 중요한 내용이 있는데, descheduler는 Pod를 eviction만 담당하며, rescheduling(재배치)는 관여하지 않고, 다시 기본 scheduler에게 맡긴다는 점입니다.
Please note, in current implementation, descheduler does not schedule replacement of evicted pods but relies on the default scheduler for that.
이렇게 설계한 이유는 명확히 제시하고 있지는 않지만 다음과 같이 추측할 수 있습니다.
기본 쿠버네티스 스케줄러 로직을 그대로 활용
기본 스케줄러 외에 descheduler가 별도의 재배치 로직을 가져가면 혼란을 가져올 수 있음
Descheduler의 복잡성 감소
단일 책임 원칙
제거
재배치
다만 in current implementation 이라고 작성한 것을 보면 추후 재배치까지 담당할 가능성을 아예 배제한 것은 아닌 것으로 보입니다.

주요 deschedule strategy plugins

Name
Extension Point Implemented
Description
Balance
Spreads replicas
Balance
Spreads pods according to pods resource requests and node resources available
Balance
Spreads pods according to pods resource requests and node resources available
Deschedule
Evicts pods violating pod anti affinity
Deschedule
Evicts pods violating node affinity
Deschedule
Evicts pods violating node taints
Balance
Evicts pods violating TopologySpreadConstraints
Deschedule
Evicts pods having too many restarts
Deschedule
Evicts pods that have exceeded a specified age limit
Deschedule
Evicts pods with certain failed reasons and exit codes
우리는 여기서 AZ 균형 배포를 위해 사용하는 TopologySpreadConstraints의 위반 사항을 감지하고 축출할 수 있도록 해주는 RemovePodsViolatingTopologySpreadConstraint 플러그인을 사용합니다.

기본 작동 방식 (CronJob 형태)

graph LR
    A[정기적 실행 CronJob] --> B[클러스터 상태 분석]
    B --> C[위반 사항 감지]
    C --> D[안전한 파드 축출]
    D --> E[Kubernetes Scheduler가 재배치]
Mermaid
복사

RemovePodsViolatingTopologySpreadConstraint의 핵심 동작 Flow

graph TB
    subgraph sg1 ["초기 수집 및 분석"]
        direction LR
        A[CronJob 실행] --> B[파드 수집 및 필터링]
        B --> C[네임스페이스별 그룹화]
        C --> D[TopologySpreadConstraints 분석]
    end
    
    subgraph sg2 ["축출 대상 선정 및 검증"]
        direction LR
        E[불균형 감지] --> F[축출 대상 파드 선정]
        F --> G[안전성 검사]
        G --> H[축출 제한 검사]
    end
    
    subgraph sg3 ["축출 실행 및 재배치"]
        direction LR
        I[PDB 준수 확인] --> J[파드 축출 실행]
        J --> K[Kubernetes Scheduler 재배치]
    end
    
    sg1 --> sg2
    sg2 --> sg3
    
    classDef subgraphStyle fill:#e1f5fe,stroke:#0288d1,stroke-width:2px
    class sg1,sg2,sg3 subgraphStyle
Mermaid
복사

Pod 수집 및 기본 필터링

참고 코드:
Pod 수집을 수행, 필터를 위해 podFilter를 함께 사용:
pods, err := podutil.ListPodsOnNodes(nodes, d.handle.GetPodsAssignedToNodeFunc(), d.podFilter)
Go
복사
podFilter 정보:
podFilter, err := podutil.NewOptions(). WithFilter(handle.Evictor().Filter). WithLabelSelector(pluginArgs.LabelSelector). BuildFilterFunc() if err != nil { return nil, fmt.Errorf("error initializing pod filter function: %v", err) }
Go
복사

topologySpreadConstraints 분석

참고 코드:
Pod 수집을 수행, 필터를 위해 podFilter를 함께 사용:
for namespace := range namespacedPods { klog.V(4).InfoS("Processing namespace for topology spread constraints", "namespace", namespace) if (len(includedNamespaces) > 0 && !includedNamespaces.Has(namespace)) || (len(excludedNamespaces) > 0 && excludedNamespaces.Has(namespace)) { continue } // ...where there is a topology constraint var namespaceTopologySpreadConstraints []topologySpreadConstraint for _, pod := range namespacedPods[namespace] { for _, constraint := range pod.Spec.TopologySpreadConstraints { // 제약조건 분석 로직 } } }
Go
복사

불균형 감지

참고 코드:
// 토폴로지 도메인별 파드 수 계산 constraintTopologies := make(map[topologyPair][]*v1.Pod) // 균형 상태 확인 if topologyIsBalanced(constraintTopologies, tsc) { continue // 이미 균형잡힌 경우 스킵 } // 균형을 위한 파드 이동 계산 d.balanceDomains(podsForEviction, tsc, constraintTopologies, sumPods, nodes)
Go
복사

제한 검사 및 Pod 축출(Eviction)

참고 코드:
for pod := range podsForEviction { // 노드별 제한 확인 if nodeLimitExceeded[pod.Spec.NodeName] { continue } // 기본 축출 가능성 재검사 if !d.podFilter(pod) { continue } // DefaultEvictor 안전성 검사 if d.handle.Evictor().PreEvictionFilter(pod) { // 실제 축출 시도 err := d.handle.Evictor().Evict(ctx, pod, evictions.EvictOptions{...}) // 제한 에러 처리 switch err.(type) { case *evictions.EvictionNodeLimitError: nodeLimitExceeded[pod.Spec.NodeName] = true case *evictions.EvictionTotalLimitError: return nil // 전체 제한 도달시 즉시 종료 } } }
Go
복사

Descheduler Policy 설정

기본 구조

kind: CronJob schedule: "*/10 * * * *" # 실행 주기 deschedulerPolicy: # 전역 제한 설정 maxNoOfPodsToEvictPerNode: 5 maxNoOfPodsToEvictPerNamespace: 15 maxNoOfPodsToEvictTotal: 50 profiles: - name: topology-fix pluginConfig: # 축출 안전성 설정 - name: DefaultEvictor args: evictLocalStoragePods: true evictSystemCriticalPods: false ignorePvcPods: true minReplicas: 2 # 토폴로지 제약조건 처리 - name: RemovePodsViolatingTopologySpreadConstraint args: constraints: - DoNotSchedule namespaces: include: - "production" - "staging" plugins: balance: enabled: - "RemovePodsViolatingTopologySpreadConstraint"
YAML
복사

주요 설정 옵션 상세 분석

설정 분류
설정 키 값
설명
목적
축출 제한 설정
maxNoOfPodsToEvictPerNode
각 노드별 최대 축출 Pod 수
단일 노드의 안정성
maxNoOfPodsToEvictPerNamespace
각 네임스페이스별 최대 축출 Pod 수
네임스페이스별 가용성
maxNoOfPodsToEvictTotal
각 리스케줄링 사이클에서 클러스터 당 최대 축출 Pod 수
클러스터 안정성
DefaultEvictor 설정
evictLocalStoragePods
Local Storage(ex. emptyDir)을 사용하는 Pod 축출 여부
로컬 스토리지 사용하는 Pod 보호(데이터 유실)
evictDaemonSetPods
DaemonSet Pod 축출 여부
데몬셋 축출은 특수 케이스가 아닌 이상 의미 없음
evictSystemCriticalPods
priority 관계 없이 Pod 축출 여부 Warning: kube-system 네임스페이스 포함한 중요한 Pod들이 축출될 수 있음 (coreDNS, CNI 등...)
시스템 중요 Pod 보호
ignorePvcPods
PVC를 사용하는 Pod를 축출 제외시킬지 여부
PVC 사용 Pod 보호
ignorePodsWithoutPDB
PDB가 없는 Pod를 축출 제외시킬지 여부
PDB가 설정되지 않은 Pod 보호
minReplicas
축출 제외시킬 Pod replica 수의 최소 임계치
최소 replica 수 보장
minPodAge
축출 제외시킬 Pod의 생성 경과 시간
최소 Pod가 존재해야하는 시간 보장
TSC plugin 설정
constraints
topologySpreadConstraints의 whenUnsatisfiable 값 중 어떤 것만 분석할지 결정 기본은 hard constraints(DoNotSchedule)만 고려하지만, 다음과 같이 설정도 가능: constraints: - DoNotSchedule - ScheduleAnyway
원하는 균형 범위 선택
namespace filter
• include: 축출 대상 네임스페이스 지정 • exclude: 축출 예외 대상 네임스페이스 지정 주의: include, exclude를 동시에 사용할 수 없음
원하는 네임스페이스 선택

AZ 균형을 위한 deschedulerPolicy

# values-topology-donotschedule.yaml kind: CronJob schedule: "*/5 * * * *" deschedulerPolicy: maxNoOfPodsToEvictPerNamespace: 3 # 단일 네임스페이스에서 축출할 수 있는 최대 파드 수 maxNoOfPodsToEvictTotal: 15 # 전체 클러스터에서 한 번의 descheduler 실행으로 축출할 수 있는 총 파드 수 profiles: - name: topology-donotschedule-fix pluginConfig: - name: DefaultEvictor args: evictLocalStoragePods: true # 로컬 스토리지 사용 파드 축출 허용 evictSystemCriticalPods: false # 시스템 중요 파드 축출 금지 ignorePvcPods: true # PVC 사용 파드 축출 금지 ignorePodsWithoutPDB: true # PDB가 없는 파드는 축출하지 않음 minReplicas: 2 # 최소 2개 replica는 유지 - name: RemovePodsViolatingTopologySpreadConstraint args: constraints: - DoNotSchedule # 하드 제약조건만 처리 namespaces: include: - "ns-cluster" plugins: balance: enabled: - "RemovePodsViolatingTopologySpreadConstraint" cmdOptions: v: 4
YAML
복사

Descheduler 검토 중 궁금증 해결을 위한 코드 파헤치기

Q1. 네임스페이스 필터링이 왜 동작하지 않는가? (RemovePodsViolatingTopologySpreadConstraint 플러그인 기준)

검토 포인트:
네임스페이스 필터를 설정해도, 로그에서 모든 네임스페이스에 대해 수행하는 것으로 기록됨
0.33.0 버전에서는 podFilter에 네임스페이스 필터가 빠져있음:
v0.33.0:
podFilter, err := podutil.NewOptions(). WithFilter(handle.Evictor().Filter). WithLabelSelector(pluginArgs.LabelSelector). BuildFilterFunc() if err != nil { return nil, fmt.Errorf("error initializing pod filter function: %v", err) }
Go
복사
master branch:
podFilter, err := podutil.NewOptions(). WithFilter(handle.Evictor().Filter). WithLabelSelector(pluginArgs.LabelSelector). WithNamespaces(includedNamespaces). WithoutNamespaces(excludedNamespaces). BuildFilterFunc() if err != nil { return nil, fmt.Errorf("error initializing pod filter function: %v", err) }
Go
복사
해당 플러그인은 코드 내 다른 부분에서 네임스페이스 필터 수행:
master branch:
// Balance 함수 for namespace := range namespacedPods { // 모든 네임스페이스에 대해 로그 출력 klog.V(4).InfoS("Processing namespace...", "namespace", namespace) // 실제 필터링은 여기서 if (len(includedNamespaces) > 0 && !includedNamespaces.Has(namespace)) || (len(excludedNamespaces) > 0 && excludedNamespaces.Has(namespace)) { continue // 제외된 네임스페이스는 실제로 스킵됨 } // 실제 처리는 include된 네임스페이스만 }
Go
복사
결론: 로그는 모든 네임스페이스에 대해 출력되지만, 실제 처리는 필터링 네임스페이스만 수행됨

Q2. 여러 Pod가 동시에 축출되는가?

검토 포인트:
같은 topologySpreadConstraints 위반으로 여러 파드가 축출 대상일 때 처리 방식
코드 분석:
// balanceDomains 함수에서 movePods := int(math.Min(smallestDiff, halfSkew)) // 여러 파드를 한 번에 축출 대상에 추가 aboveToEvict := sortedDomains[j].pods[len(sortedDomains[j].pods)-movePods:] for k := range aboveToEvict { podsForEviction[aboveToEvict[k]] = struct{}{} // 여러 파드 동시 추가 } // Balance 함수에서 순차 처리 for pod := range podsForEviction { err := d.handle.Evictor().Evict(ctx, pod, ...) // 각 파드를 순차적으로 축출 }
Go
복사
결론: 여러 파드가 동시에 축출 계획되는 것은 맞고 순차적으로 축출되며, 제한에 걸리면 나머지는 다음 스케줄까지 대기

Q3. Descheduler는 PDB를 준수하는가? 언제 어떻게 검사하는가?

검토 포인트:
PDB 검사 타이밍과 우선 순위가 중요함
코드 분석 (pod.go):
// 축출 순서 1. podFilter (네임스페이스, 레이블 등) 2. PreEvictionFilter (minReplicas, 시스템 파드 등) 3. Evict() 호출 → Kubernetes API 서버 4. API 서버에서 PDB 검사 수행 5. PDB 통과시 실제 파드 삭제 // utils.go의 PDB 확인 함수 func IsPodCoveredByPDB(pod *v1.Pod, lister policyv1.PodDisruptionBudgetLister) (bool, error) { // 네임스페이스의 모든 PDB 조회 list, err := lister.PodDisruptionBudgets(pod.Namespace).List(labels.Everything()) // 파드 레이블과 PDB 셀렉터 매칭 for _, pdb := range list { selector, err := metav1.LabelSelectorAsSelector(pdb.Spec.Selector) if selector.Matches(podLabels) { return true, nil // PDB에 의해 보호됨 } } return false, nil }
Go
복사
API를 이용한 축출은 축출 API를 사용하여 생성된 Eviction 오브젝트로 파드를 정상 종료한다.
API를 이용한 축출은 사용자가 설정한 `PodDisruptionBudgets``terminationGracePeriodSeconds` 값을 준수한다.
API를 사용하여 Eviction 오브젝트를 만드는 것은 정책 기반의 파드 `DELETE` 동작을 수행하는 것과 비슷한 효과를 낸다.
API를 사용하여 축출을 요청하면, API 서버는 인가 확인(admission checks)를 수행하고 다음 중 하나로 응답한다:
200 OK: 축출 요청이 허용되었고, Eviction 서브리소스가 생성되었고, (마치 파드 URL에 DELETE 요청을 보낸 것처럼) 파드가 삭제되었다.
429 Too Many Requests: 현재 설정된 PodDisruptionBudget 때문에 축출이 현재 허용되지 않는다. 또는 API 요청 속도 제한(rate limiting) 때문에 이 응답을 받았을 수도 있다.
500 Internal Server Error: 잘못된 환경 설정(예: 여러 PodDisruptionBudget이 하나의 동일한 파드를 참조함)으로 인해 축출이 허용되지 않는다.
축출하려는 파드가 PodDisruptionBudget이 설정된 워크로드에 속하지 않는다면, API 서버는 항상 200 OK를 반환하고 축출을 허용한다.
결론: 축출 대상의 PDB 검사는 Kubernetes API 서버에서 수행되며, descheduler는 축출 요청만 보냄

Q4. 축출 제한은 언제 적용되는가? (maxNoOf ... ToEvictPerNode)

검토 포인트:
maxNoOfPods... 설정들의 적용 순서와 우선순위
// 적용 순서 nodeLimitExceeded := map[string]bool{} namespaceLimitExceeded := map[string]bool{} for pod := range podsForEviction { // 1. 노드별 제한 확인 if nodeLimitExceeded[pod.Spec.NodeName] { continue } // 2. 네임스페이스별 제한은 더 상위 레벨에서 확인함 // pkg/descheduler/evictions/evictions.go // https://github.com/kubernetes-sigs/descheduler/blob/master/pkg/descheduler/evictions/evictions.go#L511 // 3. 축출 시도 err := d.handle.Evictor().Evict(ctx, pod, ...) // 4. 제한 도달 처리 switch err.(type) { case *evictions.EvictionNodeLimitError: nodeLimitExceeded[pod.Spec.NodeName] = true case *evictions.EvictionTotalLimitError: return nil // 전체 제한 도달시 즉시 종료 } }
Go
복사
결론: 각 플러그인마다 조금씩 다를 수 있지만, 축출 대상 Pod를 순회하다가 Limit 도달하면 바로 종료됨

Q5. 이미 알고있지만, descheduler는 정말 재배치를 하지 않는가?

검토 포인트:
descheduler는 진짜 eviction만 호출하는지 여부
for pod := range podsForEviction { ... err := d.handle.Evictor().Evict(ctx, pod, ...) ... }
Go
복사
결론: 진짜 Evict API만 호출하고 재배치 로직은 없음 → 재배치는 kube-scheduler가 담당한다