我司在线上的一个集群中对外提供了 Kubernetes 的 HPA Feature,这货一直工作好好但有几次出现了弹性失效的情况。具体的表象为当服务 Scale Down 的时候就再也上不去,与此同时存在大量的 Terminating Pod 不过这是官方已知的一个 issue Pod Stuck on Terminating #51835 最后的结果就是业务出现严重的堆积,已有实例过载。

问题排查

之前只读过一到两次的 HPA 部分的代码,所以一开始我猜测这部分是因为 Terminating 的 Pod 计入了阈值的分母导致数值稀释引发的。但再读代码后确认这块是排除的(采集 Pod 指标时就剔除了这部分的数据),于是只好再读代码。

HPA 代码分析

版本:v1.9.2 5fa2db2bd4,以下以 CPU HPA 为例

HPA 在初始化后调用 Run 函数持续运行

// Run begins watching and syncing.
func (a *HorizontalController) Run(stopCh <-chan struct{}) {
    defer utilruntime.HandleCrash()
    defer a.queue.ShutDown()

    glog.Infof("Starting HPA controller")
    defer glog.Infof("Shutting down HPA controller")

    if !controller.WaitForCacheSync("HPA", stopCh, a.hpaListerSynced) {
        return
    }

    // start a single worker (we may wish to start more in the future)
    // 这边调用的 worker 是主体执行函数
    go wait.Until(a.worker, time.Second, stopCh)

    <-stopCh
}

而在 worker 中循环调用 processNextWorkItem 来处理 HPA 检查即主逻辑。

// 主逻辑入口
func (a *HorizontalController) processNextWorkItem() bool {
    // 每次从 ratelimit queue 里取一个 key。实际格式为 `namespace/hpa-name`
    key, quit := a.queue.Get()
    if quit {
        return false
    }
    defer a.queue.Done(key)
    err := a.reconcileKey(key.(string))
    if err == nil {
        // don't "forget" here because we want to only process a given HPA once per resync interval
        return true
    }
    // 重入 queue
    a.queue.AddRateLimited(key)
    utilruntime.HandleError(err)
    return true
}


func (a *HorizontalController) reconcileKey(key string) error {
    // 拆分上面取到的 Queue Key
    namespace, name, err := cache.SplitMetaNamespaceKey(key)
    if err != nil {
        return err
    }
    // 取 HPA Object
    hpa, err := a.hpaLister.HorizontalPodAutoscalers(namespace).Get(name)
    if errors.IsNotFound(err) {
        glog.Infof("Horizontal Pod Autoscaler has been deleted %v", key)
        return nil
    }
    // 根据 HPA Spec 处理伸缩
    return a.reconcileAutoscaler(hpa)
}

func (a *HorizontalController) reconcileAutoscaler(hpav1Shared *autoscalingv1.HorizontalPodAutoscaler) error {
    // 省略一些 DeepCopy 跟 Parse
    // 根据给定资源对象获取 Scale Object
    scale, targetGR, err := a.scaleForResourceMappings(hpa.Namespace, hpa.Spec.ScaleTargetRef.Name, mappings)
    if err != nil {
        a.eventRecorder.Event(hpa, v1.EventTypeWarning, "FailedGetScale", err.Error())
        setCondition(hpa, autoscalingv2.AbleToScale, v1.ConditionFalse, "FailedGetScale", "the HPA controller was unable to get the target's current scale: %v", err)
        a.updateStatusIfNeeded(hpaStatusOriginal, hpa)
        return fmt.Errorf("failed to query scale subresource for %s: %v", reference, err)
    }
    // ...
    else {
        // 计算期望要的实例数
        metricDesiredReplicas, metricName, metricStatuses, metricTimestamp, err = a.computeReplicasForMetrics(hpa, scale, hpa.Spec.Metrics)
        // 设置 desiredReplicas
                // 根据 Mix Max 调整
                desiredReplicas = a.normalizeDesiredReplicas(hpa, currentReplicas, desiredReplicas)
    }
    // Scale or nothing
}

