作者 青鸟

在看了各个大厂的k8s底座的开源项目发现都实现了对NUMA调度的支持后,于是对k8s的NUMA实现有点兴趣,这篇博客也就诞生了.

为什么需要NUMA亲和

现代 CPU 多采用 NUMA 架构, NUMA 全称 “Non-Uniform Memory Access” 即非一致性内存访问.为啥搞个非一致性,一致性不好吗?答案肯定是不好,因为如果使用 UMA 即一致性内存访问,随着北桥上的物理核心越来越多,CPU的频率也越来越高,总线带宽扛不住,访问同一块内存的冲突问题也会越来越严重.我们回到 NUMA架构,每个 NUMA node 上会有自己的物理CPU内核,以及每个 NUMA node 上核心之间之间也共享 L3 Cache.同时,内存也分布在每个NUMA node上的.某些开启了超线程的CPU,一个物理CPU内核在操作系统上会呈现两个逻辑的核.

在NUMA架构中,系统的内存被分成多个节点,每个节点与特定的CPU核心相连.每个CPU核心可以更快地访问其本地节点的内存,而访问其他节点的内存则会导致更高的延迟.对于业务侧,如果程序都跑在同一个NUMA node上,可以更好地去共享一些L3 Cache,L3 Cache的访问速度会很快.如果L3 Cache没有命中,可以到内存中读取数据,访存速度会大大降低.这里值得一提的是NUMA对异构设备之间的访问提供了很大的支持,比如GPU和RDMA之间的通信支持可以让参数之间的交换速度大大提高.

在容器大行其道的今天,由于 CPU 错误分配的问题尤为严重.因为现在节点出现了超卖,节点上有大量的容器在同时运行,如果同一个进程分配了不同的 NUMA 会发生什么问题:

  • CPU 争抢带来频繁的上下文切换时间;
  • 频繁的进程切换导致 CPU 高速缓存失败;
  • 跨 NUMA 访存会带来更严重的性能瓶颈.

k8s中的NUMA亲和的实现

目前,Kubernetes 在节点级别支持 Pod 间亲和性和反亲和性,然而将这种支持扩展到 NUMA 级别的需求逐渐增加.举例来说,当前我有四个高内存带宽消耗的业务,这些业务之间自然是反亲和的,为了避免互相干扰,在原生的反亲和性调度策略下,我们需要找四个机器节点来逐个部署这些业务,但是使用了 NUMA 粒度的反亲和性策略后,我们只需要将这四个业务部署在同一台机器的四个 NUMA 节点上即可,这样既节省了算力资源,同时能够保证业务之间互不干扰,这就是 NUMA 粒度亲和与反亲和策略的优势所在.

目前,Kubernetes 对于 NUMA 拓扑的亲和力度是节点级别的, 也就是 pod 被调度到某个节点上后 kubelet 会执行设置好的NUMA感知和NUMA亲和调度策略. 具体执行的模块就是Topology Manager. Topology Manager 其实是解决一个历史问题,CPU Manager 和 Device Manager 是独立工作的,互相不感知.这里不再赘述Topology Manager实现的原理,可以看这篇阿里同学写的文章,很清晰的描述了具体实现Kubelet之Topology Manager分析.

这里做一个总结: 找到不同资源的 topology hints 即拓扑信息, 然后找到其中所涉及到最少的NUMA节点的数量,并将同样的数量的hint设为prefferd,然后进行合并操作. 合并的原则如下,不同 topology 类型的 hints 做 merge 操作,也就是 union,选出最优策略. 这里的最优是指cpu 的选择标准是在满足资源申请的情况下,涉及的 NUMA 节点个数最少前提下涉及到 socket 个数最小的优先选择. device manager 则在满足资源申请情况下,涉及 NUMA 节点最小优先选择.

