CDI介绍
什么是CDI
CDI(Container Device Interface)是Container Runtimes支持挂载第三方设备(比如:GPU、FPGA等)机制。它引入了设备作为资源的抽象概念,这类设备由一个完全限定的名称唯一指定,该名称由设备商ID,设备类别与一个设备类别下的一个唯一名称组成,格式如下:
vendor.com/class=unique_name
设备商ID和设备类型(vendor.com/class)为CDI资源类型。下面是一个CDI设备名称例子:
nvidia.com/gpu=all
其中:
-
nvidia.com为设备商ID
-
gpu为类别
-
all为CDI设备资源名称
为什么需要CDI
我们知道在docker中,容器如果需要使用宿主机上某个设备(或者挂载节点上某个目录或者文件),那么可以使用--device和-v完成,比如:
# docker run -d --name test --device xxx:xxx -v xxx:xxx centos:7 sleep 1d
但是在实际场景中这个功能远远不够,以容器中使用GPU设备为例,在安装有nvidia container toolkit的宿主机上执行如下命令:
# nvidia-container-cli list
/dev/nvidiactl
/dev/nvidia-uvm
/dev/nvidia-uvm-tools
/dev/nvidia-modeset
/dev/nvidia0
/usr/bin/nvidia-smi
/usr/bin/nvidia-debugdump
/usr/bin/nvidia-persistenced
/usr/bin/nvidia-cuda-mps-control
/usr/bin/nvidia-cuda-mps-server
/usr/lib64/libnvidia-ml.so.460.91.03
/usr/lib64/libnvidia-cfg.so.460.91.03
/usr/lib64/libcuda.so.460.91.03
/usr/lib64/libnvidia-opencl.so.460.91.03
/usr/lib64/libnvidia-ptxjitcompiler.so.460.91.03
/usr/lib64/libnvidia-allocator.so.460.91.03
/usr/lib64/libnvidia-compiler.so.460.91.03
/usr/lib64/libnvidia-ngx.so.460.91.03
/usr/lib64/vdpau/libvdpau_nvidia.so.460.91.03
/usr/lib64/libnvidia-encode.so.460.91.03
/usr/lib64/libnvidia-opticalflow.so.460.91.03
/usr/lib64/libnvcuvid.so.460.91.03
/usr/lib64/libnvidia-eglcore.so.460.91.03
/usr/lib64/libnvidia-glcore.so.460.91.03
/usr/lib64/libnvidia-tls.so.460.91.03
/usr/lib64/libnvidia-glsi.so.460.91.03
...... // 省略后面的部分
这条命令列出了宿主机上的GPU设备以及与GPU设备相关的库文件、可执行文件等,如果一个容器需要使用GPU,那么它需要将这些文件中的全部(或者部分)挂载到容器中,该容器才能正常使用GPU。这个时候让用户使用docker run --device 和-v去挂载这些文件,肯定不现实。而且,不止GPU,其他设备挂载也有可能碰到这种问题。
那么CDI思想是什么呢?仍然以GPU为例,可以把需要挂载的设备和库按照某种格式写入某一个文件中,然后在容器创建时,用户指定这个容器需要挂载刚刚定义的文件中的内容就完成了目标。
Containerd如何配置CDI
目前Containerd 1.7已经引入了该功能(调研该功能时,1.7正式版本还没有出,当前只有 1.7-rc.1 ReleaseNote),在containerd中配置CDI操作如下:
-
创建两个目录:/etc/cdi和/var/run/cdi,一般/etc/cdi用于存放静态的cdi设备,如果cdi存在动态更新,那么最好放在/var/run/cdi。
# mkdir -pv /etc/cdi /var/run/cdi
-
在 /etc/containerd/config.toml中添加如下配置:
[plugins."io.containerd.grpc.v1.cri"]
enable_cdi = true
cdi_spec_dirs = ["/etc/cdi", "/var/run/cdi"]
-
重启containerd服务。
如何定义CDI设备
下面是官方提供的例子:
$ mkdir /etc/cdi
$ cat > /etc/cdi/vendor.json <<EOF
{
"cdiVersion": "0.5.0",
"kind": "vendor.com/device",
"devices": [
{
"name": "myDevice",
"containerEdits": {
"deviceNodes": [
{"hostPath": "/vendor/dev/card1": "path": "/dev/card1", "type": "c", "major": 25, "minor": 25, "fileMode": 384, "permissions": "rw", "uid": 1000, "gid": 1000},
{"path": "/dev/card-render1", "type": "c", "major": 25, "minor": 25, "fileMode": 384, "permissions": "rwm", "uid": 1000, "gid": 1000}
]
}
}
],
"containerEdits": {
"env": [
"FOO=VALID_SPEC",
"BAR=BARVALUE1"
],
"deviceNodes": [
{"path": "/dev/vendorctl", "type": "b", "major": 25, "minor": 25, "fileMode": 384, "permissions": "rw", "uid": 1000, "gid": 1000}
],
"mounts": [
{"hostPath": "/bin/vendorBin", "containerPath": "/bin/vendorBin"},
{"hostPath": "/usr/lib/libVendor.so.0", "containerPath": "/usr/lib/libVendor.so.0"},
{"hostPath": "tmpfs", "containerPath": "/tmp/data", "type": "tmpfs", "options": ["nosuid","strictatime","mode=755","size=65536k"]}
],
"hooks": [
{"createContainer": {"path": "/bin/vendor-hook"} },
{"startContainer": {"path": "/usr/bin/ldconfig"} }
]
}
}
EOF
对于这个例子,有如下的说明:
-
kind字段的值的格式为“VendorID/Class”,这里为“vendor.com/device”。
-
在devices字段中,只定义了一个设备,设备名称为myDevice。containerEdits定义了该设备有哪些行为:
-
deviceNodes表示要将宿主机上哪些设备挂载到容器中。
-
env表示要为该容器自动添加哪些环境变量。
-
mounts表示要将哪些文件挂载到宿主机中。
-
hooks表示需要为容器添加哪些hooks。
-
最外层的containerEdits(与devices字段在同一级别)为所有设备的公共行为,也就是myDevice的最终的containerEdits是两个containerEdits的并集。
更详细的字段描述请参考:CDI Spec
如何使用CDI设备
podman中使用CDI
目前支持CDI的client只有podman,用podman使用CDI设备的格式如下(以上面的vendor.com/device为例):
# podman run --device vendor.com/device=myDevice ...
该命令代表容器使用vendor.com/device=myDevice这个CDI设备,其中vendor.com/device与CDI Spec中的kind一致,myDevice为CDI Spec定义的设备名称(name字段)。
k8s中使用CDI
在k8s中为容器指定CDI设备,只需要为容器添加特定前缀的Annotation(注意是容器Annotation而不是pod Annotation),而为容器指定Annotation可以通过k8s device plugin机制实现。那么添加annotation的格式如下:
AnnotationKey: cdi.k8s.io/xxxx (xxxx名称任意)
AnnotationValue: vendor.com/device=myDevice (CDI设备名称)
NVIDIA对CDI的支持
nvidia container runtime机制
在没有CDI之前,NVIDIA对容器中使用GPU的场景提供了一套称为nvidia container runtime的方案,示意图如下:
以docker为例整体流程如下:
-
在/etc/docker/daemon.json将runtime替换为nvidia-container-runtime,然后重启docker。
-
创建容器时,nvidia-container-runtime检查容器的oci spec中的环境变量是否有特定环境变量NVIDIA_VISIBLE_DEVICES;如果有,那么需要在容器oci spec中添加一个prestart hook(告诉runc在启动容器之前,执行这个hook),然后nvidia-container-runtime会调用真正的runc binary:
{
...... // 省略其他内容
"hooks":{
"prestart":[
{
"path":"/usr/bin/nvidia-container-runtime-hook",
"args":[
"/usr/bin/nvidia-container-runtime-hook",
"prestart"
]
}
]
}
...... // 省略其他内容
}
-
在容器启动之前,runc会调用nvidia-container-runtime-hook(该文件是nvidia-container-toolkit二进制文件的软连接),这个hook将会借助libnvidia-container等工具修改容器cgroup,把前面介绍的一些与GPU相关的文件挂载到容器中。
这套机制存在一些问题:
-
只针对GPU,如果是其他异构计算设备,那么需要重新开发
针对这些问题,nvidia正在逐步使用CDI替换原有的这一套机制。
CDI机制
nvidia-container-toolkit从1.12开始实现对CDI支持。如果要使用CDI版本的nvidia-container-toolkit,那么只需要安装一个名称为nvidia-container-toolkit-base包,该包提供两个二进制文件(nvidia-container-runtime和nvidia-ctk):
# rpm -ql nvidia-container-toolkit-base
/etc/nvidia-container-runtime/config.toml
/usr/bin/nvidia-container-runtime
/usr/bin/nvidia-ctk
/usr/share/licenses/nvidia-container-toolkit-base-1.12.0
/usr/share/licenses/nvidia-container-toolkit-base-1.12.0/LICENSE
然后在宿主机上使用如下命令生成CDI设备:
# nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
注意CDI设备将放在/etc/cdi/nvidia.yaml,由于文件内容过多,省略了mounts部分挂载众多的库文件:
---
cdiVersion: 0.5.0
containerEdits:
deviceNodes:
- path: /dev/nvidia-uvm-tools
- path: /dev/nvidia-uvm
- path: /dev/nvidiactl
- path: /dev/nvidia-modeset
hooks:
- args:
- nvidia-ctk
- hook
- update-ldcache
- --folder
- /lib
- --folder
- /lib64
- --folder
- /lib/vdpau
- --folder
- /lib64/vdpau
hookName: createContainer
path: /usr/bin/nvidia-ctk
- args:
- nvidia-ctk
- hook
- chmod
- --mode
- "755"
- --path
- /dev/dri
hookName: createContainer
path: /usr/bin/nvidia-ctk
mounts:
- containerPath: /usr/bin/nvidia-smi
hostPath: /usr/bin/nvidia-smi
options:
- ro
- nosuid
- nodev
- bind
...... # 省略其他挂载的库文件
devices:
- containerEdits:
deviceNodes:
- path: /dev/nvidia0
- path: /dev/dri/card1
- path: /dev/dri/renderD128
name: "0"
- containerEdits:
deviceNodes:
- path: /dev/nvidia0
- path: /dev/dri/card1
- path: /dev/dri/renderD128
name: all
kind: nvidia.com/gpu
从生成的CDI设备中可以看到,节点上只有一个GPU,并且CDI定义了两个设备:"0"和"all",“0”代表是挂载0号GPU卡,而“all”代表是挂载节点上的所有GPU设备,这里“0”和“all”是一样的。
如果要用podman创建一个使用GPU的容器,那么可以这样使用:
# podman run --device nvidia.com/gpu=all ... 挂载节点上所有GPU设备
# podman run --device nvidia.com/gpu=0 ... 挂载节点上0号GPU设备
可以看到,有了CDI后,NVIDIA将逐步放弃原有的nvidia container runtime方案。
如何在自定义k8s device plugin中使用CDI
k8s device plugin的Allocate函数
在k8s device plugin机制中,每个自定义device plugin都需要实现一个名称为Allocate的函数,函数声明以及涉及到的参数和返回值如下:
// Allocate函数
func Allocate(c context.Context, requests *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error)
// Kubelet传递的参数
type ContainerAllocateRequest struct {
DevicesIDs []string `protobuf:"bytes,1,rep,name=devicesIDs,proto3" json:"devicesIDs,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_sizecache int32 `json:"-"`
}
type AllocateRequest struct {
ContainerRequests []*ContainerAllocateRequest `protobuf:"bytes,1,rep,name=container_requests,json=containerRequests,proto3" json:"container_requests,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_sizecache int32 `json:"-"`
}
// device plugin需要返回的结构体
type ContainerAllocateResponse struct {
// List of environment variable to be set in the container to access one of more devices.
Envs map[string]string `protobuf:"bytes,1,rep,name=envs,proto3" json:"envs,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
// Mounts for the container.
Mounts []*Mount `protobuf:"bytes,2,rep,name=mounts,proto3" json:"mounts,omitempty"`
// Devices for the container.
Devices []*DeviceSpec `protobuf:"bytes,3,rep,name=devices,proto3" json:"devices,omitempty"`
// Container annotations to pass to the container runtime
Annotations map[string]string `protobuf:"bytes,4,rep,name=annotations,proto3" json:"annotations,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_sizecache int32 `json:"-"`
}
type AllocateResponse struct {
ContainerResponses []*ContainerAllocateResponse `protobuf:"bytes,1,rep,name=container_responses,json=containerResponses,proto3" json:"container_responses,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_sizecache int32 `json:"-"`
}
对于Allocate函数,说明如下:
-
Device Plugin启动时会向Kubelet注册一些设备ID,这些设备ID有可能是真的设备ID,也有可能是一些无实际意义的ID。
-
Pod申请设备资源时,申请的值为device plugin向kubelet注册的设备ID集合的子集。
-
kubelet为每个容器分配某种设备资源(比如GPU)以后,会通过GRPC协议调用该资源对应的device plugin的Allocate函数,kubelet需要传入分配的设备ID作为Allocate参数。
-
device plugin需要根据kubelet传入的设备ID,向kubelet返回需要给容器添加的配置,例如:如果kubelet为某个容器分配设备ID为TestID1,那么需要给容器配置如下的配置:
-
一个环境变量xxx=xxx
-
一个Annotation xxx=xxx
-
挂载宿主机上某个设备文件/dev/xxx到容器中
-
挂载宿主机上某个二进制文件/usr/bin/xxx到容器中
-
设备ID与容器配置信息之间的对应关系就是Device plugin的Allocate函数需要完成的逻辑。
关于k8s device plugin机制的更详细信息请参考官方文档:device plugin。
nvidia device plugin对CDI的支持
在nvidia提供的k8s-device-plugin中已经开始CDI了,关键代码如下:
# url: https://github.com/NVIDIA/k8s-device-plugin/
# commitId: de3ef904890eb90d0c82580bd1cc397f77bc0098
# path: cmd/nvidia-device-plugin/server.go#L332
......
if len(devices) > 0 {
var err error
// 假设kubelet为当前容器分配0号GPU卡,2号GPU卡,那么
// 最终为容器添加的AnnotationKey为"cdi.k8s.io/nvidia-device-plugin"
// 最终为容器添加的AnnotationValue为"nvidia.com/gpu=0,nvidia.com/gpu=2"
// 下面这行代码就是这个意思,当底层containerd检测到容器有这个annotation,那么会为这个
// 容器挂载对应的CDI设备
response.Annotations, err = cdiapi.UpdateAnnotations(map[string]string{}, "nvidia-device-plugin", responseID, devices)
if err != nil {
klog.Errorf("Failed to add CDI annotations: %v", err)
}
// Unset NVIDIA_VISIBLE_DEVICES envvar to ensure devices are only injected using container annotations
response.Envs = plugin.apiEnvs(plugin.deviceListEnvvar, []string{"void"})
}
更高级的device plugin实现方式
对于nvidia device plugin,它实现的功能是按卡的维度申请GPU资源(某个pod申请N张GPU卡),在这个场景下,调度器是弱参与GPU卡的分配,弱参与指的是,调度器知道这个节点当前有几张GPU卡可用,只要判断pod申请的值比这个值小就行,它并不关心把哪几张GPU卡分配给这个pod,而真正执行分配操作的是节点上的kubelet,它会决定分配节点上哪几张GPU卡给pod。
但在某些场景中,调度器必须是强参与资源的分配,也就是调度器不仅要知道节点上某种资源的当前可用值与pod请求值的大小比较,同时还要决定将哪些资源分配给该pod,在这种场景下,kubelet将不参与分配工作(或者说它提供的资源分配方案无效)。
对于调度器需要强参与的场景中,device plugin需要从其他渠道获取调度器为该pod分配的资源信息,而不再使用kubelet提供的分配方案。一种常见的操作是调度器将设备分配信息写入pod annotation中,然后device plugin在为pod的容器提供设备挂载信息时,直接从pod annotation获取,返回给kubelet。
然而理想很丰满,现实很骨感。从前面的分析可以看到,当kubelet通过GRPC协议调用device plugin的Allocate函数时,仅会向device plugin传递为容器分配的设备ID,device plugin此时并不知道这些设备ID是为哪个Pod的哪个容器分配的,也就无法从pod annotation中获取调度器为容器分配的设备信息,最后也就无法确定应该为容器添加哪些配置信息。这个问题一直困扰device plugin开发人员。
有了CDI机制以后,我们提出一种新的方案,解决上面困扰我们的问题。
在这个方案中,Device Plugin需要实现两个函数:Allocate和PreStartContainer,Allocate函数已经在前面介绍,PreStartContainer的函数声明如下:
// PreStartContainer is called, if indicated by Device Plugin during registeration phase,
// before each container start. Device plugin can run device specific operations
// such as resetting the device before making devices available to the container.
rpc PreStartContainer(PreStartContainerRequest) returns (PreStartContainerResponse) {}
可以看到官方对PreStartContainer这个函数的解释是可以在这个函数中重置设备或为容器准备一些设备。
需要注意的是,kubelet向PreStartContainer传递的参数仍然只有设备ID,从参数中仍然无法知道当前这些设备ID是为哪个容器分配的。
另外,kubelet调用函数的顺序是先调用Allocate函数,然后再调用PreStartContainer。
Allocate函数实现逻辑如下:
-
假设kubelet向Allocate函数传递的设备ID列表为[devId0 devId1 devId2]。
-
对设备ID列表[devId0 devId1 devId2]做HASH计算,假设算出的HASH值为abcde(算hash是保证每个设备ID列表与一个CDI设备保持一一对应)。
-
返回给kubelet的容器配置信息中,为容器添加一个Annotation:
-
Annotation的key为cdi.k8s.io/<device_plugin_name>(其中这个<device_plugin_name>不是必须的,任意字符串都行,只要保证这个Annotation的key是唯一的就行,避免与其他device plugin设置CDI Annotation key冲突。)
-
Annotation的value为CDI设备的命名规则,即<VendorId>/<class>=<unique_name>,在本方案中<class>必须为前面算出的设备ID列表的hash值。而<unique_name>只要保证唯一就行。一个示例的CDI设备名称为:alibabacloud.com/abcde=gpushare。
-
如果底层runtime支持CDI机制,发现容器具有这个annotation后,会自动寻找这个CDI设备,并将CDI定义的配置加入到容器的配置中。
注意在这一步并没有生成CDI设备文件,只是告诉底层runtime,创建容器的是需要挂载这个CDI设备,生成CDI设备需要在PreStartContainer完成。
PreStartContainer函数实现的逻辑如下:
-
假设kubelet向Allocate函数传递的设备ID列表为[devId0 devId1 devId2]。
-
对设备ID列表[devId0 devId1 devId2]做HASH计算,假设算出的HASH值为abcde(算hash是保证每个设备ID列表与一个CDI设备保持一一对应)。
var locateInfo *LocateInfo
// deviceIds是kubelet传递的设备ID
deviceList := NewDeviceList(deviceIds)
klog.V(5).Infof("list total podresource %v", len(response.PodResources))
for _, pod := range response.PodResources {
for _, container := range pod.Containers {
resourceDeviceIds := []string{}
for _, resource := range container.Devices {
if resource.ResourceName == resourceName {
resourceDeviceIds = append(resourceDeviceIds, resource.DeviceIds...)
}
}
if len(resourceDeviceIds) != 0 && deviceList.Equals(NewDeviceList(resourceDeviceIds)) {
locateInfo = &LocateInfo{
PodNamespace: pod.Namespace,
PodName: pod.Name,
ContainerName: container.Name,
}
break
}
}
}
-
定位到Pod Namespace和Pod Name以及Container Name后,接下来就可以获取Pod Annotation,有两种方式:
-
方式1:借助client-go直接访问api server拿到pod annotation,这种方式不推荐,对api server有压力。
-
方式2:借助访问kubelet 10250端口获取节点上所有pod,然后拿到目标pod的annotation,推荐使用这种方式。
-
从pod annotation中,拿到调度器为该pod的容器分配的设备信息,生成一个CDI文件,存放在/var/run/cdi中,不过需要注意:
-
CDI文件中kind字段必须与前面Allocate函数中返回的容器Annotation Value一致,比如:前面Allocate函数返回的annotation value为alibabacloud.com/abcde=gpushare,那么CDI文件中的kind字段必须为alibabacloud.com/abcde,并且devcies字段中,必须有一个device name为gpushare。
-
如果一个pod被删除,那么生成的这个CDI文件也应该被删除,可以用一个goroutine定时清理,同时CDI文件中需要有信息标记该CDI文件属于哪个pod,比如给容器添加一个环境变量,该环境变量记录pod名称。
-
PreStartContainer不需要给kubelet返回相关配置信息。
可以看到PreStartContainer主要的任务就是将调度器为Pod的容器分配的设备信息写入CDI文件中。
看到这里,或许有一个疑问:写入CDI文件为什么不在Allocate函数直接完成,非要在PreStartContainer函数中完成?主要原因在于某个pod进入kubelet PodResources Cache是在kubelet调用Allocate函数后进行的,所以在Allocate函数中如果希望通过传入Device Id列表在PodResources去定位pod name和container name是无法实现的。
NRI介绍
什么是NRI
NRI (Node Resource Interface)允许将用户某些自定的逻辑插入到OCI兼容的运行时中,此逻辑可以对容器进行受控更改,或在容器生命周期的某些点执行 OCI 范围之外的额外操作。例如,用于改进设备和其他容器资源的分配和管理。
NRI已经在containerd 1.7.0开始支持(目前1.7.0版本还没发布,最新版本为1.7.0-rc.2),不过是一个experimental状态。
当前NRI plugins与containerd交互的协议是通过GRPC完成,也就是NRI一般都是以Daemon形式存在。
NRI能够订阅Pod和容器生命周期事件,下面将介绍。
订阅Pod生命周期事件
NRI能够订阅Pod生命周期事件,包括:
-
creation
-
stopping
-
removal
相关的API如下:
// 当有pod在节点上创建时,NRI插件将收到该事件
func (p *plugin) RunPodSandbox(pod *api.PodSandbox) error {
return nil
}
// 当有pod在节点上停止时,NRI插件将收到该事件
func (p *plugin) StopPodSandbox(pod *api.PodSandbox) error {
return nil
}
// 当有pod在节点上移除时,NRI插件将收到该事件
func (p *plugin) RemovePodSandbox(pod *api.PodSandbox) error {
return nil
}
订阅容器生命周期事件
NRI能够订阅容器生命周期事件,包括:
-
creation (*)
-
post-creation
-
starting
-
post-start
-
updating (*)
-
post-update
-
stopping (*)
-
removal
相关的API如下:
// 创建容器
func (p *plugin) CreateContainer(pod *api.PodSandbox, container *api.Container) (*api.ContainerAdjustment, []*api.ContainerUpdate, error) {
return nil, nil, nil
}
// 容器创建以后
func (p *plugin) PostCreateContainer(pod *api.PodSandbox, container *api.Container) error {
return nil
}
// 容器启动之前
func (p *plugin) StartContainer(pod *api.PodSandbox, container *api.Container) error {
return nil
}
// 容器启动之后
func (p *plugin) PostStartContainer(pod *api.PodSandbox, container *api.Container) error {
return nil
}
// 容器更新时
func (p *plugin) UpdateContainer(pod *api.PodSandbox, container *api.Container) ([]*api.ContainerUpdate, error) {
return nil, nil
}
// 容器更新后
func (p *plugin) PostUpdateContainer(pod *api.PodSandbox, container *api.Container) error {
return nil
}
// 容器停止时
func (p *plugin) StopContainer(pod *api.PodSandbox, container *api.Container) ([]*api.ContainerUpdate, error) {
return nil, nil
}
// 容器移除时
func (p *plugin) RemoveContainer(pod *api.PodSandbox, container *api.Container) error {
return nil
}
Containerd配置NRI
NRI功能在Containerd 1.7中被引入,如果需要开启,那么在containerd配置文件/etc/containerd/config.toml的结尾添加如下配置:
[plugins."io.containerd.nri.v1.nri"]
# Enable NRI support in containerd.
disable = false
# Allow connections from externally launched NRI plugins.
disable_connections = false
# plugin_config_path is the directory to search for plugin-specific configuration.
plugin_config_path = "/etc/nri/conf.d"
# plugin_path is the directory to search for plugins to launch on startup.
plugin_path = "/opt/nri/plugins"
# plugin_registration_timeout is the timeout for a plugin to register after connection.
plugin_registration_timeout = "5s"
# plugin_requst_timeout is the timeout for a plugin to handle an event/request.
plugin_request_timeout = "2s"
# socket_path is the path of the NRI socket to create for plugins to connect to.
socket_path = "/var/run/nri/nri.sock"
配置完成以后,创建相关的目录:
mkdir -pv /var/run/nri /etc/nri/conf.d /opt/nri/plugins
最后重启containerd:
systemctl restart containerd
NRI Samples
在NRI项目下有一些NRI插件的样例,这里介绍一下device-injector和logger,看看怎样实现一个NRI插件。
device-injector
device-injector是一个NRI示例插件,主要的实现的能力是:只要某个Pod的annotation中声明挂载某个或多个设备,那么devcie-injector会为pod的容器挂载对应的设备。
device-injector提供了一个测试pod yaml。pod的annotation中定义了需要挂载哪些设备:
apiVersion: v1
kind: Pod
metadata:
name: bbdev0
labels:
app: bbdev0
annotations:
devices.nri.io/container.c0: |+
- path: /dev/nri-null
type: c
major: 1
minor: 3
devices.nri.io/container.c1: |+
- path: /dev/nri-zero
type: c
major: 1
minor: 5
mounts.nri.io/container.c2: |+
- source: /home
destination: /host-home
type: bind
options:
- bind
- ro
...... // 省略其他
然后,再看插件的实现代码,device-injector订阅了容器的CreateContainer事件,也就是容器在创建时:
func (p *plugin) CreateContainer(pod *api.PodSandbox, container *api.Container) (*api.ContainerAdjustment, []*api.ContainerUpdate, error) {
var (
ctrName string
devices []device
mounts []mount
err error
)
ctrName = containerName(pod, container)
if verbose {
dump("CreateContainer", "pod", pod, "container", container)
}
adjust := &api.ContainerAdjustment{}
// inject devices to container
// 从pod annotation中解析需要挂载的设备信息
devices, err = parseDevices(container.Name, pod.Annotations)
if err != nil {
return nil, nil, err
}
if len(devices) == 0 {
log.Infof("%s: no devices annotated...", ctrName)
} else {
if verbose {
dump(ctrName, "annotated devices", devices)
}
for _, d := range devices {
adjust.AddDevice(d.toNRI())
if !verbose {
log.Infof("%s: injected device %q...", ctrName, d.Path)
}
}
}
// inject mounts to container
mounts, err = parseMounts(container.Name, pod.Annotations)
if err != nil {
return nil, nil, err
}
if len(mounts) == 0 {
log.Infof("%s: no mounts annotated...", ctrName)
} else {
if verbose {
dump(ctrName, "annotated mounts", mounts)
}
for _, m := range mounts {
adjust.AddMount(m.toNRI())
if !verbose {
log.Infof("%s: injected mount %q -> %q...", ctrName, m.Source, m.Destination)
}
}
}
if verbose {
dump(ctrName, "ContainerAdjustment", adjust)
}
return adjust, nil, nil
}
整个函数的逻辑就是从pod annotation中解析需要挂载设备的信息,然后将该信息返回给containerd,让containerd更新容器配置。
logger
之所以要介绍一下logger这个NRI Sample Plugin, 主要有两点:
-
逻辑比较简单:订阅pod和容器的生命周期事件,打印传递的参数信息。
-
订阅所有事件:订阅了pod和容器所有的生命周期事件。
logger的实现代码比较简单,以RunPodSandbox为例,只是打印PodSandox的信息:
func (p *plugin) RunPodSandbox(pod *api.PodSandbox) error {
dump("RunPodSandbox", "pod", pod)
return nil
}
但是这个sample对于我们学习如何写NRI插件很有帮助。
NRI与K8s device plugin机制
对于前面提到的调度器强参与设备分配的情况下,如果有NRI参与,那么device plugin的Allocate函数和PreStartContainer函数可以什么都不用做。只需要实现一个NRI插件,该插件订阅容器创建的事件,直接从pod annotation中获取调度器为容器分配的设备信息,类似于前面的device-injector插件。但是这种绕开k8s device plugin机制是否为一种正确的方式,值得思考。
NRI应用场景
NRI虽然目前是experimental状态,实际应用场景还不太了解,不过最近发现koordinator社区的组件runtime-proxy所要做的事与NRI有很多相似之处,说明NRI的应用场景还是有的。