问题发现

  1. k8s 1.21, node使用了某国产化服务器,其CPU也是国产的,numa node数量多达8个
  2. kubelet cpumanager 启用了best-effort policy
  3. 某kubevirt vm挂载sriov网卡数量大于5后,vm无法正常创建,具体表现为vm对应的pod持续卡在pending状态

初步排查

  1. 当发现有pod卡在pending状态时,根据k8s pod的生命周期流转过程,第一时间检查调度情况。以往情况下,pod卡在pending状态下通过kubectl describe,可以快速看到其缺乏哪种资源导致pending。
  2. 但是本次发现的问题比较奇怪:describe pod看不到任何资源缺失的问题。
  3. 进一步,查看pod的yaml,发现其nodeName并不是空的,而是已经被分配的节点。
  4. 此时结合k8s创建pod的核心流程和各组件的分工,可以推测:kube-scheduler运行正常,为pod填充了nodeName字段,但是kubelet未进行后续流程。在查看kube-scheduler和对应node上kubelet的日志后,推测得到了印证。
  5. 那么问题就转化成了:为什么kubelet不像以往一样发现自己节点负责的pod,拉起容器,修改pod状态呢?
  6. 此时观察kubelet的状态:通过top可以看到kubelet的CPU使用率高达数百,名列前茅,而通常在该环境负载状态下的kubelet CPU使用率不会如此持续走高。所以推测kubelet是在进行大量持续的运算。
  7. 进一步观察kubelet日志,可以发现虽然有日志,但是缺少了一些和其核心流程相关的日志,能打出来的都是一些非主进程的go routine里的日志。这一步的观察比较依赖对kubelet代码的了解和kubelet日志的熟悉程度。
  8. 综合上面的分析,推测是创建包含6-8个sriov网卡的kubevirt vm后,kubelet主进程被卡在了大量的计算中,导致该k8s节点的主工作流程被阻塞。经过测试,后续向该节点创建的更多pod,都同样被卡在了pending阶段,且nodeName不为空。

定位及分析

另外其他同事反馈,同样的挂载了6-8个sriov网卡的kubevirt vm,在之前numa为2的环境中没有该问题,但是一旦切换到该numa node为8的环境,就会出现该问题。

由于我和组里同事之前做过kubelet topology manager/cpu manager相关的代码阅读和开发工作,所以推测和numa相关的问题,还是和kubelet topology manager/cpu manager有关。

于是我们开始进一步查找这两处代码中有哪些可能出现死循环/高复杂度的异常代码,很快同事定位到了可疑的代码:

//pkg/kubelet/cm/topologymanager/scope_container.go:86
func (s *containerScope) calculateAffinity(pod *v1.Pod, container *v1.Container) (TopologyHint, bool) {
    providersHints := s.accumulateProvidersHints(pod, container)
    bestHint, admit := s.policy.Merge(providersHints)
    klog.InfoS("ContainerTopologyHint", "bestHint", bestHint)
    return bestHint, admit
}

这个函数是topology manager的核心逻辑实现,由于和CPU拓扑相关的决策需要兼顾多种设备,因此该组件中将每种设备对拓扑的偏好情况抽象成了TopologyHint结构体,对应的给出拓扑偏好的来源抽象成HintProvider。

在topology manager启用的情况下,每当一个新pod需要kubelet创建时,都会先调用topology manager实现的Admit接口,对pod的拓扑需求进行运算,看看当前节点上的cpu拓扑是否满足pod的需求。

而获取pod对拓扑需求的方法,就在上述函数中。核心是遍历每个HintProvider,得出对应的可能的所有TopologyHint(基于位表示)。最后通过一些位运算比如位与等操作,得到pod可能的拓扑分配结果。

基于上述逻辑,继续追踪上述函数中的Merge方法,可以追踪到topology manager best effort policy对于providerHints的合并逻辑:

