2 min read
一次 Kubernetes HPA 引发的故障

我司在线上的一个集群中对外提供了 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 实例异常状况进行监控告警。