背景
我们知道,如果在Kubernetes中支持GPU设备调度,需要做如下的工作:
- 节点上安装nvidia驱动
- 节点上安装nvidia-docker
- 集群部署gpu device plugin,用于为调度到该节点的pod分配GPU设备。
除此之外,如果你需要监控集群GPU资源使用情况,你可能还需要安装DCCM exporter结合Prometheus输出GPU资源监控信息。
要安装和管理这么多的组件,对于运维人员来说压力不小。基于此,NVIDIA开源了一款叫NVIDIA GPU Operator的工具,该工具基于Operator Framework实现,用于自动化管理上面我们提到的这些组件。
在之前的文章中,作者分别介绍了NVIDIA GPU Operator所涉及的每一个组件并且演示了如何手动部署这些组件,在本篇文章中将介绍详细介绍NVIDIA GPU Operator的工作原理。
Operator Framework介绍
NVIDIA GPU Operator是基于Operator Framework实现,所以在介绍NVIDIA GPU Operator之前先简单介绍一下Operator Framework,便于理解NVIDIA GPU Operator。
官方对Operator的介绍如下:“An Operator is a method of packaging, deploying and managing a Kubernetes application.”(即Operator是一种打包、部署、管理k8s应用的方式)。
Operator Framework采用的是Controller模式,什么是Controller模式呢?简单以下面这幅图介绍一下:
- Controller可以有一个或多个Informer,Informer通过事件监听机制从APIServer处获取所关心的资源变化(创建、删除、更新等)。
- 当Informer监听到某个事件发生时,先把资源更新到本地cache中,然后会调用callback函数将该事件放进一个队列中(WorkQueue)。
- 在队列的另一端,有一个永不终止的控制循环不断从队列中取出事件。
- 从队列中取出的事件将会交给一个特定的函数处理(图中的Worker,在Operator Framework中一般称为Reconcile函数),这个函数的运行逻辑需要根据业务实现。
Operator Framework提供如下的工作流来开发一个Operator:
- 使用SDK创建一个新的Operator项目
- 添加自定义资源(CRD)以及定义相关的API
- 指定使用SDK API监听的资源
- 定义处理资源变更事件的函数(Reconcile函数)
- 使用Operator SDK构建并生成Operator部署清单文件
组件介绍
从前面的文章中,我们知道NVIDIA GPU Operator总共包含如下的几个组件:
- NFD(Node Feature Discovery):用于给节点打上某些标签,这些标签包括cpu id、内核版本、操作系统版本、是不是GPU节点等,其中需要关注的标签是“nvidia.com/gpu.present=true”,如果节点存在该标签,那么说明该节点是GPU节点。
- NVIDIA Driver Installer:基于容器的方式在节点上安装NVIDIA GPU驱动,在k8s集群中以DaemonSet方式部署,只有节点拥有标签“nvidia.com/gpu.present=true”时,DaemonSet控制的Pod才会在该节点上运行。
- NVIDIA Container Toolkit Installer:能够实现在容器中使用GPU设备,在k8s集群中以DaemonSet方式部署,只有节点拥有标签“nvidia.com/gpu.present=true”时,DaemonSet控制的Pod才会在该节点上运行。
- NVIDIA Device Plugin:NVIDIA Device Plugin用于实现将GPU设备以Kubernetes扩展资源的方式供用户使用,在k8s集群中以DaemonSet方式部署,只有节点拥有标签“nvidia.com/gpu.present=true”时,DaemonSet控制的Pod才会在该节点上运行。
- DCGM Exporter:周期性的收集节点GPU设备的状态(当前温度、总的显存、已使用显存、使用率等),然后结合Prometheus和Grafana将这些指标用丰富的仪表盘展示给用户。在k8s集群中以DaemonSet方式部署,只有节点拥有标签“nvidia.com/gpu.present=true”时,DaemonSet控制的Pod才会在该节点上运行。
- GFD(GPU Feature Discovery):用于收集节点的GPU设备属性(GPU驱动版本、GPU型号等),并将这些属性以节点标签的方式透出。在k8s集群中以DaemonSet方式部署,只有节点拥有标签“nvidia.com/gpu.present=true”时,DaemonSet控制的Pod才会在该节点上运行。
工作流程
NVIDIA GPU Operator的工作流程可以描述为:
- NVIDIA GPU Operator依如下的顺序部署各个组件,并且如果前一个组件部署失败,那么其后面的组件将停止部署:
- NVIDIA Driver Installer
- NVIDIA Container Toolkit Installer
- NVIDIA Device Plugin
- DCGM Exporter
- GFD
- 每个组件都是以DaemonSet方式部署,并且只有当节点存在标签nvidia.com/gpu.present=true时,各DaemonSet控制的Pod才会在节点上运行。
源码介绍
前提说明
- GPU Operator的代码地址为:https://github.com/NVIDIA/gpu-operator.git
- 本文分析的代码的tag为1.6.2
NVIDIA GPU Operator的CRD
前面我们提到过Operator的开发流程,在开发流程中需要添加自定义资源(CRD),那么NVIDIA GPU Operator的CRD是怎样定义的呢?
GPU Operator定义了一个CRD: clusterpolicies.nvidia.com,clusterpolicies.nvidia.com这种CRD用于保存GPU Operator需要部署的各组件的配置信息。通过helm部署GPU Operator时,会部署一个名为cluster-policy的CR,可以通过如下的命令获取其内容:
$ kubectl get clusterpolicies.nvidia.com cluster-policy -o yaml
apiVersion: nvidia.com/v1
kind: ClusterPolicy
metadata:
annotations:
meta.helm.sh/release-name: operator
meta.helm.sh/release-namespace: gpu
creationTimestamp: "2021-04-10T05:04:52Z"
generation: 1
labels:
app.kubernetes.io/component: gpu-operator
app.kubernetes.io/managed-by: Helm
name: cluster-policy
resourceVersion: "10582204"
selfLink: /apis/nvidia.com/v1/clusterpolicies/cluster-policy
uid: 0d44ab71-c64b-4b23-a74f-45087f8725c7
spec:
dcgmExporter:
args:
- -f
- /etc/dcgm-exporter/dcp-metrics-included.csv
image: dcgm-exporter
imagePullPolicy: IfNotPresent
repository: nvcr.io/nvidia/k8s
version: 2.1.4-2.2.0-ubuntu20.04
devicePlugin:
args:
- --mig-strategy=single
- --pass-device-specs=true
- --fail-on-init-error=true
- --device-list-strategy=envvar
- --nvidia-driver-root=/run/nvidia/driver
image: k8s-device-plugin
imagePullPolicy: IfNotPresent
nodeSelector:
nvidia.com/gpu.present: "true"
repository: nvcr.io/nvidia
securityContext:
privileged: true
version: v0.8.2-ubi8
driver:
image: nvidia-driver
imagePullPolicy: IfNotPresent
licensingConfig:
configMapName: ""
nodeSelector:
nvidia.com/gpu.present: "true"
repoConfig:
configMapName: ""
destinationDir: ""
repository: registry.cn-beijing.aliyuncs.com/happy365
securityContext:
privileged: true
seLinuxOptions:
level: s0
tolerations:
- effect: NoSchedule
key: nvidia.com/gpu
operator: Exists
version: 450.102.04
gfd:
discoveryIntervalSeconds: 60
image: gpu-feature-discovery
imagePullPolicy: IfNotPresent
migStrategy: single
nodeSelector:
nvidia.com/gpu.present: "true"
repository: nvcr.io/nvidia
version: v0.4.1
operator:
defaultRuntime: docker
validator:
image: cuda-sample
imagePullPolicy: IfNotPresent
repository: nvcr.io/nvidia/k8s
version: vectoradd-cuda10.2
toolkit:
image: container-toolkit
imagePullPolicy: IfNotPresent
nodeSelector:
nvidia.com/gpu.present: "true"
repository: nvcr.io/nvidia/k8s
securityContext:
privileged: true
seLinuxOptions:
level: s0
tolerations:
- key: CriticalAddonsOnly
operator: Exists
- effect: NoSchedule
key: nvidia.com/gpu
operator: Exists
version: 1.4.3-ubi8
status:
state: notReady
可以看到在CR的spec部分保存了各组件的配置信息,这些配置信息来源于helm chart的values.yaml。
另外,出了保存各组件的配置信息,在status部分,还有一个字段state保存GPU Operator状态。
NVIDIA GPU Operator监听的资源
可以在pkg/controller/clusterpolicy/clusterpolicy_controller.go中的add函数,找到GPU Operator所监听的资源。从代码中可以看到,NVIDIA GPU Operator需要监听三种资源变化:
- NVIDIA GPU Operator自定义资源(CRD)发生变化
- 集群中的节点发生变化(比如集群添加节点,集群节点的标签发生变化等)
- 由NVIDIA GPU Operator创建的Pod发生变化(即各个DaemonSet控制的Pod发生变化)
// add adds a new Controller to mgr with r as the reconcile.Reconciler
func add(mgr manager.Manager, r reconcile.Reconciler) error {
// Create a new controller
c, err := controller.New("clusterpolicy-controller", mgr, controller.Options{Reconciler: r})
if err != nil {
return err
}
// Watch for changes to primary resource ClusterPolicy
// 1.当NVIDIA GPU Operator自定义资源(CRD)发生变化时,需要通知GPU Operator进行处理
err = c.Watch(&source.Kind{Type: &gpuv1.ClusterPolicy{}}, &handler.EnqueueRequestForObject{})
if err != nil {
return err
}
// Watch for changes to Node labels and requeue the owner ClusterPolicy
// 2.当有新节点添加或者节点更新时,需要通知GPU Operator进行处理
err = addWatchNewGPUNode(c, mgr, r)
if err != nil {
return err
}
// TODO(user): Modify this to be the types you create that are owned by the primary resource
// Watch for changes to secondary resource Pods and requeue the owner ClusterPolicy
// 3.与NVIDIA GPU Operator相关的pod发生变化时,需要通知GPU Operator进行处理
err = c.Watch(&source.Kind{Type: &corev1.Pod{}}, &handler.EnqueueRequestForOwner{
IsController: true,
OwnerType: &gpuv1.ClusterPolicy{},
})
if err != nil {
return err
}
return nil
}
Reconcile函数
前面介绍Operator Framework提到过,开发Operator时需要开发者根据业务场景实现Reconcile函数,用于处理Operator所监听的资源发生变化时,应该做出哪些操作。
接下来分析一下Reconcile函数的执行逻辑,其中传入的参数为从队列中取出的资源变化的事件。
func (r *ReconcileClusterPolicy) Reconcile(request reconcile.Request) (reconcile.Result, error) {
ctx := log.WithValues("Request.Name", request.Name)
ctx.Info("Reconciling ClusterPolicy")
// 获取ClusterPolicy实例,GPU Operator中定义了一个名为clusterpolicies.nvidia.com的CRD。
// 用于保存其helm chart的values.yaml中各组件的配置信息,比如:镜像名称,启动命令等。
// 同时,在gpu operator的helm chart已定义了一个名为cluster-policy的CR,在安装helm chart时会自动安装该CR。
instance := &gpuv1.ClusterPolicy{}
err := r.client.Get(context.TODO(), request.NamespacedName, instance)
if err != nil {
// 如果没有发现CR,证明该CR被删除了,不会将request重新放进事件队列中进行再一次处理。
if errors.IsNotFound(err) {
return reconcile.Result{}, nil
}
// 否则返回错误,该请求会被放进事件队列中再次处理。
// Error reading the object - requeue the request.
return reconcile.Result{}, err
}
// 如果获取的ClusterPolicy实例名称与当前保存的ClusterPolicy实例名称不一致
// 那么将实例状态设置为Ignored,同时结束函数,直接返回,并且request不会被放入队列中再次处理。
if ctrl.singleton != nil && ctrl.singleton.ObjectMeta.Name != instance.ObjectMeta.Name {
instance.SetState(gpuv1.Ignored)
return reconcile.Result{}, err
}
// 初始化ClusterPolicyController,初始化的操作后面会详细分析。
err = ctrl.init(r, instance)
if err != nil {
log.Error(err, "Failed to initialize ClusterPolicy controller")
return reconcile.Result{}, err
}
// for循环用于依次部署各组件:nvidia driver、nvidia container toolkit、nvidia device plugin
// dcgm exporter和gfd。
for {
// ctrl.step函数用于部署各组件(nvidia driver、nvidia container toolkit等)并返回部署的组件的状态。
// 每执行一次ctrl.step(),那么有一个组件将会被部署
status, statusError := ctrl.step()
// Update the CR status
// 更新CR状态,首先获取CR
instance = &gpuv1.ClusterPolicy{}
err := r.client.Get(context.TODO(), request.NamespacedName, instance)
if err != nil {
log.Error(err, "Failed to get ClusterPolicy instance for status update")
return reconcile.Result{RequeueAfter: time.Second * 5}, err
}
// 如果CR状态与当前部署的组件状态不一致,更新CR状态。
if instance.Status.State != status {
instance.Status.State = status
err = r.client.Status().Update(context.TODO(), instance)
if err != nil {
log.Error(err, "Failed to update ClusterPolicy status")
return reconcile.Result{RequeueAfter: time.Second * 5}, err
}
}
// 如果部署当前组件失败,那么将request放进事件队列,等待再次处理。
if statusError != nil {
return reconcile.Result{RequeueAfter: time.Second * 5}, statusError
}
// 如果当前部署的组件的状态不是Ready的,那么将request放入队列,等待再次处理。
if status == gpuv1.NotReady {
// If the resource is not ready, wait 5 secs and reconcile
log.Info("ClusterPolicy step wasn't ready", "State:", status)
return reconcile.Result{RequeueAfter: time.Second * 5}, nil
}
// 如果该组件是Ready状态,那么判断当前的组件是不是最后一个需要部署的组件,如果是,退出循环。
// 否则部署下一个组件。
if ctrl.last() {
break
}
}
// 更新CR状态,将其设置为Ready状态。
instance.SetState(gpuv1.Ready)
return reconcile.Result{}, nil
}
简单总结一下Reconcile函数所做的事情:
- 获取cluster-policy这个CR。
- 初始化ctrl对象(需要用到cluster-policy中的配置),初始化的过程中将会注册负责安装各组件的函数,在接下来真正部署组件时会调用这些函数。
- 通过for循环,ctrl对象会依次部署各组件,如果部署完某个组件后,发现该组件处于NotReady状态,那么会将事件重新扔进队列中再次处理;如果组件处于Ready状态,那么接着部署下一个组件。
- 如果所有组件都部署成功,那么更新CR状态为Ready。
可以看到,整个安装组件的逻辑还是比较清晰的,接着看看ctrl初始化。
ClusterPolicyController对象的初始化操作
在Reconcile函数中,有这样一行代码:
err = ctrl.init(r, instance)
该行代码是初始化ClusterPolicyController类型的实例ctrl,ctrl是真正执行组件安装的对象。init函数内容如下:
func (n *ClusterPolicyController) init(r *ReconcileClusterPolicy, i *gpuv1.ClusterPolicy) error {
.... // 省略不关心的代码
// 将ClusterPolicy实例保存
n.singleton = i
// 保存ReconcileClusterPolicy实例
n.rec = r
// 初始化当前部署成功的组件的索引
n.idx = 0
// 如果当前没有安装组件的函数注册,那么调用addState函数开始执行注册操作。
// 注册后将会在ClusterPolicyController对象的step函数中依次调用这些函数,各组件将会被部署。
if len(n.controls) == 0 {
promv1.AddToScheme(r.scheme)
secv1.AddToScheme(r.scheme)
// addState函数用户注册安装各组件的函数。
// 注册部署nvidia driver组件的函数。
addState(n, "/opt/gpu-operator/state-driver")
// 注册部署nvidia container toolkit组件的函数。
addState(n, "/opt/gpu-operator/state-container-toolkit")
// 注册部署nvidia device plugin组件的函数。
addState(n, "/opt/gpu-operator/state-device-plugin")
// 注册校验nvidia device plugin是否正常的函数。
addState(n, "/opt/gpu-operator/state-device-plugin-validation")
// 注册部署dcgm exporter组件的函数。
addState(n, "/opt/gpu-operator/state-monitoring")
// 注册部署gfd组件的函数。
addState(n, "/opt/gpu-operator/gpu-feature-discovery")
}
// fetch all nodes and label gpu nodes
// 获取所有节点并且为GPU节点打上标签nvidia.com/gpu.present=true
err = n.labelGPUNodes()
if err != nil {
return err
}
return nil
}
可以看到,init函数最重要的操作就是调用addState函数注册一些函数,这些函数定义了每一个组件的安装逻辑,这些函数将会在ctrl的step函数中使用,这里需要注意组件的添加顺序,组件的安装顺序就是现在的添加顺序。
addState函数
addState函数用于将定义各个组件的安装逻辑的函数注册到ctrl对象中,函数比较简单,主要就是调用addResourcesControls函数,addResourcesControls有两个返回值:
- 各组件所涉及的资源,比如NVIDIA Driver Installer组件包含:DaemonSet、ConfigMap、ServiceAccount、Role、RoleBinding等。
- 定义每种资源的安装逻辑函数,比如:NVIDIA Driver Installer组件涉及资源ServiceAccount、ConfigMap和DaemonSet。其中操作ServiceAccount、ConfigMap函数比较简单,直接创建即可;而操作Daemonset的函数还得根据操作系统类型(例如CentOS 7.x或Ubuntu )设置DaemonSet中Pod Spec的镜像,然后才能提交APIServer创建。
返回的函数和资源都将被保存下来,完成注册操作。
func addState(n *ClusterPolicyController, path string) error {
// TODO check for path
// 返回的res中包含不同种类的k8s资源。
// 返回的ctrl为部署该组件所要执行的一系列函数。
res, ctrl := addResourcesControls(path, n.openshift)
// 将安装该组件所需的函数添加到n.controls这个数组中,完成函数注册。
n.controls = append(n.controls, ctrl)
// 保存返回的资源。
n.resources = append(n.resources, res)
return nil
}
addResourcesControls函数
addResourcesControls函数用于获取给定的目录下的yaml文件,然后通过yaml文件中"kind"字段获取该yaml所描述的k8s资源类型,根据不同的资源类型注册不同的k8s资源处理函数。
func addResourcesControls(path, openshiftVersion string) (Resources, controlFunc) {
res := Resources{}
ctrl := controlFunc{}
log.Info("Getting assets from: ", "path:", path)
// 从给定的目录path下读取所有的文件
manifests := getAssetsFrom(path, openshiftVersion)
// 创建解析yaml文件的工具
s := json.NewYAMLSerializer(json.DefaultMetaFactory, scheme.Scheme,
scheme.Scheme)
reg, _ := regexp.Compile(`\b(\w*kind:\w*)\B.*\b`)
// 循环处理path目录下的文件
for _, m := range manifests {
// 从当前文件中寻找kind关键字,获取k8s资源类型,比如:Daemonset、ServiceAccount等。
kind := reg.FindString(string(m))
slce := strings.Split(kind, ":")
kind = strings.TrimSpace(slce[1])
log.Info("DEBUG: Looking for ", "Kind", kind, "in path:", path)
// 判断kind类型
switch kind {
// 如果是k8s中的ServiceAccount
case "ServiceAccount":
// 将yaml文件的内容反序列化为res.ServiceAccount对象
_, _, err := s.Decode(m, nil, &res.ServiceAccount)
panicIfError(err)
// 请注意ServiceAccount是一个函数,
ctrl = append(ctrl, ServiceAccount)
...... // 省略其他代码
case "DaemonSet":
_, _, err := s.Decode(m, nil, &res.DaemonSet)
panicIfError(err)
ctrl = append(ctrl, DaemonSet)
...... // 省略其他代码
default:
log.Info("Unknown Resource", "Manifest", m, "Kind", kind)
}
}
return res, ctrl
}
以nvidia driver组件为例,与其相关的yaml组件存放在gpu-operator容器中的/opt/gpu-operator/state-driver,该目下的文件如下:
$ ls -l
total 48
-rw-r--r-- 1 yangjunfeng staff 104B 3 10 15:50 0100_service_account.yaml
-rw-r--r-- 1 yangjunfeng staff 259B 3 10 15:50 0200_role.yaml
-rw-r--r-- 1 yangjunfeng staff 408B 3 10 15:50 0300_rolebinding.yaml
-rw-r--r-- 1 yangjunfeng staff 613B 3 10 15:50 0400_configmap.yaml
-rw-r--r-- 1 yangjunfeng staff 1.2K 3 10 15:50 0410_scc.openshift.yaml
-rw-r--r-- 1 yangjunfeng staff 1.9K 3 10 15:51 0500_daemonset.yaml
然后通过for循环依次处理目录下的每个yaml文件,比如:第一次是0100_service_account.yaml,那么经过一个循环后,ctrl数组的内容为:[ServiceAccount],其中ServiceAccount为处理0100_service_account.yaml中的对象的函数,第二次是处理0200_role.yaml,经过该循环后,ctrl数组的内容为:
[ServiceAccount,Role],当对所有文件处理完成后,返回ctrl数组。
ServiceAccount函数和Daemonset函数
每一种k8s资源类型都有一个函数对应,每种函数的处理逻辑各不相同,接下来以ServiceAccount和Daemonset为例。
如果从yaml文件中读取了一个ServiceAccount对象,该对象将由ServiceAccount函数处理,函数内容如下:
func ServiceAccount(n ClusterPolicyController) (gpuv1.State, error) {
state := n.idx
// 获取service account对象,该对象即从yaml中读取的service account对象
obj := n.resources[state].ServiceAccount.DeepCopy()
logger := log.WithValues("ServiceAccount", obj.Name, "Namespace", obj.Namespace)
// 设置Reference
if err := controllerutil.SetControllerReference(n.singleton, obj, n.rec.scheme); err != nil {
return gpuv1.NotReady, err
}
// 创建该service account
if err := n.rec.client.Create(context.TODO(), obj); err != nil {
if errors.IsAlreadyExists(err) {
logger.Info("Found Resource")
return gpuv1.Ready, nil
}
logger.Info("Couldn't create", "Error", err)
return gpuv1.NotReady, err
}
return gpuv1.Ready, nil
}
可以看到,对于一个Servicce Account对象,处理它的函数只是简单的将其与ClusterPolicy关联,然后创建它。如果创建没有问题,那么就返回Ready状态;如果已存在,那么也返回Ready状态,否则返回NotReady状态。
Daemonset函数是需要重点理解的函数,通过它我们可以解释一些现象。
// DaemonSet creates Daemonset resource
func DaemonSet(n ClusterPolicyController) (gpuv1.State, error) {
state := n.idx
// 获取daemonst对象
obj := n.resources[state].DaemonSet.DeepCopy()
logger := log.WithValues("DaemonSet", obj.Name, "Namespace", obj.Namespace)
// 预处理该daemonset对象,这里的预处理是对该daemonset的某些域进行赋值处理,
// 以nvidia driver组件的daemonset(名为nvidia-driver-daemonset)为例,preProcessDaemonSet是将ClusterPolicy这个CR中关于
// nvidia-driver-daemonset的配置赋值到该daemonset对象中。
err := preProcessDaemonSet(obj, n)
if err != nil {
logger.Info("Could not pre-process", "Error", err)
return gpuv1.NotReady, err
}
// 关联该daemonset与ClusterPolicy对象
if err := controllerutil.SetControllerReference(n.singleton, obj, n.rec.scheme); err != nil {
return gpuv1.NotReady, err
}
// 创建该daemonset
if err := n.rec.client.Create(context.TODO(), obj); err != nil {
if errors.IsAlreadyExists(err) {
logger.Info("Found Resource")
return isDaemonSetReady(obj.Name, n), nil
}
logger.Info("Couldn't create", "Error", err)
return gpuv1.NotReady, err
}
// 检查该daemonset是否Ready
return isDaemonSetReady(obj.Name, n), nil
}
判断一个daemonset是否Ready是由isDaemonSetReady函数完成,主要逻辑如下:
- 通过DaemonSet的label寻找该DaemonSet,如果没有搜索到,那么返回NotReady
- 如果该daemonset的NumberUnavailable不为0,那么直接返回NotReady
- 该DaemonSet所控制的pod的状态如果都是Running,返回Ready,否则返回NotReady
func isDaemonSetReady(name string, n ClusterPolicyController) gpuv1.State {
opts := []client.ListOption{
client.MatchingLabels{"app": name},
}
// 通过label获取目标daemonset
log.Info("DEBUG: DaemonSet", "LabelSelector", fmt.Sprintf("app=%s", name))
list := &appsv1.DaemonSetList{}
err := n.rec.client.List(context.TODO(), list, opts...)
if err != nil {
log.Info("Could not get DaemonSetList", err)
}
// 如果没有发现daemonset,返回NotReady
log.Info("DEBUG: DaemonSet", "NumberOfDaemonSets", len(list.Items))
if len(list.Items) == 0 {
return gpuv1.NotReady
}
ds := list.Items[0]
log.Info("DEBUG: DaemonSet", "NumberUnavailable", ds.Status.NumberUnavailable)
// 如果该daemonset的NumberUnavailable不为0,那么直接返回NotReady
if ds.Status.NumberUnavailable != 0 {
return gpuv1.NotReady
}
// 只有所有pod都是Running时,该daemonset才算Ready
return isPodReady(name, n, "Running")
}
基于上面的代码,现在有一个问题可以讨论一下:当在所有GPU节点上安装nvidia driver时,如果有一个节点安装失败了,那么会发生什么情况?——从代码中可以知道,只有当该DaemonSet所有pod都处于Running时,该DaemonSet才是Ready状态,所以如果有一个节点安装失败了,那么DaemonSet在该节点的pod必然是非Running状态,此时该DaemonSet是NotReady状态,也就是安装nvidia driver组件获得状态是NotReady,那么GPU Operator将不会继续安装接下来的组件。
ClusterPolicyController的部署组件操作
ctrl部署各组件的操作是由其step函数完成的,如果该函数被调用一次,那么就有一个组件被安装。
func (n *ClusterPolicyController) step() (gpuv1.State, error) {
// n.idx指示当前待安装的组件的索引
// 通过该索引可以获取安装组件的函数列表,例如我们之前举的例子,nvidia driver组件的
// 目录下有Service Account、Role、RoleBinding、ConfigMap、Daemonset等对象
// 那么n.controls[n.idx]中函数列表为:[ServiceAccount,Role,RoleBinding,ConfigMap,Daemonset]
// 然后依次执行列表中的函数,如果有一个函数返回NotReady,那么将不会创建其后面的对象,并返回
// NotReady
for _, fs := range n.controls[n.idx] {
stat, err := fs(*n)
if err != nil {
return stat, err
}
if stat != gpuv1.Ready {
return stat, nil
}
}
// 索引值加1,指向下一个待安装的组件
n.idx = n.idx + 1
// 如果所有函数都返回Ready状态,那么才返step函数才返回Ready状态。
return gpuv1.Ready, nil
}
问题探讨
关于NVIDIA GPU Operator,有一些问题可以讨论一下。
问题1: 各个组件都是以DaemonSet方式进行部署,那么NVIDIA GPU Operator是一次把所有DaemonSet都部署到集群中吗?
答:从前面的源码分析中可以看到,NVIDIA GPU Operator是一个组件一个组件部署的,如果前一个组件部署失败,后一个组件不会部署,自然而然后一个组件的DaemonSet也不会部署下去。
问题2:假设现在集群有三个GPU节点,在安装NVIDIA GPU Driver时,有两个GPU节点安装成功,一个GPU节点安装不成功,后续组件会接着安装吗?
答:不会,从前面的源码分析中可以看到,某个DaemonSet如果是Ready需要满足其所有Pod的状态都是Running,现在有一个节点安装失败,那么该DaemonSet在节点上部署的Pod将不会是Running状态,该DaemonSet返回NotReady状态,导致组件安装失败,后续组件将不会安装。
问题3:如果NVIDIA GPU Operator已经成功在集群中运行,并且集群中GPU节点已成功安装各个组件,如果此时有一个新的GPU节点加入到集群中,因为此时集群中已部署各组件,会不会出现安装GPU驱动的Pod还未处于Running,而NVIDIA Device plugin的Pod先处于Running,然后检查到节点没有驱动,NVIDIA Device plugin这个Pod进入Error状态?
答:不会,后面的组件的Pod中都存在一个InitContainer,都会做相应的检查,以NVIDIA Container Toolkit为例,其Pod中存在一个InitContainer用于检查节点GPU驱动是否安装成功。
initContainers:
- args:
- export SYS_LIBRARY_PATH=$(ldconfig -v 2>/dev/null | grep -v '^[[:space:]]' |
cut -d':' -f1 | tr '[[:space:]]' ':'); export NVIDIA_LIBRARY_PATH=/run/nvidia/driver/usr/lib/x86_64-linux-gnu/:/run/nvidia/driver/usr/lib64;
export LD_LIBRARY_PATH=${SYS_LIBRARY_PATH}:${NVIDIA_LIBRARY_PATH}; echo ${LD_LIBRARY_PATH};
export PATH=/run/nvidia/driver/usr/bin/:${PATH}; until nvidia-smi; do echo waiting
for nvidia drivers to be loaded; sleep 5; done
目前的不足
NVIDIA GPU Operator的优点这里有不做多的介绍,有兴趣可以参考官方文档。这里还是想分析一下NVIDIA GPU Operator当前存在的一些不足,在本系列之前的文章中,我们分析了每个组件并手动安装了这些组件,也对一些组件的安装做出了缺点说明,现在总结一下这些缺点:
- 基于容器安装NVIDIA GPU驱动的方式目前还不太稳定,在GPU节点上如果重启Pod,会导致Pod重启失败,报驱动正在使用的错误,解决办法只有重启节点。
- 基于容器安装NVIDIA GPU驱动的方式目前还是区分操作系统类型,比如基于CentOS7基础docker镜像构建的docker镜像不能运行在操作系统为Ubuntu的k8s节点上。
- 基于容器安装NVIDIA Container Toolkit方式目前还不能自动识别节点的Container Runtime是docker还是containerd并执行相应的安装操作,这需要用户在安装NVIDIA GPU Operator时指定Container Runtime,同时也造成了集群的节点必须安装相同的Container Runtime。
- 在监控方面,目前NVIDIA GPU Operator只能提供以节点维度的GPU资源监控方案,而缺乏基于Pod或者基于集群维度的GPU资源监控仪表盘。
总结
本篇文章从源码的角度分析了NVIDIA GPU Operator,并依据源码给了一些问题的探讨,最后对NVIDIA GPU Operator当前的不足作了一下说明。