如果选到还好,如果没选出来怎么办?kubernetes 提供了 kubelet 配置策略:

  • best-effort: kubernetes 节点也会接纳这个 Pod,就是效果不达预期.
  • restricted:节点会拒绝接纳这个 Pod,如果 Pod 遭到节点拒绝,其状态将变为 Terminated.
  • single-NUMA-node:节点会拒绝接纳这个Pod,如果 Pod 遭到节点拒绝,其状态将变为Terminated.这里比 restricted 还多一个条件是选出来的 NUMA 节点个数必须是1个.

所以我们看到 Kubernetes Topology Manager 还是在以 NUMA 为中心来进行不同资源来进行 complete fair 最短路径的选择.而且是在 pod 被调度到某个节点上后 kubelet 执行上述的过程,这样会带来几个问题:

  1. pod 有很大概率会 Terminated, 生产上不可用.
  2. 缺乏集群视角,选择发生在调度节点之后,在调度时没有办法以NUMA亲和调度最佳节点.
  3. 节点的 topology-manager-policy 配置不方便, kubelet 每次配置参数都需要重启,如果遇到特殊的版本可能会重启所有节点 pod

所以我们会想到几个优化的方案:

  1. 让一个类似 kubelet 的 daemon 进程,可以发现 topo 关系,并向外暴露;
  2. 可以让拓扑感知放在 kube-scheduler 给 pod 分配 node 的时候就感知,而且可以指导调度.

koodinator 实现 NUMA Topology Scheduling

实现

在scheduler-plugins中其实已经有对应的插件的实现了,目前处于Beta版本. 其实仔细研究后可以发现,各个开源社区的实现都是基于这个提案,实现的方式也都大同小异.

这里大致描述一下社区里的开源项目实现的流程:

  1. agent 从节点采集资源拓扑,包括NUMA、Socket、设备等信息,汇总到一个自定义的节点资源拓扑资源对象中.
  2. scheduler在调度时会参考节点的节点资源拓扑资源对象获取到节点详细的资源拓扑结构,在调度到节点的同时还会为Pod分配拓扑资源,并将结果写到Pod的annotations中.
  3. agent在节点上Watch到Pod被调度后,从Pod的annotations中获取到拓扑分配结果,并按照用户给定的CPU绑定策略进行CPUSet的细粒度分配.

在koodinator中NUMA信息的聚合是通过noderesourcetopologies这个资源去收集的,这也是社区里的实现.

Koordinator调度器需要引入一个新的插件NUMATopology,该插件负责协调不同插件之间的资源分配,并根据NUMA拓扑策略决定合适的NUMA节点,确保根据决策选择的NUMA节点能够分配Pod所需的资源.同时,通过评分机制,选择集群级别上最合适的节点.

NUMATopology将要求感知NUMA拓扑的插件实现一个新的接口,称为NUMATopologyHintProvider.NUMATopologyHintProvider借鉴了kubelet的拓扑管理器.现有插件如NodeNUMAResource和DeviceShare需要实现这个新接口.

在过滤阶段,NUMATopology插件会调用每个HintProvider的NUMATopologyHintProvider.GetPodTopologyHints方法,获取每个资源所对应的NUMA节点提示,指示这些资源可以在哪些NUMA节点上进行分配. 对于异构设备,只需要在device plugin中实现这个方法即可获得NUMA拓扑的能力. NUMATopology插件会根据策略整合这些提示,并计算出一个可以满足所有资源需求的NUMA节点.如果没有这样的NUMA节点,则表示该节点无法参与后续的调度分配.如果有这样的NUMA节点,则称为首选NUMA节点.然后继续调用NUMATopologyHintProvider.Allocate方法,确认是否能够在首选NUMA节点上分配资源.如果不能,认为该节点也无法参与调度分配.如果该节点有一个符合Pod需求的首选NUMA节点,它将被保存到CycleState中,供后续的评分/预留阶段使用.

默认情况下,使用MostAllocated算法进行评分,优先选择剩余NUMA节点较少的节点.它还支持基于节点上的node.koordinator.sh/numa-allocate-strategy标签指定的策略进行评分,但只支持两种策略:MostAllocated和LeastAllocated.

