kubelet在多NUMA下的性能问题排查
问题发现
- k8s 1.21, node使用了某国产化服务器,其CPU也是国产的,numa node数量多达8个
- kubelet cpumanager 启用了best-effort policy
- 某kubevirt vm挂载sriov网卡数量大于5后,vm无法正常创建,具体表现为vm对应的pod持续卡在pending状态
初步排查
- 当发现有pod卡在pending状态时,根据k8s pod的生命周期流转过程,第一时间检查调度情况。以往情况下,pod卡在pending状态下通过kubectl describe,可以快速看到其缺乏哪种资源导致pending。
- 但是本次发现的问题比较奇怪:describe pod看不到任何资源缺失的问题。
- 进一步,查看pod的yaml,发现其nodeName并不是空的,而是已经被分配的节点。
- 此时结合k8s创建pod的核心流程和各组件的分工,可以推测:kube-scheduler运行正常,为pod填充了nodeName字段,但是kubelet未进行后续流程。在查看kube-scheduler和对应node上kubelet的日志后,推测得到了印证。
- 那么问题就转化成了:为什么kubelet不像以往一样发现自己节点负责的pod,拉起容器,修改pod状态呢?
- 此时观察kubelet的状态:通过top可以看到kubelet的CPU使用率高达数百,名列前茅,而通常在该环境负载状态下的kubelet CPU使用率不会如此持续走高。所以推测kubelet是在进行大量持续的运算。
- 进一步观察kubelet日志,可以发现虽然有日志,但是缺少了一些和其核心流程相关的日志,能打出来的都是一些非主进程的go routine里的日志。这一步的观察比较依赖对kubelet代码的了解和kubelet日志的熟悉程度。
- 综合上面的分析,推测是创建包含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
解决
可能的思路:
- 彻底修改算法,从暴力改为其他,从而降低算法复杂度。难度★★★★★
- 在现有暴力算法的基础上修改,尝试降低迭代的数量级。难度★★★☆☆
显然从优先解决问题的角度出发,选择思路2。
在numa数量固定不变的基础上,只有选择降低provider数量解决。因此最后通过修改topology manager代码,将device-manager(负责sriov网卡)这个provider从merge hint的流程中移除,这样就可以快速降低迭代的量级了。
但是这样修改的代价是:
- topology manager寻找最优拓扑的结果可能有偏差,不完美
- gpu等设备可能会被牵连
但由于这两点代价在我司目前的应用中是可接受的,所以暂时ok。
todo
- 调研k8s 1.21后续版本对拓扑管理器的优化
- 是否有更好的算法可以取代暴力算法,从而更好地支持8 numa
- 和社区沟通,看是否有类似的问题和其他解决方案
参考
本作品采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可。