GPU热挂载到Kubernetes的原理
本文主要基于 GPUMounter 这个项目的代码来进行分析,项目代码比较多,里面最核心的就两个地方,这里以动态挂载为例来说明。
众所周知容器的核心两大技术就是 cgroup 和 namespace,cgroup 主要是提供资源限制、namespace 主要是提供资源隔离。device 也是其中的一种资源,所以动态挂载核心就是对 cgroup 和 namespace 的操作。
Cgroups
Cgroups是 control groups 的缩写,是Linux内核提供的一种可以限制、记录、隔离进程组(process groups)所使用的物理资源(如:cpu,memory,IO等等)的机制。最初由google的工程师提出,后来被整合进Linux内核。Cgroups也是LXC为实现虚拟化所使用的资源管理手段,可以说没有cgroups就没有LXC。
Cgroups 提供了如下的一些功能:
- 限制进程组可以使用的资源数量(Resource limiting)比如:memory子系统可以为进程组设定一个memory使用上限,一旦进程组使用的内存达到限额再申请内存,就会出发OOM(out of memory)。
- 进程组的优先级控制(Prioritization )。比如:可以使用cpu子系统为某个进程组分配特定cpu share。
- 记录进程组使用的资源数量(Accounting),比如:可以使用cpuacct子系统记录某个进程组使用的cpu时间
- 进程组隔离(Isolation),比如:使用ns子系统可以使不同的进程组使用不同的namespace,以达到隔离的目的,不同的进程组有各自的进程、网络、文件系统挂载空间。
- 进程组控制(Control)。比如:使用freezer子系统可以将进程组挂起和恢复。
Cgroups 提供了较多 子系统,目前存在有 v1 和 v2 两个版本,device 在 v1 版本中是一个独立的子系统,在 v2 中被移除了,下面我将分两个版本来讲解。
我们通过如下的命令来查看当前操作系统 Cgroups 版本:
$ mount |grep cgroup
cgroup on /sys/fs/cgroup/systemd type cgroup
# v1 版本数据
$ mount |grep cgroup
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime)
# v2 版本数据
V1
在 Cgroups V1 中,使用 devices 子系统可以允许或者拒绝 cgroup 中的进程访问设备。
devices子系统有三个控制文件:
- devices.allow:devices.allow用于指定cgroup中的进程可以访问的设备
- devices.deny:devices.deny用于指定cgroup中的进程不能访问的设备
- devices.list:devices.list用于报告cgroup中的进程访问的设备。
devices.allow文件中包含若干条目,每个条目有四个字段:type、major、minor 和 access。type、major 和 minor 字段中使用的值对应 Linux 分配的设备。
type字段指定设备类型:
- a 应用所有设备,可以是字符设备,也可以是块设备
- b 指定块设备
- c 指定字符设备
major和minor指定设备的主次设备号,可以通过命令获取:
$ ls -l /dev/nvidia0
crw-rw-rw- 1 root root 195, 0 Dec 24 14:32 /dev/nvidia0
# 195 major
# 0 minor
access 则指定相应的权限:
- r 允许任务从指定设备中读取
- w 允许任务写入指定设备
- m 允许任务生成还不存在的设备文件
V2
Cgroups V2 大部分和 V1 是一样的,但是 device 这部分是不一样的,这部分在 V2 中移除了。
https://www.kernel.org/doc/html/v5.10/admin-guide/cgroup-v2.html#device-controller
在 Linux Kernel 中提到 Device controller 需要使用 BPF 来进行 filter 控制设备。
Namespace
Linux Namespaces机制提供一种资源隔离方案,PID,IPC,Network等系统资源不再是全局性的,而是属于特定的Namespace。每个Namespace里面的资源对其他Namespace都是透明的。不同container内的进程属于不同的Namespace,彼此透明,互不干扰。
查看当前机器支持哪些 Namespace:
$ ls -l /proc/self/ns
lrwxrwxrwx 1 root root 0 Jan 2 22:07 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Jan 2 22:07 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 root root 0 Jan 2 22:07 mnt -> mnt:[4026531841]
lrwxrwxrwx 1 root root 0 Jan 2 22:07 net -> net:[4026531840]
lrwxrwxrwx 1 root root 0 Jan 2 22:07 pid -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 Jan 2 22:07 pid_for_children -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 Jan 2 22:07 time -> time:[4026531834]
lrwxrwxrwx 1 root root 0 Jan 2 22:07 time_for_children -> time:[4026531834]
lrwxrwxrwx 1 root root 0 Jan 2 22:07 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Jan 2 22:07 uts -> uts:[4026531838]
这里我们不讨论怎么创建出 ns,我们是在创建出的 ns 之上进行使用,device 也是文件,所以也遵循 mount ns的隔离机制。
Demo
Cgroups V1
这里我们用 k8s 来进行这个功能演示,首先我们需要一个正常的 K8s 集群,并且需要提前安装好 Nvidia 的所有组件。
- 安装好之后我们可以使用如下的 Pod 进行测试是否能正常运行,如果能 running 则说明依赖组件都安装成功。
apiVersion: v1
kind: Pod
metadata:
name: gpu-pod
spec:
nodeSelector:
gpu-mounter-enable: enable
containers:
- name: cuda-container
image: docker.samzong.me/chrstnhntschl/gpu_burn
resources:
limits:
nvidia.com/gpu: '1'
- 创建一个没有使用 GPU 的Pod;
apiVersion: v1
kind: Pod
metadata:
name: gpu-pod
spec:
nodeSelector:
gpu-mounter-enable: enable
containers:
- name: cuda-container
image: docker.samzong.me/chrstnhntschl/gpu_burn
command:
- sleep
args:
- '100000000'
env:
- name: NVIDIA_VISIBLE_DEVICES
value: "none"
- 启动成功之后我们确定 Pod UID、ContainerID、PID 这三个变量:
- UID
$ kubectl get pod gpu-pod -o jsonpath='{.metadata.uid}’
ace81d74-99ca-4b34-b60e-a60ec1442875
- ContainerID
$ kubectl get pod gpu-pod -o jsonpath='{.status.containerStatuses[0].containerID}’
containerd://4b5ef4c625208800283bb58e9da3ccb69ce92bd840f3cf885931c513a901ab04
- PID: 1145716
$ ctr -n k8s.io task ls |grep 4b5ef4c625208800283bb58e9da3ccb69ce92bd840f3cf885931c513a901ab04
4b5ef4c625208800283bb58e9da3ccb69ce92bd840f3cf885931c513a901ab04 1145716 RUNNING
- 往 cgroupPath 中写入 device.allow 文件
k8s 的 cgroupPath 由多个部分组成:/sys/fs/cgroup/kubepods.slice/kubepods-besteffort.slice/kubepods-besteffort-pod{UID}.slice/cri-containerd-{ContainerID}.scope
# /sys/fs/cgroup/kubepods.slice/kubepods-besteffort.slice/kubepods-besteffort-podace81d74_99ca_4b34_b60e_a60ec1442875.slice/cri-containerd-4b5ef4c625208800283bb58e9da3ccb69ce92bd840f3cf885931c513a901ab04.scope
$ echo 'c 195:0 rw' > /sys/fs/cgroup/kubepods.slice/kubepods-besteffort.slice/kubepods-besteffort-podace81d74_99ca_4b34_b60e_a60ec1442875.slice/cri-containerd-4b5ef4c625208800283bb58e9da3ccb69ce92bd840f3cf885931c513a901ab04.scope/devices.allow
写入之后 cgroup 就配置成功了,还需要配置 namespace 才可以使用。
Cgroups V2
因为 cgroups v2的 device 不是写文件,是通过 ebpf 进行操作,所以需要借助一些工具才能写入 ebpf 的filter 规则。
这里就不讨论如何写入了,描述如何查看是否写入成功,我们可以借助 CLI 工具写入 ebpf rule.
- 首先我们安装工具
$ apt update && apt install bpftool -y
- 查看 cgroup 路径下是否设置了 device
#bpftool cgroup tree {cgroupPath}
$ bpftool cgroup tree /sys/fs/cgroup/kubepods.slice/kubepods-besteffort.slice/kubepods-besteffort-podace81d74_99ca_4b34_b60e_a60ec1442875.slice/cri-containerd-4b5ef4c625208800283bb58e9da3ccb69ce92bd840f3cf885931c513a901ab04.scope
ID AttachType AttachFlags Name
14962 device multi
- AttachType 为 device 表示设备过滤器。
- Name 是挂载的 BPF 程序的名称。
- 查看详细的 BPF 程序信息
#bpftool prog show id <program_id>
$ bpftool prog show id 14962
14962: cgroup_device tag c394c0e22708d632
loaded_at 2025-01-04T11:29:58+0800 uid 0
xlated 3840B jited 2172B memlock 4096B
- Type: cgroup_device 表示该程序是设备过滤器。
Namespace
我们通过 Cgroups 设置了 GPU 的资源限制之后,就需要在 Namespace中把设备隔离打开,否则看不见设备。
我们使用 Cgroups V1 中的容器环境,启动成功之后我们确定 PID 这个变量:
- PID: 1145716
$ ctr -n k8s.io task ls |grep 4b5ef4c625208800283bb58e9da3ccb69ce92bd840f3cf885931c513a901ab04
4b5ef4c625208800283bb58e9da3ccb69ce92bd840f3cf885931c513a901ab04 1145716 RUNNING
- 执行 nsenter 命令,进入指定进程的 namespace中
$ nsenter --target 1145716 --mount sh
- 使用
mknod
命令创建一个 字符设备节点
$ mknod /dev/nvidia0 c 195 0
- /dev/nvidia0:这是设备文件路径,0 是设备编号,如果要挂载 1号设备,就用 /dev/nvidia1
- c: 这是设备类型
- 195:这是设备 major
- 0: 这是设备 minor
- 修改权限
$ chmod 666 /dev/nvidia0
CLI
上述都是需要一步一步的手动操作,这样便于理解这里面的细节,都在操作什么,理解完之后可以直接使用 CLI 命令来进行操作。
nvcli 提供了两个字命令,mount 和 unmount 子命令;通过设置 —v 可以查看更详细的执行日志。
$ ./nvcli
Support GPU hot loading and unloading to a Pod command line tool
Usage:
nvcli [command]
Available Commands:
help Help about any command
mount dynamic mount nvidia gpu device to pod
unmount dynamic unmount nvidia gpu device to pod
version Print the version number of Hugo
Flags:
-h, --help help for nvcli
--kubeconfig string kubeconfig file (default "/root/.kube/config")
--name string pod name
--namespace string namespace (default "default")
--v string Log level for klog (0-10) (default "2")
Use "nvcli [command] --help" for more information about a command.
mount
mount 提供了一个 —-mount
的参数,主要是指定宿主机上 GPU 的 index,然后会被加载到容器中,可以接受一个数组。
$ ./nvcli mount
Usage:
nvcli mount [flags]
Flags:
-h, --help help for mount
--mount stringArray mount nvidia gpu device index
Global Flags:
--kubeconfig string kubeconfig file (default "/root/.kube/config")
--name string pod name
--namespace string namespace (default "default")
--v string Log level for klog (0-10) (default "2")
如下是一个参数 demo,指定 pod 的name,pod namespace 默认是defaule, --kubeconfig
默认读区当前根目录下的文件。
$ ./nvcli mount --name=gpu-pod --mount=0
unmount
unmount
提供了一个 —-mount
的参数,主要是指定宿主机上 GPU 的 index,然后会被从容器中卸载,可以接受一个数组。
$ ./nvcli unmount
Usage:
nvcli unmount [flags]
Flags:
-h, --help help for unmount
--unmount stringArray unmount nvidia gpu device index
Global Flags:
--kubeconfig string kubeconfig file (default "/root/.kube/config")
--name string pod name
--namespace string namespace (default "default")
--v string Log level for klog (0-10) (default "2")
如下是一个参数 demo,指定 pod 的name,pod namespace 默认是defaule, --kubeconfig
默认读区当前根目录下的文件。
$ ./nvcli unmount --name=gpu-pod --unmount=0