func (p *bestEffortPolicy) Merge(providersHints []map[string][]TopologyHint) (TopologyHint, bool) {
    filteredProvidersHints := filterProvidersHints(providersHints)
    bestHint := mergeFilteredHints(p.numaNodes, filteredProvidersHints)
    admit := p.canAdmitPodResult(&bestHint)
    return bestHint, admit
}

进一步追踪上述代码中的mergeFilteredHints函数,可以看到合并TopologyHint的核心算法:暴力递归

//pkg/kubelet/cm/topologymanager/policy.go:95
func mergeFilteredHints(numaNodes []int, filteredHints [][]TopologyHint) TopologyHint {
    // Set the default affinity as an any-numa affinity containing the list
    // of NUMA Nodes available on this machine.
    defaultAffinity, _ := bitmask.NewBitMask(numaNodes...)

    // Set the bestHint to return from this function as {nil false}.
    // This will only be returned if no better hint can be found when
    // merging hints from each hint provider.
    bestHint := TopologyHint{defaultAffinity, false}
    iterateAllProviderTopologyHints(filteredHints, func(permutation []TopologyHint) {
        // Get the NUMANodeAffinity from each hint in the permutation and see if any
        // of them encode unpreferred allocations.
        mergedHint := mergePermutation(numaNodes, permutation)
        // Only consider mergedHints that result in a NUMANodeAffinity > 0 to
        // replace the current bestHint.
        if mergedHint.NUMANodeAffinity.Count() == 0 {
            return
        }

        // If the current bestHint is non-preferred and the new mergedHint is
        // preferred, always choose the preferred hint over the non-preferred one.
        if mergedHint.Preferred && !bestHint.Preferred {
            bestHint = mergedHint
            return
        }

        // If the current bestHint is preferred and the new mergedHint is
        // non-preferred, never update bestHint, regardless of mergedHint's
        // narowness.
        if !mergedHint.Preferred && bestHint.Preferred {
            return
        }

        // If mergedHint and bestHint has the same preference, only consider
        // mergedHints that have a narrower NUMANodeAffinity than the
        // NUMANodeAffinity in the current bestHint.
        if !mergedHint.NUMANodeAffinity.IsNarrowerThan(bestHint.NUMANodeAffinity) {
            return
        }

        // In all other cases, update bestHint to the current mergedHint
        bestHint = mergedHint
    })

    return bestHint
}

其中又调用了一个包含递归实现的迭代函数

// pkg/kubelet/cm/topologymanager/policy.go:161
func iterateAllProviderTopologyHints(allProviderHints [][]TopologyHint, callback func([]TopologyHint)) {
    // Internal helper function to accumulate the permutation before calling the callback.
    var iterate func(i int, accum []TopologyHint)
    iterate = func(i int, accum []TopologyHint) {
        // Base case: we have looped through all providers and have a full permutation.
        if i == len(allProviderHints) {
            callback(accum)
            return
        }

        // Loop through all hints for provider 'i', and recurse to build the
        // the permutation of this hint with all hints from providers 'i++'.
        for j := range allProviderHints[i] {
            iterate(i+1, append(accum, allProviderHints[i][j]))
        }
    }
    iterate(0, []TopologyHint{})
}

难点在于理解上述两个函数,是如何暴力迭代合并TopologyHint的。

经过我和同事的梳理,发现重点在于该暴力迭代中用到的二维数组,即allProviderHints [][]TopologyHint。该二维数组的内层,由基于numa个数生成的所有拓扑可能构成,比如有两个numa时,内层最多有$2^2$个元素。而当numa数量上升到8后,元素数量也会上升到$2^8$=256个。

再看二维数组的外层,其个数由hintProvider的个数决定。暴力的过程就是基于这个二维数组生成排列组合,逐个做位运算,然后从中寻找最佳的Hint。

