背景
随着 AI 业务不断膨胀,开发者对 GPU 的需求出现了猛涨。近期大部分时间都投入在调度器的业务上。实际上在早期,K8s 也提供了一种名为 alpha.kubernetes.io/nvidia-gpu
的资源来支持 NVIDIA GPU,不过后面也发现了很多问题,每增加一种资源都要修改 k8s 核心代码,k8s 社区压力山大。于是在 1.8 版本引入了 device plugin
机制,通过插件形式来接入其他资源。
笔者近期也在做调度器相关的业务,因此理解这块的原理已经迫在眉睫。本文就从 Kubelet 的角度,看下 device-plugin 和 Kubelet 是如何通力合作的。
在调度器过程中的分工
- Kubelet:运行在每个节点之上。负责上报节点信息,启动/销毁 Pod,为 Pod 分配网络、存储、资源等;
- Kube-Scheduler:节点级别的调度,负责将 Pod 调度到指定节点上;
- Device-plugin:由各厂家实现,典型的有 nvidia-device-plugin,rdma-deivce-plugin,ascend-device-plugin 等。负责为负载分配真实的资源,同时会维护节点的资源信息。
需要注意的是,大多数 device-plugin 的实现方式都是只实际分配资源,分配到哪个资源由 Kubelet 决定。也存在某些(比如 ascend-device-plugin),不认 Kubelet 的分配结果,重新分配
交互流程
- Kubelet 启动 grpc Registration 服务,等待 device-plugin 接入;
- device-plugin 往往以 daemonset 的方式部署在集群中,最终会在每个节点上启一个 Pod,该 Pod 会通过 kubelet.sock 调用 Register 接口,向 Kubelet 发起注册请求,其中携带 endpoint,apiversion,resourcename 等信息;
- Kubelet 利用 device-plugin 的 endpoint 调用 device-plugin 的 ListAndWatch,获取当前节点的资源信息;
- Kubelet 将资源池信息发送给 Kube-ApiServer,更新节点资源信息;
- 用户向 Kube-ApiServer 发送申请创建 Pod;
- Kube-Scheduler Watch 到该 Pod,将其调度到指定节点上;
- Kubelet 感知到有 Pod,调用 device-plugin 的 Allocate 接口,进行资源分配。
kubelet 源码分析
初始化 DeviceManager 服务
以 v1.22 Tag 为准
直接从 kubelet 启动的代码看起。cmd/kubelet/app/server.go
中调用 NewContainerManager
方法,创建 ContainerManager
对象,该对象的 deviceManager
字段是 ManagerImpl
对象,该对象即是 device manager 的实现核心。
// cmd/kubelet/app/server.go
func run(ctx context.Context, s *options.KubeletServer, kubeDeps *kubelet.Dependencies, featureGate featuregate.FeatureGate) (err error) {
// ...
kubeDeps.ContainerManager, err = cm.NewContainerManager(...)
if err != nil {
return err
}
}
//...
return nil
}
// pkg/kubelet/cm/container_manager_linux.go
func NewContainerManager(mountUtil mount.Interface, cadvisorInterface cadvisor.Interface, nodeConfig NodeConfig, failSwapOn bool, devicePluginEnabled bool, recorder record.EventRecorder) (ContainerManager, error) {
// ...
if devicePluginEnabled {
cm.deviceManager, err = devicemanager.NewManagerImpl(machineInfo.Topology, cm.topologyManager)
cm.topologyManager.AddHintProvider(cm.deviceManager)
} else {
cm.deviceManager, err = devicemanager.NewManagerStub()
}
//...
}
咱们也不着急研究 ManagerImpl
类型,先来看看初始化之后 Kubelet
做了什么。沿着 cmd/kubelet/app/server.go
中的 NewContainerManager
往下找,调用链路如下:
RunKubelet -> startKubelet -> Run -> updateRuntimeUp -> initializeRuntimeDependentModules
走到 initializeRuntimeDependentModules
后,可以看到各种 Start
调用。其中就包括 containerManager
。
func (kl *Kubelet) initializeRuntimeDependentModules() {
// ...
// containerManager must start after cAdvisor because it needs filesystem capacity information
if err := kl.containerManager.Start(node, kl.GetActivePods, kl.sourcesReady, kl.statusManager, kl.runtimeService); err != nil {
// Fail kubelet and rely on the babysitter to retry starting kubelet.
klog.ErrorS(err, "Failed to start ContainerManager")
os.Exit(1)
}
// ...
}
containerManager
的 Start
中启动了各类 Manager
,同时缓存了 Node 信息。其中包括 devicemanager.ManagerImpl
的 Start
,其作用如下:
- 初始化当前 Pod Devices 现状和可分配的 Devices;
- 启动 Registration 服务。启动过程中需要设置 sock 文件位置,linux 下默认是
/var/lib/kubelet/device-plugins/kubelet.sock
。
此时,Kubelet 已经顺利启动 Registration 服务,等待 device-plugin 注册。
device-plugin 通过 grpc 调用 Register 函数,Kubelet 调用 ListAndWatch
本文仅关注 Kubelet 源码,device-plugin 的逻辑会在后续文章介绍。
device-plugin 会调用到 ManagetImpl
的 Register
方法。
Register
方法中最重要的是 addEndpoint
方法:
- 初始化 EndPoint 对象;
- 调用
registerEndpoint
函数,将 resourceName 和 EndPoint 对象写入到 map 中。此处可以多关注:因为 Kubelet 将此类信息都保存在 map,所以 Kubelet 重启后,device-plugin 都需要重新注册; - 启动 goroutinue,调用 runEndpoint 函数;
- runEndpoint 最终会调用到 ListAndWatch 方法,用以从 device-plugin 处获取资源的最新信息。
// pkg/kubelet/cm/devicemanager/manager.go
// Register registers a device plugin.
func (m *ManagerImpl) Register(ctx context.Context, r *pluginapi.RegisterRequest) (*pluginapi.Empty, error) {
// ...
// TODO: for now, always accepts newest device plugin. Later may consider to
// add some policies here, e.g., verify whether an old device plugin with the
// same resource name is still alive to determine whether we want to accept
// the new registration.
go m.addEndpoint(r)
return &pluginapi.Empty{}, nil
}
// pkg/kubelet/cm/devicemanager/manager.go
func (m *ManagerImpl) addEndpoint(r *pluginapi.RegisterRequest) {
new, err := newEndpointImpl(filepath.Join(m.socketdir, r.Endpoint), r.ResourceName, m.callback)
if err != nil {
klog.ErrorS(err, "Failed to dial device plugin with request", "request", r)
return
}
m.registerEndpoint(r.ResourceName, r.Options, new)
go func() {
m.runEndpoint(r.ResourceName, new)
}()
}
// pkg/kubelet/cm/devicemanager/manager.go
func (m *ManagerImpl) runEndpoint(resourceName string, e endpoint) {
e.run()
// ...
}
// pkg/kubelet/cm/devicemanager/endpoint.go
func (e *endpointImpl) run() {
stream, err := e.client.ListAndWatch(context.Background(), &pluginapi.Empty{})
// ...
}
Kube-Scheduler 开启 Pod 调度
无论是 Kube-Scheduler 原声调度器还是 Volcano 等三方调度器,都只能保证节点级别的调度。负载调度到节点后,真正使用哪块 cpu 或者 gpu,均由 Kubelet 和 device-plugin 决定。因此,在大规模场景下,满足拓扑分布的调度是一个非常有价值的研究点。 *次数不过多关注调度器细节,对 Volcano 感兴趣的可以查看其他文章 Volcano 详解二
Kubelet 执行 Allocate 动作
从 devicemanager.ManagerImpl.Allocate
出发,反向查找 Kubelet 是如何使用 Allocate
的。调用链路如下:
Allocate -> Admit -> canAdmitPod, canRunPod
可以发现有 canAdmitPod
和 canRunPod
两个方法会调用 Allocate
函数,其实针对 devicemanager.ManagerImpl
而言,只有 canAdmitPod
函数,该函数遍历了 admitHandlers
,此时 Kubelet 就会实际调用 device-plugin 的 Allocate
函数,为 Pod 分配资源。
// pkg/kubelet/kubelet.go
func (kl *Kubelet) canAdmitPod(pods []*v1.Pod, pod *v1.Pod) (bool, string, string) {
// the kubelet will invoke each pod admit handler in sequence
// if any handler rejects, the pod is rejected.
// TODO: move out of disk check into a pod admitter
// TODO: out of resource eviction should have a pod admitter call-out
attrs := &lifecycle.PodAdmitAttributes{Pod: pod, OtherPods: pods}
for _, podAdmitHandler := range kl.admitHandlers {
if result := podAdmitHandler.Admit(attrs); !result.Admit {
return false, result.Reason, result.Message
}
}
return true, "", ""
}
刚聊过,canAdmitPod
中会遍历admitHandlers
数组,该数组中元素与ManagerImpl
存在关联,那么是怎么关联的呢。咱们还是先回到初始化的部分。可以看到此处就建立起了关联,将 Admit
加入到了 admitHandlers
中。
// pkg/kubelet/kubelet.go
func NewMainKubelet(...) (*Kubelet, error) {
//...
klet.admitHandlers.AddPodAdmitHandler(klet.containerManager.GetAllocateResourcesPodAdmitHandler())
// ...
klet.admitHandlers.AddPodAdmitHandler(lifecycle.NewPredicateAdmitHandler(klet.getNodeAnyWay, criticalPodAdmissionHandler, klet.containerManager.UpdatePluginResources))
//...
}
补充:与 TopologyManager 的关系
从代码中看,devicemanager.ManagerImpl
还实现了 HintProvider
接口,先前做 Numa 调度相关业务时,了解过一些 TopologyManager 的内容,那 deviceManager
和 TolopyManager
有何关系呢。
首先,从代码上看,ManagerImpl
实现了 HintProvider
,那自然是 HintProvider
的一种。那难道不开启 toplogy,就用不了ManagerImpl
吗?显然不是,否则各类 device-plugin 根本无法在三方集群上产品化使用。
照着 pkg/kubelet/cm/topologymanager/topology_manager.go
中的 NewManager
往上追一下,可以发现在初始化 ContainerManager
的时候,如果开启了 TopologyManager
,则初始化 NewManager
。后续将 deviceManager
加入到 HintProvider
中。如果未开启 TopologyManager,同样会启动一个 FakeManager
,依然不影响后续的 deviceManager
能力。
func NewContainerManager(...) {
if utilfeature.DefaultFeatureGate.Enabled(kubefeatures.TopologyManager) {
cm.topologyManager, err = topologymanager.NewManager(
machineInfo.Topology,
nodeConfig.ExperimentalTopologyManagerPolicy,
nodeConfig.ExperimentalTopologyManagerScope,
)
if err != nil {
return nil, err
}
} else {
cm.topologyManager = topologymanager.NewFakeManager()
}
klog.InfoS("Creating device plugin manager", "devicePluginEnabled", devicePluginEnabled)
if devicePluginEnabled {
cm.deviceManager, err = devicemanager.NewManagerImpl(machineInfo.Topology, cm.topologyManager)
cm.topologyManager.AddHintProvider(cm.deviceManager)
} else {
cm.deviceManager, err = devicemanager.NewManagerStub()
}
}
因此,无论是否开启 TopologyManager 开关,device-plugin 的能力依旧,只是打开了后,可以实现更高级的拓扑能力。
总结
随着业务不断深入,笔者愈发觉得云原生调度是个很有意思的方向,后期笔者还会继续深入,探索更多的内容。比如 TopoloyManager,晟腾卡调度,GPU 拓扑调度等。