雲計算

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函數),這個函數的運行邏輯需要根據業務實現。

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

Leave a Reply

Your email address will not be published. Required fields are marked *