因此,暴力迭代的次数取决于numa个数(n)和provider数量(k):$(2^n)^k=2^{n \times k}$。进而可以估算:当numa为8,挂载了6个sriov网卡时(实际的provider数量会比6大,因为除了网卡还有cpu等其他provider),最少的迭代次数为$2^{8 \times 6}$量级,这就会相当耗时了,因此kubelet主进程会被卡住。

复现

尝试从单元测试角度对该问题进行复现。已有的单元测试只针对2numa的情况。当新增单元测试把二维数组填充到上述分析的数量级时,果然merge hint的时间就会随provider数量的上升而快速上升:

policy_best_effort_test.go:70: providerNum:2, hintNum: 2, time: 93.339µs 
policy_best_effort_test.go:70: providerNum:2, hintNum: 4, time: 27.282µs 
policy_best_effort_test.go:70: providerNum:2, hintNum: 8, time: 179.697µs 
policy_best_effort_test.go:70: providerNum:2, hintNum: 16, time: 213.062µs 
policy_best_effort_test.go:70: providerNum:2, hintNum: 32, time: 613.811µs 
policy_best_effort_test.go:70: providerNum:2, hintNum: 64, time: 3.597093ms 
policy_best_effort_test.go:70: providerNum:2, hintNum: 128, time: 7.870391ms 
policy_best_effort_test.go:70: providerNum:2, hintNum: 256, time: 31.972573ms

policy_best_effort_test.go:70: providerNum:3, hintNum: 2, time: 981.528µs 
policy_best_effort_test.go:70: providerNum:3, hintNum: 4, time: 274.927µs 
policy_best_effort_test.go:70: providerNum:3, hintNum: 8, time: 506.2µs 
policy_best_effort_test.go:70: providerNum:3, hintNum: 16, time: 3.457632ms 
policy_best_effort_test.go:70: providerNum:3, hintNum: 32, time: 21.034867ms 
policy_best_effort_test.go:70: providerNum:3, hintNum: 64, time: 175.226735ms 
policy_best_effort_test.go:70: providerNum:3, hintNum: 128, time: 1.336284721s 
policy_best_effort_test.go:70: providerNum:3, hintNum: 256, time: 10.537113408s

policy_best_effort_test.go:70: providerNum:4, hintNum: 2, time: 73.696µs 
policy_best_effort_test.go:70: providerNum:4, hintNum: 4, time: 977.817µs 
policy_best_effort_test.go:70: providerNum:4, hintNum: 8, time: 2.988092ms 
policy_best_effort_test.go:70: providerNum:4, hintNum: 16, time: 33.168184ms 
policy_best_effort_test.go:70: providerNum:4, hintNum: 32, time: 529.52575ms 
policy_best_effort_test.go:70: providerNum:4, hintNum: 64, time: 8.306636684s 
# 当把provider提高到4时,7numa的time就已经来到了2min
policy_best_effort_test.go:70: providerNum:4, hintNum: 128, time: 2m11.962081401s

解决

可能的思路:

  1. 彻底修改算法,从暴力改为其他,从而降低算法复杂度。难度★★★★★
  2. 在现有暴力算法的基础上修改,尝试降低迭代的数量级。难度★★★☆☆

显然从优先解决问题的角度出发,选择思路2。

在numa数量固定不变的基础上,只有选择降低provider数量解决。因此最后通过修改topology manager代码,将device-manager(负责sriov网卡)这个provider从merge hint的流程中移除,这样就可以快速降低迭代的量级了。

但是这样修改的代价是:

  1. topology manager寻找最优拓扑的结果可能有偏差,不完美
  2. gpu等设备可能会被牵连

但由于这两点代价在我司目前的应用中是可接受的,所以暂时ok。

todo

  1. 调研k8s 1.21后续版本对拓扑管理器的优化
  2. 是否有更好的算法可以取代暴力算法,从而更好地支持8 numa
  3. 和社区沟通,看是否有类似的问题和其他解决方案

参考

文章目录