Skip to main content

背景介绍

在AI算力系统中,为了提高资源利用率,我们通常会将集群节点划分为不同的资源池:

  • 推理资源池:专门用于部署在线推理服务,保证推理服务的稳定性和响应速度
  • 训练资源池:专门用于运行离线训练任务
  • 混部资源池:同时运行在线推理服务和离线训练任务,实现资源的混合部署

调度策略

在线推理服务在部署时采用以下调度策略:

  1. 高优先级服务:优先调度到推理资源池,资源不足时溢出到混部资源池
  2. 中低优先级服务:优先调度到混部资源池,资源不足时溢出到推理资源池

这种调度策略确保了高优服务的稳定性,同时通过混部资源池提高了整体资源利用率。

缩容需求

当在线推理服务需要缩容时,我们希望实现以下目标:

  1. 优先缩容混部资源池中的Pod:释放混部资源池的资源,让给离线训练任务使用
  2. 保留推理资源池中的Pod:保证推理服务的稳定性,避免影响核心业务

核心问题Kubernetes原生的缩容逻辑是如何工作的?我们如何精确控制缩容时删除哪些Pod

源码分析

1. Pod删除排序机制

Kubernetes在进行ReplicaSetDeployment缩容时,需要决定删除哪些Pod。这个决策逻辑在pkg/controller/controller_utils.go文件的ActivePodsWithRanks结构中实现。

1.1 核心排序规则

根据源码controller_utils.go#780-812Pod删除的优先级排序遵循以下规则(按优先级从高到低):

  1. 未分配节点的Pod优先删除:未被调度到节点的Pod会优先于已分配节点的Pod被删除
  2. 按Pod阶段排序Pending < Unknown < Running,即Pending状态的Pod最先被删除
  3. 未就绪的Pod优先删除:未ReadyPod会优先于ReadyPod被删除
  4. Pod删除成本(pod-deletion-cost)成本值越低的Pod越优先被删除
  5. 节点上Pod密度:同一节点上相同ReplicaSetPod越多,该节点上的Pod越优先被删除(避免单点故障)
  6. Ready时间Ready时间越短的Pod越优先被删除
  7. 容器重启次数:重启次数越多的Pod越优先被删除
  8. 创建时间:创建时间越晚的Pod越优先被删除
// Source: pkg/controller/controller_utils.go#780-812
// ActivePodsWithRanks is a sortable list of pods and a list of corresponding
// ranks which will be considered during sorting.
type ActivePodsWithRanks struct {
Pods []*v1.Pod
Rank []int
Now metav1.Time
}

// Less compares two pods with corresponding ranks and returns true if the first
// one should be preferred for deletion.
func (s ActivePodsWithRanks) Less(i, j int) bool {
// 1. Unassigned < assigned
if s.Pods[i].Spec.NodeName != s.Pods[j].Spec.NodeName &&
(len(s.Pods[i].Spec.NodeName) == 0 || len(s.Pods[j].Spec.NodeName) == 0) {
return len(s.Pods[i].Spec.NodeName) == 0
}

// 2. PodPending < PodUnknown < PodRunning
if podPhaseToOrdinal[s.Pods[i].Status.Phase] != podPhaseToOrdinal[s.Pods[j].Status.Phase] {
return podPhaseToOrdinal[s.Pods[i].Status.Phase] < podPhaseToOrdinal[s.Pods[j].Status.Phase]
}

// 3. Not ready < ready
if podutil.IsPodReady(s.Pods[i]) != podutil.IsPodReady(s.Pods[j]) {
return !podutil.IsPodReady(s.Pods[i])
}

// 4. lower pod-deletion-cost < higher pod-deletion cost
if utilfeature.DefaultFeatureGate.Enabled(features.PodDeletionCost) {
pi, _ := helper.GetDeletionCostFromPodAnnotations(s.Pods[i].Annotations)
pj, _ := helper.GetDeletionCostFromPodAnnotations(s.Pods[j].Annotations)
if pi != pj {
return pi < pj // 成本低的优先删除
}
}

// 5-8. 其他规则...
}

1.2 Pod删除成本(pod-deletion-cost)

pod-deletion-costKubernetes提供的一个annotation,用于控制Pod的删除优先级。源码位于pkg/apis/core/helper/helpers.go#489-506

