nvidia-k8s-device-plugin源碼分析

1.引言

nvidia-k8s-device-plugin代碼由go語言編寫,在此確實要讚歎一下go語言的簡潔和強大,想必以後會有越來越多的人喜歡上這門語言。

當然,如果想了解一個程序的代碼,首先梳理一下每個文件的作用:

1.main.go:作爲程序入口

2.nvidia.go:放置所有調用了nvml有關的函數代碼

3.watcher.go:定義監視器的代碼

4.server.go:實現與k8s-device-plugin有關流程的代碼

在server.go中定義了NvidiaDevicePlugin 結構體,該結構體成員作用如下:

type NvidiaDevicePlugin struct {
	devs   []*pluginapi.Device   # api.protobuf裏定義的一個數組,每個成員包括設備ID和其health信息
	socket string   # nvidia-device-plugin監聽端口路徑,實際爲/var/lib/kubelet/device-plugins/nvidia.sock

	stop   chan interface{}  # 接受啓停命令的管道
	health chan *pluginapi.Device  # 接受不健康設備的管道,發來pluginapi.Device的結構

	server *grpc.Server   # grcpserver,用來保存於kubelet的通訊
}

2.執行邏輯

main.go作爲程序入口,首次執行代碼邏輯如下。

1.首先加載nvml庫,如果沒有問題進行下一步,有問題則報錯

log.Println("Loading NVML")
	if err := nvml.Init(); err != nil {
		log.Printf("Failed to initialize NVML: %s.", err)
		log.Printf("If this is a GPU node, did you set the docker default runtime to `nvidia`?")
		log.Printf("You can check the prerequisites at: https://github.com/NVIDIA/k8s-device-plugin#prerequisites")
		log.Printf("You can learn how to set the runtime at: https://github.com/NVIDIA/k8s-device-plugin#quick-start")

		select {}
	} 
	defer func() { log.Println("Shutdown of NVML returned:", nvml.Shutdown()) }()

2.獲得當前宿主機設備數量,若爲0則log出等待信息

log.Println("Fetching d  evices.")
	if len(getDevices()) == 0 {                       
		log.Println("No devices found. Waiting indefinitely.")
		select {}
	}

3.創建對於/var/lib/kubelet/device-plugins/文件夾的fsnotify監視器watcher,監視了所有的文件更改操作。

log.Println("Starting FS watcher.")
	watcher, err := newFSWatcher(pluginapi.DevicePluginPath) //constants.go->"/var/lib/kubelet/device-plugins/",監視了所有的文件更改操作
	if err != nil {
		log.Println("Failed to created FS watcher.")
		os.Exit(1)
	}
	defer watcher.Close()

4.創建系統調用信號監視器sigs,監視系統調用信號

defer watcher.Close()

	log.Println("Starting OS watcher.")
	sigs := newOSWatcher(syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)  //監聽信號,將系統的對應信號發送給sigs

5.監視deviceplugin的狀態和系統信號,並作出相應反應

for循環L有兩個功能塊組成:

1)重啓模塊:

如果是第一次啓動則創建新的NvidiaDevicePlugin結構體並填充信息,開啓NvidiaDevicePlugin服務,否則停止之前的deviceplugin並重新創建

2)監視器模塊:

針對watcher和sigs的傳來不同信號的情況針對性處理,直至收到系統發來的停止信號則退出。

	restart := true
	var devicePlugin *NvidiaDevicePlugin

L:
	for {
		if restart {
			if devicePlugin != nil {
				devicePlugin.Stop()
			}
                    //如果還沒有創建deviceplugin則創建,否則就停止原來的
			devicePlugin = NewNvidiaDevicePlugin() 
                    //返回一個結構體,裏面包含NvidiaDevicePlugin{ devs,socket,stop,health}
			if err := devicePlugin.Serve(); err != nil {    
                    //開啓NvidiaDevicePlugin的服務程序,並檢查和kubelet的連通性,並
                    //開啓健康監測,並向kubelet註冊設備
				log.Println("Could not contact Kubelet, retrying. Did you enable the device plugin feature gate?")
				log.Printf("You can check the prerequisites at: https://github.com/NVIDIA/k8s-device-plugin#prerequisites")
				log.Printf("You can learn how to set the runtime at: https://github.com/NVIDIA/k8s-device-plugin#quick-start")
			} else {
				restart = false
			}
		}

		select {
		case event := <-watcher.Events:
			if event.Name == pluginapi.KubeletSocket && event.Op&fsnotify.Create == fsnotify.Create {
				log.Printf("inotify: %s created, restarting.", pluginapi.KubeletSocket)
				restart = true //若有重新創建的行爲則重啓
			}

		case err := <-watcher.Errors:  //出錯則報錯
			log.Printf("inotify: %s", err)

		case s := <-sigs:   //若有系統調用信號傳來
			switch s {
			case syscall.SIGHUP:  //重啓信號
				log.Println("Received SIGHUP, restarting.")
				restart = true
			default:   //其餘信號都停止plugin服務
				log.Printf("Received signal \"%v\", shutting down.", s)
				devicePlugin.Stop()
				break L
			}
		}
	}

下面main.go中每個步驟中關鍵的函數:

分析之前我們先看一下main.go和nvidia.go同時引入的包pluginapi "k8s.io/kubernetes/pkg/kubelet/apis/deviceplugin/v1beta1"

,該路徑下有一個api.pb.go和constants.go兩個文件,包名同樣爲v1beta1,api.pb.go爲grpc分析api.proto自動生成,constants.go中定義了很多接下來的要用到的常量,列舉在這裏

// \vendor\k8s.io\kubernetes\pkg\kubelet\apis\deviceplugin\v1beta1\constants.go
package v1beta1

const (
	// Healthy means that the device is healty
	Healthy = "Healthy"
	// UnHealthy means that the device is unhealthy
	Unhealthy = "Unhealthy"

	// Current version of the API supported by kubelet
	Version = "v1beta1"
	// DevicePluginPath is the folder the Device Plugin is expecting sockets to be on
	// Only privileged pods have access to this path
	// Note: Placeholder until we find a "standard path"
	DevicePluginPath = "/var/lib/kubelet/device-plugins/"
	// KubeletSocket is the path of the Kubelet registry socket
	KubeletSocket = DevicePluginPath + "kubelet.sock"
	// Timeout duration in secs for PreStartContainer RPC
	KubeletPreStartContainerRPCTimeoutInSecs = 30
)

var SupportedVersions = [...]string{"v1beta1"}

步驟1:

只有一個nvml.Init(),從字面意思可以知道是nvml進行了一些初始化操作。

步驟2:

1.getDevices()

// nvidia.go
func getDevices() []*pluginapi.Device {
	n, err := nvml.GetDeviceCount()
	check(err)

	var devs []*pluginapi.Device
	for i := uint(0); i < n; i++ {
		d, err := nvml.NewDeviceLite(i)
		check(err)
		devs = append(devs, &pluginapi.Device{
			ID:     d.UUID,
			Health: pluginapi.Healthy,
		})
	}

該函數定義在nvidia.go中,首先其調用了nvml.GetDeviceCount()獲得當前宿主機設備數,將所有設備的信息加入devs數組,該數組每個成員是一個pluginapi.Device結構體,其ID被初始化爲每個設備的UUID,Health字段初始化爲"Healthy"(在constants.go中的const字段定義的Healthy = "Healthy")

步驟3:

1.newFSWatcher(pluginapi.DevicePluginPath)

該函數定義在watchers.go中,其主要功能是創建一個監視pluginapi.DevicePluginPath路徑下的文件變動的watcher並返回,從constants.go中的定義我們可以看到,其監視的路徑爲/var/lib/kubelet/device-plugins/,即同時監視了kubelet.sock和nvidia.sock

// watchers.go
func newFSWatcher(files ...string) (*fsnotify.Watcher, error) {
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		return nil, err
	}

	for _, f := range files {
		err = watcher.Add(f)
		if err != nil {
			watcher.Close()
			return nil, err
		}
	}

	return watcher, nil
}

步驟4:

1.newOSWatcher(syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)

該函數同樣定義在watchers.go中,其返回一個監視系統發來的SIGHUP、SIGINT、SIGTERM、SIGQUIT信號的watcher,該watcher實際上是一個只有一個緩存且成員爲os.Signal的chan。main.go的L循環則監視該chan並做出相應的反應,

// watchers.go
func newOSWatcher(sigs ...os.Signal) chan os.Signal {
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, sigs...)   //sigs:syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT,監聽

	return sigChan
}

步驟5:

1. devicePlugin.Stop()

定義在server.go中,停止grcp服務並清理現場。

// server.go
func (m *NvidiaDevicePlugin) Stop() error {
	if m.server == nil {
		return nil
	}

	m.server.Stop()
	m.server = nil
	close(m.stop)

	return m.cleanup()
}

2.NewNvidiaDevicePlugin()

返回一個結構體,裏面包含NvidiaDevicePlugin{ devs,socket,stop,health},devs是getDevices()返回的devs,socket是server.go中定義的常量serverSock = pluginapi.DevicePluginPath + "nvidia.sock",即/var/lib/kubelet/device-plugins/nvidia.sock,stop是一個可以接受任何類型輸入的無緩存chan,health是可以可以接受*pluginapi.Device類型輸入的無緩存chan,其主要作用的及時將不健康的device報告給kubelet。 //待確定

//server.go
func NewNvidiaDevicePlugin() *NvidiaDevicePlugin {
	return &NvidiaDevicePlugin{
		devs:   getDevices(),    //改
		socket: serverSock,
		stop:   make(chan interface{}),
		health: make(chan *pluginapi.Device),
	}
}

3.devicePlugin.Serve()

該函數是main.go中最重要的函數,其負責開啓NvidiaDevicePlugin的服務程序,並開啓健康監測,並向kubelet註冊設備。

首先其調用了NvidiaDevicePlugin結構體的Start()方法,該方法定義在nvidia.go中,作用爲開啓NvidiaDevicePlugin的服務程序,檢查和kubelet的連通性,並開啓健康監測。

然後再調用NvidiaDevicePlugin結構體的Register(pluginapi.KubeletSocket, resourceName)方法,兩個參數pluginapi.KubeletSocket是constants.go中的常量,值爲/var/lib/kubelet/device-plugins/kubelet.sock,而resourceName是該函數中定義的一個常量,爲"nvidia.com/gpu",結合k8splugin的流程我們很容易知道這個函數的作用就是將"nvidia.com/gpu"這個資源類型通過kubelet.sock註冊到kubelet上。

// nvidia.go
func (m *NvidiaDevicePlugin) Serve() error {
	err := m.Start()    //開啓NvidiaDevicePlugin的服務程序,並開啓健康監測
	if err != nil {
		log.Printf("Could not start device plugin: %s", err)
		return err
	}
	log.Println("Starting to serve on", m.socket)

	err = m.Register(pluginapi.KubeletSocket, resourceName)
	if err != nil {
		log.Printf("Could not register device plugin: %s", err)
		m.Stop()
		return err
	}
	log.Println("Registered device plugin with Kubelet")

	return nil
}

接下來我們深入分析Start()方法和Register()方法,這兩個是k8s-plugin流程的核心。

首先我們回憶一下kubernetes實現plugin要求設備廠商遵從的機制(參考https://www.kubernetes.org.cn/4391.html):

  1. 廠商自行實現一個管理設備資源的程序,部署到相應的節點上,我們稱之爲插件;
  2. 插件需要向kubelet註冊,註冊內容包含自己的endpoint(endpoint就是一個用於通信的地址)、version、resource_name和DevicePluginOptions類型的options,而即這個類型僅有pre_start_required一個bool型成員,註釋解釋的很明白,表示PreStartContainer這個操作是否要在每個container啓動前調用,其實就是啓動容器前先通知插件做一下準備,多一些擴展性
  3. kubelet連接插件的endpoint,就此kubelet和插件就建立了聯繫;
  4. kubelet監聽/var/lib/kubelet/device-plugins/kubelet.sock(unix sockets)這個地址,插件監聽的也是類似的地址,只是地址變成了/var/lib/kubelet/device-plugins/nvidia.sock

這樣我們便可以明白Start()方法和Register()方法到底在做些什麼

3.1 Start()

首先其調用cleanup()方法刪除/var/lib/kubelet/device-plugins/nvidia.sock,接下來進行標準的grpc調用操作,首先綁定服務端程序監聽的sock(nvidia.sock),然後註冊一個新的grpc_server對象,賦值到NvidiaDevicePlugin的server成員上,然後註冊該grpc服務,之後用go關鍵字起一個獨立的監聽服務,然後測試一下服務是否正常工作,然後再用go關鍵字啓動獨立的健康檢測程序。

// server.go
// Start starts the gRPC server of the device plugin
func (m *NvidiaDevicePlugin) Start() error {
	//NvidiaDevicePlugin開啓自身的grpc服務端程序
	err := m.cleanup()   //刪除文件夾下存在的nvidia.sock
	if err != nil {
		return err
	}

	sock, err := net.Listen("unix", m.socket)   //創建服務端程序監聽的sock
	if err != nil {
		return err
	}
	m.server = grpc.NewServer([]grpc.ServerOption{}...)  //註冊一個新的grpc_server
	pluginapi.RegisterDevicePluginServer(m.server, m) //將deviceplugin這種類型的grpc服務指定由NvidiaDevicePlugin實現

	go m.server.Serve(sock)   //創建獨立服務監聽

	// Wait for server to start by launching a blocking connexion
	conn, err := dial(m.socket, 5*time.Second)    //?創建一個 gRPC channel 和服務器交互連接,試一下是否服務器是否創建成功
	if err != nil {
		return err
	}  //出問題則報錯
	conn.Close()     //關閉連接

	go m.healthcheck()   //開始健康監測

	return nil
}

3.1.1 healthcheck()

healthcheck()同樣定義在server.go中,目前健康檢查僅支持xids,其首先定義了xids,是一個成員爲pluginapi.Device的chan,然後用go關鍵字調用watchXIDs(ctx, m.devs, xids),最後用一個for循環select做檢查,若NvidiaDevicePlugin中stop收到信息,則調用cancel()函數並返回,若xids中有信息了,則將調用m.unhealth(dev)將其放入m.health成員管道中。

// server.go
func (m *NvidiaDevicePlugin) healthcheck() {
	disableHealthChecks := strings.ToLower(os.Getenv(envDisableHealthChecks))
	if disableHealthChecks == "all" {
		disableHealthChecks = allHealthChecks   //目前健康檢測僅支持xids
	}

	ctx, cancel := context.WithCancel(context.Background())

	var xids chan *pluginapi.Device
	if !strings.Contains(disableHealthChecks, "xids") {
		xids = make(chan *pluginapi.Device)
		go watchXIDs(ctx, m.devs, xids)
	}

	for {
		select {
		case <-m.stop:                    //取消健康檢查
			cancel()               
			return
		case dev := <-xids:              
			m.unhealthy(dev)              //如果xids中有內容了,則將其中的設備加入m.healthy中
		}
	}
}

3.1.1.1 watchXIDs(ctx context.Context, devs []*pluginapi.Device, xids chan<- *pluginapi.Device)

首先其調用nvml.RegisterEventForDevice(eventSet, nvml.XidCriticalError, d.ID)爲每個設備開啓驅動端的健康監測,然後根據驅動返回的設備狀態碼決定是否要把不健康設備傳入NvidiaDevicePlugin的health成員管道中。

// nvidia.go
func watchXIDs(ctx context.Context, devs []*pluginapi.Device, xids chan<- *pluginapi.Device) {
	eventSet := nvml.NewEventSet()
	defer nvml.DeleteEventSet(eventSet)

	for _, d := range devs {    //爲每一個devs開啓驅動端健康檢測
		err := nvml.RegisterEventForDevice(eventSet, nvml.XidCriticalError, d.ID)
		if err != nil && strings.HasSuffix(err.Error(), "Not Supported") {
			log.Printf("Warning: %s is too old to support healthchecking: %s. Marking it unhealthy.", d.ID, err)

			xids <- d
			continue
		}

		if err != nil {
			log.Panicln("Fatal:", err)
		}
	}

	for {
		select {
		case <-ctx.Done():
			return
		default:
		}  //如果工作完成了就退出健康檢查

		e, err := nvml.WaitForEvent(eventSet, 5000)
		if err != nil && e.Etype != nvml.XidCriticalError {
			continue
		}  //錯誤不是致命錯誤進行新一輪

		// FIXME: formalize the full list and document it.
		// http://docs.nvidia.com/deploy/xid-errors/index.html#topic_4
		// Application errors: the GPU should still be healthy
		if e.Edata == 31 || e.Edata == 43 || e.Edata == 45 {
			continue
		}//健康的
        
		if e.UUID == nil || len(*e.UUID) == 0 {
			// All devices are unhealthy,將所有的設備號都放入xid——channel中並進行下一輪
			for _, d := range devs {
				xids <- d
			}
			continue
		}
        //有錯誤將所有有錯誤的設備都放進去
		for _, d := range devs {
			if d.ID == *e.UUID {
				xids <- d
			}
		}
	}
}

3.2 Register(pluginapi.KubeletSocket, resourceName)

Register(kubeletEndpoint, resourceName string)函數首先通過kubelet.sock建立與kubelet的連接,然後調用kubelet的服務端預預先定義的GRPC方法Register(context.Background(), reqt)方法,將pluginapi.RegisterRequest類型的設備信息reqt註冊至kubelet,包括:

reqt := &pluginapi.RegisterRequest{
		Version:      pluginapi.Version,  // v1beta1
		Endpoint:     path.Base(m.socket), // /var/lib/kubelet/device-plugins/nvidia.sock
		ResourceName: resourceName,  // nvidia.com/gpu
	}
// server.go
// Register registers the device plugin for the given resourceName with Kubelet.
func (m *NvidiaDevicePlugin) Register(kubeletEndpoint, resourceName string) error {
	conn, err := dial(kubeletEndpoint, 5*time.Second)  //與kubelet建立連接
	if err != nil {
		return err
	}
	defer conn.Close()

	client := pluginapi.NewRegistrationClient(conn)   //獲得並註冊遠程調用的註冊方法
	reqt := &pluginapi.RegisterRequest{
		Version:      pluginapi.Version,
		Endpoint:     path.Base(m.socket),
		ResourceName: resourceName,
	} 

	_, err = client.Register(context.Background(), reqt)   //將設備信息註冊到kubelet中
	if err != nil {
		return err
	}
	return nil
}

以上便是Nvidia-Device-Plugin中向kubelet註冊插件操作、健康檢查程序和插件端服務監聽的建立過程,那麼我們可以發現,上述代碼實現瞭如何讓kubelet發現自己,而根據k8s-deviceplugin的流程和proto的定義,k8s要求插件端實現ListAndWatch、Allocate、GetDevicePluginOptions和PreStartContainer四個操作用來被kubelet操作,下面我們看一下這四個方法是如何實現的,當然,他們都定義在server.go中。

1.ListAndWatch()

函數中僅有一個for循環,裏面的select關鍵字表明其在監控兩個chan,一個是stop信號,另一個是health信號,如果health通道被填入內容,則說明有設備處於不健康的狀態,那麼將調用Send函數將設備號報告給kubelet。此處有個註釋是FIXME,內容是現階段無法讓失效設備恢復,在未來版本應該會改進這個問題

// server.go
func (m *NvidiaDevicePlugin) ListAndWatch(e *pluginapi.Empty, s pluginapi.DevicePlugin_ListAndWatchServer) error {
	s.Send(&pluginapi.ListAndWatchResponse{Devices: m.devs})

	for {
		select {
		case <-m.stop:
			return nil
		case d := <-m.health:
			// FIXME: there is no way to recover from the Unhealthy state.
			d.Health = pluginapi.Unhealthy
			s.Send(&pluginapi.ListAndWatchResponse{Devices: m.devs})
		}
	}
}

2.Allocate()

該函數接受kubelet傳來的設備分配請求reqs,並返回要在容器中設置的設備的環境變量NVIDIA_VISIBLE_DEVICES的值,其值是由多個設備ID由 ‘,' 連接而成的,結合nvidia-docker的代碼我們可以知道,k8s-plugin和nvidia-docker之間的交互是通過環境變量發生的,nvidia-docker中的libnvidia-container的prestarthook在運行時通過容器的環境變量設置來決定mount哪一個設備進入容器,這是一個系統的解決方案問題,所以mount這個動作發生在nvidia-docker而不是k8s這裏也情有可原。

// server.go
// Allocate which return list of devices.傳回環境變量與nvidia進行交互
func (m *NvidiaDevicePlugin) Allocate(ctx context.Context, reqs *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) {
	devs := m.devs
	responses := pluginapi.AllocateResponse{}
	for _, req := range reqs.ContainerRequests {
		response := pluginapi.ContainerAllocateResponse{
			Envs: map[string]string{
				"NVIDIA_VISIBLE_DEVICES": strings.Join(req.DevicesIDs, ","),
			},
		}

		for _, id := range req.DevicesIDs {
			if !deviceExists(devs, id) {
				return nil, fmt.Errorf("invalid allocation request: unknown device: %s", id)
			}
		}

		responses.ContainerResponses = append(responses.ContainerResponses, &response)
	}

	return &responses, nil
}

3.PreStartContainer()

什麼都沒有返回,現在應該是還沒有實現這個函數的需求

// server.go
func (m *NvidiaDevicePlugin) PreStartContainer(context.Context, *pluginapi.PreStartContainerRequest) (*pluginapi.PreStartContainerResponse, error) {
	return &pluginapi.PreStartContainerResponse{}, nil
}

4.GetDevicePluginOptions()

同樣什麼都沒有返回

// server.go
func (m *NvidiaDevicePlugin) GetDevicePluginOptions(context.Context, *pluginapi.Empty) (*pluginapi.DevicePluginOptions, error) {

	return &pluginapi.DevicePluginOptions{}, nil
}

3.總結

梳理一下nvidia-device-plugin的整體邏輯:

1.getCount()函數通過調用NVML接口,列出當前主機的所有設備信息,從而調用事先定義grpc函數Register(),來向kubelet註冊自己。

2.kubelet通過grpc調用nvidia-device-plugin實現的Allocate()函數,向容器注入待分配設備的環境變量信息,容器創建時nvidia-docker通過調用libcontainer的prestartHook獲取環境變量信息,在容器中掛載對應的設備。

3.通過調用nvml實現了一個健康檢查程序,該健康檢查程序負責監控本機GPU設備的健康情況,如果某個設備出了問題,該健康檢查程序通過向NvidiaDevicePlugin的health通道發送不健康設備的信息,此時觸發ListAndWatch函數的報告程序,將不健康設備通過grpc結構從kubelet.sock發送給kubelet,kubelet收到不健康設備信息後將處理所有受到該設備影響的pod。

4.目前健康檢查僅支持xids,且沒有支持重新恢復健康狀態的設備的重新註冊。

 

如果有錯誤之處還望指出。

4.參考資料

1.https://www.kubernetes.org.cn/4391.html

2.nvidia-k8s-device-plugin源碼:Latest commit 2d56964 on 23 Aug

3.nvidia-docker源碼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章