Kubelet 中与 Device-plugin 交互的源码分析

633 阅读6分钟

背景

随着 AI 业务不断膨胀,开发者对 GPU 的需求出现了猛涨。近期大部分时间都投入在调度器的业务上。实际上在早期,K8s 也提供了一种名为 alpha.kubernetes.io/nvidia-gpu 的资源来支持 NVIDIA GPU,不过后面也发现了很多问题,每增加一种资源都要修改 k8s 核心代码,k8s 社区压力山大。于是在 1.8 版本引入了 device plugin 机制,通过插件形式来接入其他资源。

笔者近期也在做调度器相关的业务,因此理解这块的原理已经迫在眉睫。本文就从 Kubelet 的角度,看下 device-plugin 和 Kubelet 是如何通力合作的。

在调度器过程中的分工

  1. Kubelet:运行在每个节点之上。负责上报节点信息,启动/销毁 Pod,为 Pod 分配网络、存储、资源等;
  2. Kube-Scheduler:节点级别的调度,负责将 Pod 调度到指定节点上;
  3. Device-plugin:由各厂家实现,典型的有 nvidia-device-plugin,rdma-deivce-plugin,ascend-device-plugin 等。负责为负载分配真实的资源,同时会维护节点的资源信息。

需要注意的是,大多数 device-plugin 的实现方式都是只实际分配资源,分配到哪个资源由 Kubelet 决定。也存在某些(比如 ascend-device-plugin),不认 Kubelet 的分配结果,重新分配

交互流程

image.png

  1. Kubelet 启动 grpc Registration 服务,等待 device-plugin 接入;
  2. device-plugin 往往以 daemonset 的方式部署在集群中,最终会在每个节点上启一个 Pod,该 Pod 会通过 kubelet.sock 调用 Register 接口,向 Kubelet 发起注册请求,其中携带 endpoint,apiversion,resourcename 等信息;
  3. Kubelet 利用 device-plugin 的 endpoint 调用 device-plugin 的 ListAndWatch,获取当前节点的资源信息;
  4. Kubelet 将资源池信息发送给 Kube-ApiServer,更新节点资源信息;
  5. 用户向 Kube-ApiServer 发送申请创建 Pod;
  6. Kube-Scheduler Watch 到该 Pod,将其调度到指定节点上;
  7. 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)
   }
// ...
}

containerManagerStart 中启动了各类 Manager,同时缓存了 Node 信息。其中包括 devicemanager.ManagerImplStart,其作用如下:

  1. 初始化当前 Pod Devices 现状和可分配的 Devices;
  2. 启动 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 会调用到 ManagetImplRegister 方法。

Register 方法中最重要的是 addEndpoint 方法:

  1. 初始化 EndPoint 对象;
  2. 调用 registerEndpoint 函数,将 resourceName 和 EndPoint 对象写入到 map 中。此处可以多关注:因为 Kubelet 将此类信息都保存在 map,所以 Kubelet 重启后,device-plugin 都需要重新注册
  3. 启动 goroutinue,调用 runEndpoint 函数;
  4. 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 可以发现有 canAdmitPodcanRunPod 两个方法会调用 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 的内容,那 deviceManagerTolopyManager 有何关系呢。

首先,从代码上看,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 拓扑调度等。