// GetDeletionCostFromPodAnnotations returns the integer value of pod-deletion-cost.
// Returns 0 if not set or the value is invalid.
func GetDeletionCostFromPodAnnotations(annotations map[string]string) (int32, error) {
if value, exist := annotations[core.PodDeletionCost]; exist {
// values that start with plus sign (e.g, "+10") or leading zeros (e.g., "008") are not valid.
if !validFirstDigit(value) {
return 0, fmt.Errorf("invalid value %q", value)
}

i, err := strconv.ParseInt(value, 10, 32)
if err != nil {
return 0, err
}
return int32(i), nil
}
return 0, nil
}

关键特性

  • Annotation键名:controller.kubernetes.io/pod-deletion-cost
  • 值类型:int32范围的整数(-21474836482147483647
  • 默认值:0(未设置时)
  • 删除规则:成本值越低,越优先被删除
  • 无效值会被忽略,视为0

2. ReplicaSet缩容流程

ReplicaSet控制器中,缩容流程如下(源码位于pkg/controller/replicaset/replica_set.go#869-878):

func getPodsToDelete(filteredPods, relatedPods []*v1.Pod, diff int) []*v1.Pod {
// No need to sort pods if we are about to delete all of them.
if diff < len(filteredPods) {
podsWithRanks := getPodsRankedByRelatedPodsOnSameNode(filteredPods, relatedPods)
sort.Sort(podsWithRanks) // 按照ActivePodsWithRanks.Less规则排序
reportSortingDeletionAgeRatioMetric(filteredPods, diff)
}
return filteredPods[:diff] // 返回排序后前diff个Pod进行删除
}

3. Deployment滚动更新中的缩容

Deployment滚动更新过程中,旧ReplicaSet的缩容逻辑位于pkg/controller/deployment/rolling.go#190-236

func (dc *DeploymentController) scaleDownOldReplicaSetsForRollingUpdate(
ctx context.Context, allRSs []*apps.ReplicaSet, oldRSs []*apps.ReplicaSet,
deployment *apps.Deployment) (int32, error) {

maxUnavailable := deploymentutil.MaxUnavailable(*deployment)
minAvailable := *(deployment.Spec.Replicas) - maxUnavailable
availablePodCount := deploymentutil.GetAvailableReplicaCountForReplicaSets(allRSs)

if availablePodCount <= minAvailable {
return 0, nil // 不能缩容,会违反可用性要求
}

// 按创建时间排序旧的ReplicaSet
sort.Sort(controller.ReplicaSetsByCreationTimestamp(oldRSs))

// 依次缩容旧的ReplicaSet
for _, targetRS := range oldRSs {
if totalScaledDown >= totalScaleDownCount {
break
}
// 缩容逻辑...
}

return totalScaledDown, nil
}

实现方案

基于以上源码分析,我们可以通过pod-deletion-cost注解来精确控制混部调度场景下的Pod删除优先级。

方案一:手动设置删除成本

对推理服务缩容之前,获取该服务的所有Pod,根据Pod绑定的节点名称,获取节点对应的标签信息。并根据用途标签设置Podpod-deletion-cost注解,只需要设置混部节点的Pod其删除成本为负数(如-100)即可。然后再执行缩容。

方案二:自动化设置删除成本

通过Controller监听Pod创建和修改事件,根据Pod实际调度到的节点资源池动态调整Pod的删除成本注解:

// Pod Controller
func (c *PodController) syncPod(pod *corev1.Pod) error {
// 只处理已调度的Pod
if pod.Spec.NodeName == "" {
return nil
}

// 获取Pod所在节点
node, err := c.nodeLister.Get(pod.Spec.NodeName)
if err != nil {
return err
}

// 获取节点的资源池标签
resourcePool := node.Labels["node.usage"]

// 根据资源池设置删除成本
var deletionCost string
switch resourcePool {
case "inference":
// 推理资源池:高删除成本
deletionCost = "1000"
case "hybrid":
// 混部资源池:低删除成本
deletionCost = "-100"
default:
// 默认资源池
deletionCost = "0"
}

// 检查是否需要更新
currentCost := pod.Annotations["controller.kubernetes.io/pod-deletion-cost"]
if currentCost == deletionCost {
return nil // 无需更新
}

// 更新Pod annotation
podCopy := pod.DeepCopy()
if podCopy.Annotations == nil {
podCopy.Annotations = make(map[string]string)
}
podCopy.Annotations["controller.kubernetes.io/pod-deletion-cost"] = deletionCost

_, err = c.kubeClient.CoreV1().Pods(pod.Namespace).Update(
context.TODO(), podCopy, metav1.UpdateOptions{},
)

return err
}

参考资料