MostAllocated和LeastAllocated策略都基于已使用NUMA节点的数量与NUMA节点总数之间的关系进行计算.以MostAllocated为例,如果一个节点有4个NUMA节点,但其中一个已经完全分配,且此次调度过程中还需要分配一个,那么计算公式为:Score = (2 * 100)/4 = 50. 值得注意的是打分并没有考虑不同节点之间的NUMA数量的区别,只是对资源的存量进行打分. 其实基于pod在不同节点所需的不同的NUMA数量的打分的在社区里也是有提案的, 但是koodinator并没有实现.

在预留阶段,将过滤阶段获得的首选NUMA节点传递给每个NUMATopologyHintProvider.Allocate接口,并将assume参数设置为true,表示该分配结果需要假设有效.

值得注意的是, 在NUMA架构下,内存管理相对复杂.如果一个Pod完全限制只访问某个NUMA节点下的物理内存,可能会降低系统的稳定性.对于这种内存使用方式,通常会修改内核参数,或者由所有Pod的运行时负责锁定内存,或者提前使用HugePages进行内存规划.

不管采用哪种具体方式,Koordinator并未直接提供锁定内存的机制,而是增加了一种协议,通过NUMA节点拓扑感知某个节点的内存是否需要进行处理.这项策略以标签的形式写入节点,标签键为:node.koordinator.sh/numa-memory-policy,目前值尚未定义.当节点上存在这样的标签时,调度器的NodeNUMAResource插件会记录每个NUMA节点下的内存分配情况,并避免在内存已分配完的NUMA节点上分配内存.调度器并不会感知NUMA节点的实际内存使用情况.

NodeNUMAResource插件的区别

Kubernetes拓扑管理器要求CPU管理器启用静态策略,以限制在特定NUMA节点上的CPU分配.然而,用户不一定希望所有Pod都绑定到一个CPU核心,尤其是在共存场景下,使用CPU共享(CPUShare)可以提供更灵活的空间.因此,Koordinator调度器的NUMANodeResource插件需要根据NUMA拓扑策略,支持CPUShare和CPUSet两种资源分配场景.

NUMANodeResource插件实现了NUMATopologyHintProvider接口,用于根据Pod的请求提供哪些NUMA节点可以分配的提示.在计算这些提示时,仅考虑每个NUMA节点的实际剩余资源,而不关注绑定的CPU核心是否最优.NUMATopologyHintProvider.Allocate实现需要确认是否可以根据传入的首选NUMA亲和性(Preferred NUMA Affinity)在指定的NUMA节点上分配CPU资源.

NodeNUMAResource的原始Filter逻辑保持不变,目前仅检查节点的NodeResourceTopology中记录的CPU拓扑是否有效.原始的Score实现也保持不变,仅添加支持CPUSet场景.

Reserve实现需要了解节点的NUMA拓扑策略,并在策略不为None时返回.在NUMATopologyHintProvider.Allocate实现中,插件负责预留资源.

此外,NodeNUMAResource插件需要支持CPU Share Pods.如果节点的NUMA拓扑策略不为None,则需要在分配阶段预留资源;在PreBind阶段,需要设置ResourceStatus,指示CPU共享只能在指定的NUMA节点上实现.

在故障转移过程中,NodeNUMAResource插件需要记录CPU Share Pods使用了哪些NUMA节点以及使用的资源量.插件还需要支持抢占.Restricted/SingleNUMANode策略要求每种资源只能分配到指定的NUMA节点,这意味着可抢占的资源也必须遵循这些策略.

在处理Reservation CRD对象以预留CPU时,NodeNUMAResource插件不会改变其原始逻辑.因为在预留资源时遵循的调度逻辑相同,获得的资源也满足NUMA拓扑策略约束,这意味着所有者Pod(Owner Pods)也可以根据策略重新使用这些资源.

参考链接: