44丨KubernetesGPU管理与DevicePlugin机制

思考并回答以下问题:

2016年,随着AlphaGo的走红和TensorFlow项目的异军突起,一场名为AI的技术革命迅速从学术界蔓延到了工业界,所谓的AI元年,就此拉开帷幕。

当然,机器学习或者说人工智能,并不是什么新鲜的概念。而这次热潮的背后,云计算服务的普及与成熟,以及算力的巨大提升,其实正是将人工智能从象牙塔带到工业界的一个重要推手。

而与之相对应的,从2016年开始,Kubernetes社区就不断收到来自不同渠道的大量诉求,希望能够在Kubernetes集群上运行TensorFlow等机器学习框架所创建的训练(Training)和服务(Serving)任务。而这些诉求中,除了前面我为你讲解过的Job、Operator等离线作业管理需要用到的编排概念之外,还有一个亟待实现的功能,就是对GPU等硬件加速设备管理的支持。

不过,正如同TensorFlow之于Google的战略意义一样,GPU支持对于Kubernetes项目来说,其实也有着超过技术本身的考虑。所以,尽管在硬件加速器这个领域里,Kubernetes上游有着不少来自NVIDIA和Intel等芯片厂商的工程师,但这个特性本身,却从一开始就是以GoogleCloud的需求为主导来推进的。

而对于云的用户来说,在GPU的支持上,他们最基本的诉求其实非常简单:我只要在Pod的YAML里面,声明某容器需要的GPU个数,那么Kubernetes为我创建的容器里就应该出现对应的GPU设备,以及它对应的驱动目录。

以NVIDIA的GPU设备为例,上面的需求就意味着当用户的容器被创建之后,这个容器里必须出现如下两部分设备和目录:

1,GPU设备,比如/dev/nvidia0;

2,GPU驱动目录,比如/usr/local/nvidia/*

其中,GPU设备路径,正是该容器启动时的Devices参数;而驱动目录,则是该容器启动时的Volume参数。所以,在Kubernetes的GPU支持的实现里,kubelet实际上就是将上述两部分内容,设置在了创建该容器的CRI(ContainerRuntimeInterface)参数里面。这样,等到该容器启动之后,对应的容器里就会出现GPU设备和驱动的路径了。

不过,Kubernetes在Pod的API对象里,并没有为GPU专门设置一个资源类型字段,而是使用了一种叫作ExtendedResource(ER)的特殊字段来负责传递GPU的信息。比如下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
kind: Pod
metadata:
name: cuda-vector-add
spec:
restartPolicy: OnFailure
containers:
- name: cuda-vector-add
image: "k8s.gcr.io/cuda-vector-add:v0.1"
resources:
limits:
nvidia.com/gpu: 1

可以看到,在上述Pod的limits字段里,这个资源的名称是nvidia.com/gpu,它的值是1。也就是说,这个Pod声明了自己要使用一个NVIDIA类型的GPU。

而在kube-scheduler里面,它其实并不关心这个字段的具体含义,只会在计算的时候,一律将调度器里保存的该类型资源的可用量,直接减去Pod声明的数值即可。所以说,ExtendedResource,其实是Kubernetes为用户设置的一种对自定义资源的支持。

当然,为了能够让调度器知道这个自定义类型的资源在每台宿主机上的可用量,宿主机节点本身,就必须能够向APIServer汇报该类型资源的可用数量。在Kubernetes里,各种类型的资源可用量,其实是Node对象Status字段的内容,比如下面这个例子:

1
2
3
4
5
6
7
8
9
apiVersion: v1
kind: Node
metadata:
name: node-1
...
Status:
Capacity:
cpu: 2
memory: 2049008Ki

而为了能够在上述Status字段里添加自定义资源的数据,你就必须使用PATCHAPI来对该Node对象进行更新,加上你的自定义资源的数量。这个PATCH操作,可以简单地使用curl命令来发起,如下所示:
1
2
3
4
5
6
7
8
# 启动 Kubernetes 的客户端 proxy,这样你就可以直接使用 curl 来跟 Kubernetes  的API Server 进行交互了
$ kubectl proxy

# 执行 PACTH 操作
$ curl --header "Content-Type: application/json-patch+json" \
--request PATCH \
--data '[{"op": "add", "path": "/status/capacity/nvidia.com/gpu", "value": "1"}]' \
http://localhost:8001/api/v1/nodes/<your-node-name>/status

PATCH操作完成后,你就可以看到Node的Status变成了如下所示的内容:
1
2
3
4
5
6
7
8
apiVersion: v1
kind: Node
...
Status:
Capacity:
cpu: 2
memory: 2049008Ki
nvidia.com/gpu: 1

这样在调度器里,它就能够在缓存里记录下node-1上的nvidia.com/gpu类型的资源的数量是1。

当然,在Kubernetes的GPU支持方案里,你并不需要真正去做上述关于ExtendedResource的这些操作。在Kubernetes中,对所有硬件加速设备进行管理的功能,都是由一种叫作DevicePlugin的插件来负责的。这其中,当然也就包括了对该硬件的ExtendedResource进行汇报的逻辑。

Kubernetes的DevicePlugin机制,我可以用如下所示的一幅示意图来和你解释清楚。

我们先从这幅示意图的右侧开始看起。

首先,对于每一种硬件设备,都需要有它所对应的DevicePlugin进行管理,这些DevicePlugin,都通过gRPC的方式,同kubelet连接起来。以NVIDIAGPU为例,它对应的插件叫作NVIDIAGPUdeviceplugin。

这个DevicePlugin会通过一个叫作ListAndWatch的API,定期向kubelet汇报该Node上GPU的列表。比如,在我们的例子里,一共有三个GPU(GPU0、GPU1和GPU2)。这样,kubelet在拿到这个列表之后,就可以直接在它向APIServer发送的心跳里,以ExtendedResource的方式,加上这些GPU的数量,比如nvidia.com/gpu=3。所以说,用户在这里是不需要关心GPU信息向上的汇报流程的。

需要注意的是,ListAndWatch向上汇报的信息,只有本机上GPU的ID列表,而不会有任何关于GPU设备本身的信息。而且kubelet在向APIServer汇报的时候,只会汇报该GPU对应的ExtendedResource的数量。当然,kubelet本身,会将这个GPU的ID列表保存在自己的内存里,并通过ListAndWatchAPI定时更新。

而当一个Pod想要使用一个GPU的时候,它只需要像我在本文一开始给出的例子一样,在Pod的limits字段声明nvidia.com/gpu:1。那么接下来,Kubernetes的调度器就会从它的缓存里,寻找GPU数量满足条件的Node,然后将缓存里的GPU数量减1,完成Pod与Node的绑定。

这个调度成功后的Pod信息,自然就会被对应的kubelet拿来进行容器操作。而当kubelet发现这个Pod的容器请求一个GPU的时候,kubelet就会从自己持有的GPU列表里,为这个容器分配一个GPU。此时,kubelet就会向本机的DevicePlugin发起一个Allocate()请求。这个请求携带的参数,正是即将分配给该容器的设备ID列表。

当DevicePlugin收到Allocate请求之后,它就会根据kubelet传递过来的设备ID,从DevicePlugin里找到这些设备对应的设备路径和驱动目录。当然,这些信息,正是DevicePlugin周期性的从本机查询到的。比如,在NVIDIADevicePlugin的实现里,它会定期访问nvidia-docker插件,从而获取到本机的GPU信息。

而被分配GPU对应的设备路径和驱动目录信息被返回给kubelet之后,kubelet就完成了为一个容器分配GPU的操作。接下来,kubelet会把这些信息追加在创建该容器所对应的CRI请求当中。这样,当这个CRI请求发给Docker之后,Docker为你创建出来的容器里,就会出现这个GPU设备,并把它所需要的驱动目录挂载进去。

至此,Kubernetes为一个Pod分配一个GPU的流程就完成了。

对于其他类型硬件来说,要想在Kubernetes所管理的容器里使用这些硬件的话,也需要遵循上述DevicePlugin的流程来实现如下所示的Allocate和ListAndWatchAPI:

1
2
3
4
5
6
7
8
9
10
service DevicePlugin {
// ListAndWatch returns a stream of List of Devices
// Whenever a Device state change or a Device disappears, ListAndWatch
// returns the new list
rpc ListAndWatch(Empty) returns (stream ListAndWatchResponse) {}
// Allocate is called during container creation so that the Device
// Plugin can run device specific operations and instruct Kubelet
// of the steps to make the Device available in the container
rpc Allocate(AllocateRequest) returns (AllocateResponse) {}
}

目前,Kubernetes社区里已经实现了很多硬件插件,比如FPGA、SRIOV、RDMA等等。感兴趣的话,你可以点击这些链接来查看这些DevicePlugin的实现。

总结

在本篇文章中,我为你详细讲述了Kubernetes对GPU的管理方式,以及它所需要使用的DevicePlugin机制。

需要指出的是,DevicePlugin的设计,长期以来都是以GoogleCloud的用户需求为主导的,所以,它的整套工作机制和流程上,实际上跟学术界和工业界的真实场景还有着不小的差异。

这里最大的问题在于,GPU等硬件设备的调度工作,实际上是由kubelet完成的。即,kubelet会负责从它所持有的硬件设备列表中,为容器挑选一个硬件设备,然后调用DevicePlugin的AllocateAPI来完成这个分配操作。可以看到,在整条链路中,调度器扮演的角色,仅仅是为Pod寻找到可用的、支持这种硬件设备的节点而已。

这就使得,Kubernetes里对硬件设备的管理,只能处理“设备个数”这唯一一种情况。一旦你的设备是异构的、不能简单地用“数目”去描述具体使用需求的时候,比如,“我的Pod想要运行在计算能力最强的那个GPU上”,DevicePlugin就完全不能处理了。

更不用说,在很多场景下,我们其实希望在调度器进行调度的时候,就可以根据整个集群里的某种硬件设备的全局分布,做出一个最佳的调度选择。

此外,上述DevicePlugin的设计,也使得Kubernetes里,缺乏一种能够对Device进行描述的API对象。这就使得如果你的硬件设备本身的属性比较复杂,并且Pod也关心这些硬件的属性的话,那么DevicePlugin也是完全没有办法支持的。

更为棘手的是,在DevicePlugin的设计和实现中,Google的工程师们一直不太愿意为Allocate和ListAndWatchAPI添加可扩展性的参数。这就使得,当你确实需要处理一些比较复杂的硬件设备使用需求时,是没有办法通过扩展DevicePlugin的API来实现的。

针对这些问题,RedHat在社区里曾经大力推进过ResourceClass的设计,试图将硬件设备的管理功能上浮到API层和调度层。但是,由于各方势力的反对,这个提议最后不了了之了。

所以说,目前Kubernetes本身的DevicePlugin的设计,实际上能覆盖的场景是非常单一的,属于“可用”但是“不好用”的状态。并且,DevicePlugin的API本身的可扩展性也不是很好。这也就解释了为什么像NVIDIA这样的硬件厂商,实际上并没有完全基于上游的Kubernetes代码来实现自己的GPU解决方案,而是做了一定的改动,也就是fork。这,实属不得已而为之。

思考题

请你结合自己的需求谈一谈,你希望如何对当前的DevicePlugin进行改进呢?或者说,你觉得当前的设计已经完全够用了吗?

0%