func (a *HorizontalController) computeReplicasForMetrics(hpa *autoscalingv2.HorizontalPodAutoscaler, scale *autoscalingv1.Scale,
    // 多个依次计算期望值
    for i, metricSpec := range metricSpecs {
        switch metricSpec.Type {
            case autoscalingv2.ObjectMetricSourceType:
            ...
            case autoscalingv2.PodsMetricSourceType:
            ...
            case autoscalingv2.ResourceMetricSourceType:
            if metricSpec.Resource.TargetAverageValue != nil {
                ...
            } else {
                /// 检查 TargetAverageUtilization 设置
                if metricSpec.Resource.TargetAverageUtilization == nil { ... }
                ...
                // 计算实例数
                replicaCountProposal, percentageProposal, rawProposal, timestampProposal, err = a.replicaCalc.GetResourceReplicas(currentReplicas, targetUtilization, metricSpec.Resource.Name, hpa.Namespace, selector)
            }
            default:
            ...
        }
    }
    setCondition...
}

GetResourceReplicas

func (c *ReplicaCalculator) GetResourceReplicas(currentReplicas int32, targetUtilization int32, resource v1.ResourceName, namespace string, selector labels.Selector) (replicaCount int32, utilization int32, rawUtilization int64, timestamp time.Time, err error) {
        // 获取 Metrics 指标
        // 根据 HPA Selector 获取 POD 列表
        for _, pod := range podList.Items {
            podSum := int64(0)
            for _, container := range pod.Spec.Containers {
                // 累加 POD Container Reuqests
            }
            requests[pod.Name] = podSum
            // 如果不是 Running 或则 Ready 的 POD 直接跳过。**但 Request 照加**
            if pod.Status.Phase != v1.PodRunning || !podutil.IsPodReady(&pod) {
                // save this pod name for later, but pretend it doesn't exist for now
                unreadyPods.Insert(pod.Name)
                delete(metrics, pod.Name)
                continue
            }
            if _, found := metrics[pod.Name]; !found {
                // save this pod name for later, but pretend it doesn't exist for now
                missingPods.Insert(pod.Name)
                continue
            }
        }
        // 撮合计算各指标的使用率,返回 使用率、使用量、平均用量
        // 这里的计算实际会忽略 NonReady 跟 NonRunning 的 POD
        usageRatio, utilization, rawUtilization, err := metricsclient.GetResourceUtilizationRatio(metrics, requests, targetUtilization)
        if err != nil {
            return 0, 0, 0, time.Time{}, err
        }
        // 当 Request 使用率 > 1 且有 unready 的 Pod
        rebalanceUnready := len(unreadyPods) > 0 && usageRatio > 1.0
        if !rebalanceUnready && len(missingPods) == 0 {
            // 没 unready 且都有 metrics
            // 使用率 < 1 且都有 metrics

            // 使用率在容忍范围内
            if math.Abs(1.0-usageRatio) <= c.tolerance {
            return currentReplicas, utilization, rawUtilization, timestamp, nil
            }
            // 期望实例数 使用率 * Ready Pod 向上取整
            return int32(math.Ceil(usageRatio * float64(readyPodCount))), utilization, rawUtilization, timestamp, nil
        }

        if len(missingPods) > 0 {
            if usageRatio < 1.0 {
                // 可能缩容,缺的 metrics = request 
            } else if usageRatio > 1.0 {
                // 可能扩容,缺的 metrics = 0 
            }
        }
        if rebalanceUnready {
            // 可能扩容,缺的 metrics = 0
        }

        // 重算使用率
        // 这里把 NonReady 跟 NonRunning 补齐也就让 GetResourceUtilizationRatio 一起算了
        newUsageRatio, _, _, err := metricsclient.GetResourceUtilizationRatio(metrics, requests, targetUtilization)
        // 容忍范围内或新旧使用率一升一降
        if math.Abs(1.0-newUsageRatio) <= c.tolerance || (usageRatio < 1.0 && newUsageRatio > 1.0) || (usageRatio > 1.0 && newUsageRatio < 1.0) {
            // 不伸缩
            return currentReplicas, utilization, rawUtilization, timestamp, nil
        }
        // 期望实例数 新使用率 * POD 数 向上取整
        return int32(math.Ceil(newUsageRatio * float64(len(metrics)))), utilization, nil
}

func GetResourceUtilizationRatio(metrics PodMetricsInfo, requests map[string]int64, targetUtilization int32) (utilizationRatio float64, currentUtilization int32, rawAverageValue int64, err error) {
// 根据 metrics 进行循环
}

结论

当 Deployment 出现 POD Terminating 时可能导致 GetResourceUtilizationRatioGetResourceReplicas metrics 补齐前后出现 usageRatio 一个大于 1 一个小于 1 导致不进行伸缩。所以目前给到业务的建议是重要业务不要建 HPA minReplicas 调整过低,并同期对 HPA 实例异常状况进行监控告警。