NVIDIA GPU Operator分析六:NVIDIA GPU Operator原理分析

背景

我们知道,如果在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函数),这个函数的运行逻辑需要根据业务实现。

NVIDIA GPU Operator分析六:NVIDIA GPU Operator原理分析

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才会在节点上运行。

源码介绍

前提说明

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当前的不足作了一下说明。

上一篇:lunix的查看Tomcat目录下日志的快速操作


下一篇:直面问题,不要逃避