news 2026/9/29 23:07:11

手写K8s Device Plugin:让RK3588 NPU成为集群可调度资源

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写K8s Device Plugin:让RK3588 NPU成为集群可调度资源

用RK3588做边缘AI节点这事儿,我玩了快一年了。单板部署YOLOv8推理任务确实爽,但一旦任务从几个变成几十个,要在十几台RK3588上分发、调度、滚动更新,手动一台台SSH上去部署就完全扛不住了。我想把它交给K8s,结果发现一个大坑:K8s能调度CPU、内存,甚至能通过插件调度GPU,但对RK3588的NPU,官方压根没做适配。

Rockchip官方的RKNN SDK面向的是单板场景,从来没考虑过集群里那么多块板子怎么分NPU算力。K8s社区里也没人给RK3588写过像样的调度插件。所以——官方没做的事,我补上了。这篇文章就是完整复盘:我是怎么用K8s的Device Plugin机制,把RK3588的NPU上报成一种可调度的集群资源,让kubectl describe node里出现rockchip.com/npu,让Pod通过limits申请NPU核心数量,然后被调度器送到有对应资源的节点上运行。

这篇内容适合两类人:一是手头有RK3588、准备组集群跑AI推理的开发者,二是理解K8s设备插件原理的朋友。我会从问题根源讲起,然后是方案选型、核心代码、容器内的RKNN runtime打通、最后是我上线后踩的一堆坑。

1. 为什么RK3588的NPU在K8s里"没人管"

1.1 先认识RK3588这块NPU长什么样

RK3588的NPU是一颗算力标称6 TOPS(INT8)的AI加速器,内部有3个核心。它在系统里不是一个独立的PCIe设备,而是通过rknpu驱动挂载,表现为一个字符设备节点:/dev/rknpu。你要在Linux上使用它,必须装对应的内核模块和RKNN runtime(librknnrt.so)。

这一点和咱们熟悉的NVIDIA GPU有本质区别。GPU在服务器里是标准的PCIe设备,有厂商驱动、有CUDA库、有nvidia-smi;而RK3588的NPU是SoC内部集成模块,没有标准的设备枚举机制,也没有统一的"NPU显存"概念。你只能用Rockchip定义的RKNN API去调用它。

如果只是单板开发,流程很简单:加载驱动、装RKNN-Toolkit2的runtime库、把模型转换成.rknn格式,然后写代码rknn_init创建上下文、rknn_inference执行推理。但放到集群场景里,问题就变成了:K8s怎么知道这个节点上有NPU?怎么知道这个节点还剩几个NPU核心能用?怎么在容器里安全地分配到某一个核心?

1.2 K8s默认只认识CPU和内存,其他资源都要"上报"

K8s的调度器做调度决策时,看的核心信息是Node的Capacity和Allocatable。CPU、内存这些内置资源,由kubelet直接采集上报。像NPU、GPU、FPGA这类外部设备,K8s标准做法是使用Device Plugin机制:设备厂商在节点上跑一个独立插件,通过gRPC和kubelet通信,告诉kubelet"我这台机器上有多少设备、每个设备什么标识、哪些设备需要被健康检查"。

kubelet拿到这些信息后,会把资源汇总成一种特殊资源,名字格式通常是vendor-domain/resource,比如nvidia.com/gpu。这类资源全称叫Extended Resource(扩展资源)。调度器看到Pod的limits里写了rockchip.com/npu: "2",就会去找哪个节点的Allocatable里有充足的rockchip.com/npu余量。没有Device Plugin支持,K8s就是"睁眼瞎",NPU对集群来说根本不存在。

1.3 为什么到现在官方都没做

说到这,你可能会问:Rockchip为什么不像NVIDIA那样离线发布一个nvidia-device-plugin?原因不复杂。NVIDIA的K8s调度插件,本质上是把GPU以PCIe设备的方式暴露给容器,依赖一整套nvml库;而RK3588的NPU是嵌入式SoC里的模块,官方的主要用户还是安卓、Linux单板开发,很少有人会在K8s集群里管理几十块开发板。Rockchip的SDK交付物是deb包、固件、交叉编译工具链,根本没打算处理分布式调度这种复杂度。

另一个原因是内核驱动暴露方式特殊。rknpu驱动不像GPU那样能通过nvidia-smi查询设备列表和利用率,让插件去做健康监测、算力隔离都非常麻烦。社区里也有零星的尝试,但大多是直接把NPU设备挂载进容器,靠节点亲和性手动绑节点,并没有做成完整的调度方案。

于是,就得自己动手了。其实K8s的Device Plugin API本身是为这个场景准备的,RK3588的NPU能不能被调度,不取决于某个厂商的官方支持,而取决于我们有没有耐心把一个"非标准设备"用标准机制包装好。

2. 调度方案选型:为什么是Device Plugin而不是其他土办法

2.1 先说三个候选方案,别一上来就写代码

我当时考虑过三种让NPU进入K8s调度视野的方法,分别用一个表格说清楚利弊。

方案实现成本资源可见性调度体验缺点
A. 自己写Device Plugin中高高,出现为独立Extended ResourcePod直接声明limits即可需要理解API,调试链路长
B. 给节点打标签,用NodeSelector绑Node低低,只区分布板型号,区分不了剩余算力需要手动维护任务和节点的关系多任务并发时严重不均,容易热点
C. 自定义调度器+CRD管理任务很高中高需要额外实现调度逻辑改动大,要写controller,前期待遇不够

方案B看着简单,我一开始也试过。给每块板子打个rk3588=true标签,然后所有推理任务都用NodeSelector固定到某一块板子。单任务没问题,多任务全挤在同一个节点上,其他板子闲着,这跟"分布式调度"没有半毛钱关系。

方案C太重。CRD、自定义调度器、控制器全写完,基本等于再造半个K8s。而且我们这些任务的调度需求并不复杂,就是"按NPU核心数调度,资源不够就排队,不要超卖",这恰好是K8s原生调度器支持的语义,没必要重复造轮子。

所以最终选方案A。Device Plugin不是只有NVIDIA能用,它是K8s给所有外部设备提供的一个统一插座,谁把设备包装成标准接口,谁就能被kubelet接管。RK3588的NPU再特殊,在这个插座面前都只是一个"可以提供N个设备ID的资源池"。

2.2 Device Plugin内部是怎么和Kubelet勾搭上的

理解Device Plugin的机制,是写代码之前必须做的事。整个链路是这样的:

  1. Device Plugin在节点上跑起来,监听一个Unix Socket,路径一般是/var/lib/kubelet/device-plugins/xxx.sock。
  2. 插件启动后,向kubelet另一个固定的Socket(/var/lib/kubelet/device-plugins/kubelet.sock)发送注册请求,声明自己的资源名(比如rockchip.com/npu)和Socket路径。
  3. kubelet收到注册后,会调用插件的ListAndWatch接口,插件返回当前节点上的所有设备列表,以及在后续运行中持续上报设备健康状态。
  4. 当用户申请这个资源时,kubelet会调用插件的Allocate接口,传入被分配的设备ID,插件返回需要注入容器的东西——设备节点、挂载目录、环境变量。
  5. 调度器那头,kubelet会把设备的数量和健康状态汇总成Extended Resource,上报给API Server。

这里最核心的是第三个和第四个接口:ListAndWatch决定能报多少设备,Allocate决定容器里能看到什么。NVIDIA的插件在这两个接口里做的事情是:返回GPU的UUID,然后把设备节点、驱动的库目录、环境变量等注入容器。我们要给RK3588做的东西,本质一样,只不过设备ID是NPU核心号,注入的环境变量是NPU Core Mask。

2.3 Extended Resource的限制值得先说清楚

在写代码之前,还有一个概念必须踩清楚。Extended Resource有几个硬性限制:

  • 只能申请整数数量。你不能limits: rockchip.com/npu: "0.5"表达半个NPU核,K8s不接受这种小数。
  • 数据是quantity类型,数值上可以用字符串表示。
  • 它的调度是"账本式"的,调度器只做容量检查,不做运行时隔离。比如两个Pod都申请到了同一个核心,调度器不会发现,因为你没有上报细分维度。
  • Extended Resource不支持nvidia.com/gpu那样的显存大小属性,所有信息都只能通过设备数量来表达。

这些限制导致了我的设计思路:一个NPU核心就是一个设备ID,上报3个设备,资源名为rockchip.com/npu,节点Capacity就是3。Pod想用几个核,就申请几个rockchip.com/npu。这样"一个设备ID = 一个NPU核心"的映射最直观,调度器做容量检查时也最准确。

有人可能会问,能不能只上报一个设备,把整个NPU当一个大资源?能,但那样最多只能让一个Pod用整个NPU,3核就废了。我的业务场景里,有的任务需要1核,有的任务需要3核,按核上报能支持更细粒度分配。

3. 手写RK3588 NPU Device Plugin的核心实现

3.1 工程结构和环境准备

代码用Go写,因为K8s的Device Plugin官方示例和依赖包都是Go生态。需要准备的东西很简单:

  • 一台RK3588节点(Ubuntu 20.04/22.04都行),跑着kubelet。
  • Go 1.20以上的开发环境。
  • 依赖包:google.golang.org/grpc、k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1,这个是K8s提供的Device Plugin API的Go定义。

工程结构我是这样组织的:

rk3588-npu-device-plugin/ ├── go.mod ├── main.go ├── plugin/ │ ├── server.go # gRPC服务,监听socket │ ├── plugin.go # Device Plugin核心逻辑 │ └── register.go # 向kubelet注册 └── deploy/ ├── plugin.yaml # DaemonSet(或者systemd单元) └── pod-example.yaml

这里建议把注册逻辑和Device Plugin服务逻辑分开写。因为注册和gRPC服务是有顺序的:先启动gRPC服务监听自己的Socket,然后再去发注册请求。如果你先把服务起好,再注册,中间有短窗口期kubelet可能还没准备好,需要做重试。

3.2 ListAndWatch:上报3个核心设备

核心代码不多,我直接贴最关键的一段。设备列表写死为三个核心,健康状态初始为Healthy:

package plugin import ( "context" "time" "google.golang.org/grpc" pluginapi "k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1" ) const resourceName = "rockchip.com/npu" type NPUDevicePlugin struct { socket string devices []*pluginapi.Device server *grpc.Server } func NewNPUDevicePlugin(socket string) *NPUDevicePlugin { return &NPUDevicePlugin{ socket: socket, devices: []*pluginapi.Device{ {ID: "npu-core-0", Health: pluginapi.Healthy}, {ID: "npu-core-1", Health: pluginapi.Healthy}, {ID: "npu-core-2", Health: pluginapi.Healthy}, }, } } func (p *NPUDevicePlugin) ListAndWatch(_ *pluginapi.Empty, stream pluginapi.DevicePlugin_ListAndWatchServer) error { if err := stream.Send(&pluginapi.ListAndWatchResponse{Devices: p.devices}); err != nil { return err } // 这里做健康状态循环上报。目前RK3588的NPU驱动没有可靠的 // 单设备健康查询接口,所以简单点:每30秒重新上报一次即可。 for { select { case <-stream.Context().Done(): return nil case <-time.After(30 * time.Second): if err := stream.Send(&pluginapi.ListAndWatchResponse{Devices: p.devices}); err != nil { return err } } } }

这里有三个细节需要说透:

  1. 设备ID用什么格式无所谓,npu-core-0这种可读性好、调试方便。真正重要的是注册时上报的资源名,也就是rockchip.com/npu里的vendor一定要是域名格式,不能随便起个名字,否则kubelet会拒绝接收。
  2. 健康检查是Device Plugin可选的,但强烈建议要有一个。有些朋友图省事,只在ListAndWatch里发一次设备列表就返回。这会导致kubelet认为设备列表已结束,插件不可用。更稳妥的做法是保持流持续发送。目前我们没有真正的健康检测手段,就定时重新上报。
  3. 如果未来想做一个真正的健康检查,可以读取/sys/class/devfreq/fdab0000.npu/load,判断NPU驱动是否还在响应用户空间调用。但这个值反映的是负载,不是设备存活,不能直接用。

3.3 Allocate:把设备ID翻译成NPU核心掩码

Allocate是所有环节里最见功夫的地方。我对它的理解是:设备ID是K8s侧的抽象,实际NPU核心的使用方式由我们决定。RK3588的RKNN Runtime在使用时,需要传入一个核心掩码rknn_core_mask,取值有RKNN_NPU_CORE_0、RKNN_NPU_CORE_1、RKNN_NPU_CORE_2、RKNN_NPU_CORE_0_1、RKNN_NPU_CORE_0_1_2等。

所以我的方案是把"请求几个设备ID"转换成"掩码的比特位组合":

  • npu-core-0对应掩码第0位,即值1
  • npu-core-1对应掩码第1位,即值2
  • npu-core-2对应掩码第2位,即值4
  • 如果申请到2个核心(比如0和2),掩码就是1+4=5

然后这个掩码通过环境变量RKNN_NPU_CORE_MASK注入容器。容器内的应用启动时,读这个环境变量,传给rknn_init即可。

代码实现如下:

func (p *NPUDevicePlugin) Allocate(_ context.Context, reqs *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) { responses := &pluginapi.AllocateResponse{} for _, req := range reqs.ContainerRequests { coreMask := 0 for _, id := range req.DevicesIDs { switch id { case "npu-core-0": coreMask |= 1 << 0 case "npu-core-1": coreMask |= 1 << 1 case "npu-core-2": coreMask |= 1 << 2 } } resp := &pluginapi.ContainerAllocateResponse{ EnvVars: []*pluginapi.KeyValue{ { Key: "RKNN_NPU_CORE_MASK", Value: fmt.Sprintf("%d", coreMask), }, }, } responses.ContainerResponses = append(responses.ContainerResponses, resp) } return responses, nil }

看到这里你可能会觉得:这比NVIDIA的插件简单太多了吧?是的,因为RK3588的NPU不需要像GPU那样挂载PCIe设备,不需要处理CUDA版本目录,设备节点就一个/dev/rknpu。真正麻烦的反而是下一步:设备节点怎么进容器、RKNN runtime和固件怎么让容器内应用读得到。这些Allocate之外的事,比写Allocate本身费心思,后面我会专门讲。

还有一个细节:Allocate接口里还会返回Mounts。有的设备需要把某些目录挂载进去才可用。RK3588的NPU驱动固件目录也需要进去,但这个我更倾向于直接打进镜像,而不是每次在Allocate里挂载。理由是固件版本和runtime版本需要匹配,如果把固件从宿主机随意挂进去,镜像移植性会很差。

3.4 Register与启动流程

写完了核心接口,还得让插件真正运行起来。Start函数负责创建Socket、启动gRPC Server、注册到kubelet。这里有一大坑:Socket文件如果上次没有清理干净,会因为address already in use起不来,必须os.Remove。

package plugin import ( "context" "fmt" "net" "os" "time" "google.golang.org/grpc" pluginapi "k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1" ) // Start 启动插件服务并注册到kubelet func (p *NPUDevicePlugin) Start() error { if err := os.Remove(p.socket); err != nil && !os.IsNotExist(err) { return err } _ = os.MkdirAll("/var/lib/kubelet/device-plugins", 0755) lis, err := net.Listen("unix", p.socket) if err != nil { return err } p.server = grpc.NewServer() pluginapi.RegisterDevicePluginServer(p.server, p) go func() { _ = p.server.Serve(lis) }() // 连接kubelet的固定socket进行注册,带重试 conn, err := grpc.Dial("unix:///var/lib/kubelet/device-plugins/kubelet.sock", grpc.WithInsecure(), grpc.WithBlock(), ) if err != nil { return err } defer conn.Close() client := pluginapi.NewRegistrationClient(conn) req := &pluginapi.RegisterRequest{ Version: pluginapi.Version, Endpoint: "npu.sock", ResourceName: resourceName, } // 第一次失败最多重试10次,每次间隔5秒 for i := 0; i < 10; i++ { if _, err := client.Register(context.Background(), req); err == nil { return nil } time.Sleep(5 * time.Second) } return fmt.Errorf("register to kubelet failed") }

main.go里调用它:

package main import ( "flag" "log" "rk3588-npu-device-plugin/plugin" ) func main() { var socket = flag.String("socket", "/var/lib/kubelet/device-plugins/npu.sock", "device plugin socket") flag.Parse() p := plugin.NewNPUDevicePlugin(*socket) if err := p.Start(); err != nil { log.Fatalf("failed to start: %v", err) } select {} }

编译产物是一个二进制。注意K8s Device Plugin的Socket路径有严格的约定:必须在/var/lib/kubelet/device-plugins/目录下。同时插件需要有足够的权限访问这个目录。我用的是systemd单元部署,因为Device Plugin属于节点级系统组件,不应该跑在容器里再挂载一堆目录。用DaemonSet虽然也可以,但会让节点初始化变得更绕。

3.5 部署并验证资源被识别

在RK3588节点上,把编译好的rk3588-npu-device-plugin放到/usr/local/bin/,写一个systemd单元文件:

[Unit] Description=RK3588 NPU Device Plugin After=kubelet.service [Service] ExecStart=/usr/local/bin/rk3588-npu-device-plugin Restart=always RestartSec=5 StandardOutput=journal [Install] WantedBy=multi-user.target

启动后观察日志:

systemctl start rk3588-npu-device-plugin journalctl -u rk3588-npu-device-plugin -f

如果注册成功,kubelet的日志里会出现类似"Registered device plugin with name rockchip.com/npu"的记录。然后看节点资源:

kubectl describe node rk3588-node-01

在Capacity和Allocatable两节里,应该能看到:

rockchip.com/npu: 3

此时,K8s已经"看到"了NPU。接下来才是真正的考验:怎么让容器里跑起来的推理程序,真的用上被分配的核心。

4. 把RKNN Runtime和设备节点送进容器,推理任务才算真正跑起来

4.1 NPU不是挂上就能用,容器里还缺三样东西

很多朋友做到上面那步,以为资源能被调度了就万事大吉,结果Pod一启动,应用直接报Failed to open /dev/rknpu或者librknnrt.so not found。这个坑我踩过,几乎必然会遇到。原因是:K8s把Pod调到这个节点,只是说明Pod有资格用NPU;容器进程能不能访问设备,取决于容器里有没有设备节点、依赖库和固件。

具体缺三样东西:

  1. 设备节点/dev/rknpu。这是内核驱动提供的字符设备。如果容器里没有这个节点,open("/dev/rknpu", O_RDWR)会失败。
  2. RKNN runtime库librknnrt.so。所有推理方法都通过它跟驱动交互,这个库通常来自Rockchip发布的内核SDK或runtime包。
  3. NPU固件。Rockchip的NPU需要从用户空间加载一段固件,一般放在系统的firmware目录,比如/usr/lib/firmware/rknpu_fw.bin之类。缺了它,驱动虽然能打开设备节点,但初始化会卡住或报版本错误。

设备节点可以用两种方式进容器:一种是在YAML里通过hostPath直接挂载;另一种是在Device Plugin的Allocate接口里通过ContainerDevices返回,kubelet会把它注入容器。我选择在Allocate里返回设备节点,因为这才是插件该管的事,业务Pod不需要关心宿主机设备路径:

resp := &pluginapi.ContainerAllocateResponse{ Devices: []*pluginapi.DeviceSpec{ { HostPath: "/dev/rknpu", ContainerPath: "/dev/rknpu", Permissions: "rw", }, }, ... }

如果采用这个方式,需要1.12以上K8s,Device Plugin API才支持DeviceSpec。

至于库和固件,我建议直接构建进镜像,不要用hostPath。原因有两个:

  • 不同板子可能用不同版本的RKNN runtime,打成镜像才能保证镜像在任意节点行为一致。
  • 在镜像里做库的版本控制、多版本共存,远比在每台宿主机上维护全局目录干净。

如果非要用hostPath挂载宿主机的/usr/lib/librknnrt.so,确实能跑,但会遇到"在节点A跑得好好的,到节点B就报版本不兼容"的情况。

4.2 Dockerfile与启动脚本,把环境变量变成核心掩码

我的业务镜像大概长这样:

FROM ubuntu:20.04 # 把RKNN runtime库和依赖装进镜像 COPY install/librknnrt.so /usr/lib/librknnrt.so COPY install/rockchip_rknn_runtime_*.deb /tmp/ RUN dpkg -i /tmp/rockchip_rknn_runtime_*.deb || true && \ rm -rf /tmp/*.deb # NPU固件目录,放进镜像 COPY install/rknpu_fw.bin /usr/lib/firmware/rknpu_fw.bin RUN apt-get update && apt-get install -y \ libgomp1 \ python3 \ python3-pip \ && pip3 install opencv-python numpy COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh COPY app /app WORKDIR /app ENTRYPOINT ["/entrypoint.sh"]

入口脚本负责把Device Plugin注入的环境变量,整理成进程需要的形式:

#!/bin/bash set -e export NPU_CORE_MASK="${RKNN_NPU_CORE_MASK:-3}" echo "NPU core mask: $NPU_CORE_MASK" exec python3 /app/infer.py

RKNN_NPU_CORE_MASK来自Allocate接口注入的EnvVars。如果某个Pod忘了通过Device Plugin调度,或者直接手动绑节点,这个环境变量就不存在,此时默认给一个双核掩码(3)不至于让任务挂死。

在infer.py里,真正需要做的只是读取这个变量并传给RKNN接口:

import os import numpy as np from rknn.api import RKNN core_mask = int(os.getenv("RKNN_NPU_CORE_MASK", "3")) rknn = RKNN() rknn.load_rknn("/app/model/yolov8s.rknn") # 初始化runtime时传入核心掩码 rknn.init_runtime(target="rk3588", npu_core_mask=core_mask) img = ... outputs = rknn.inference(inputs=[img]) print("done")

RKNN-Toolkit2的Python接口支持在init_runtime里指定npu_core_mask,掩码含义和C API一致。在K8s里,所有这些对外部设备的感知,最终都收敛成一个小小的环境变量,这也是我为什么坚持在Allocate里不用复杂挂载、只用环境变量的原因:应用代码改动最小,运维可解释性最强。

4.3 一次完整的调度生效过程

这时候整个链路已经打通,我把最终流程完整串一遍:

  1. 上报:Device Plugin把rockchip.com/npu总共3个设备上报给kubelet,kubelet转成扩展资源rockchip.com/npu: 3。
  2. 调度:用户提交YAML,Pod声明limits: rockchip.com/npu: "2"。调度器发现节点上可用资源大于等于2,就把Pod调度到RK3588节点。
  3. 分配:kubelet在启动Pod时,向Device Plugin发起Allocate请求,请求中的DevicesIDs是2个设备ID(比如npu-core-0和npu-core-1)。
  4. 注入:插件返回环境变量RKNN_NPU_CORE_MASK=3和设备节点/dev/rknpu。
  5. 运行:容器启动,入口脚本读取环境变量,Python应用拿到核心掩码,初始化RKNN Runtime时使用这两个核来回推理。

我实测跑一个640x640输入的YOLOv8s模型,单核推理大概要32ms,双核约23ms,三核全开约18ms。这个数据仅供大家参考,不同模型、不同量化方式差异很大。重点是:Pod内通过环境变量确实能感知到自己被分配了多少核,并且在多Pod并发时,每个Pod的掩码互不冲突。

5. 上线之后踩的坑,以及NPU监控的补充

5.1 最经典的坑:Kubelet重启后插件"失联"

我的第一个Workload上线跑了一个星期,中间某次kubelet重启后,节点上所有GPU/NPU相关的Pod全部卡在ContainerCreating。查日志发现kubelet压根不认识rockchip.com/npu了,节点的Capacity里,NPU这一列直接消失。

原因在于:Device Plugin向kubelet注册是一个"即时行为",kubelet重启后会清理掉以前的插件Socket,重新向已注册的插件目录发起连接,但前提是插件还活着,并且还能响应gRPC请求。如果插件因为某个bug死了,kubelet没法自己恢复它,只能等插件重启后重新注册。

我用的systemd单元,Restart=always,理论上插件进程死了会自动拉起并重新注册。但当时的问题在于:旧Socket文件没清理干净,新进程起不来,所以就卡死了。我在代码里用os.Remove(p.socket)处理了这个情况,但部署时旧版本没有这步。建议大家都加上这一行,并且插件启动后主动等待2到3秒再注册,给kubelet一点反应时间。

5.2 Allocate拿到的设备ID数量和Pod请求数对不上

有一次我提交了rockchip.com/npu: "1"的Pod,结果在代码日志里看到Allocate请求中包含了2个设备ID。查了很久才发现,这是K8s 1.14之后出现的特性:Device Plugin有GetPreferredAllocation接口,kubelet在某些情况下会把Pod请求数量翻译成建议分配列表。如果没有实现这个接口,kubelet会自己选设备,选出来的数量依然等于请求数量才对。

后来反复测试,确认这不是插件逻辑问题。最终原因是我在测试的Pod里同时声明了limits和requests不一致。这给大家一个教训:Extended Resource的limits和requests最好保持一致。如果只写limits,K8s会自动把requests设置为跟limits一样;如果两者都写且values不一致,调度和实际分配会出现心理预期边界模糊,最终Allocate拿到的数量以limits为准。

5.3 Pod能调度进去,但容器里打不开/dev/rknpu

这个问题也很典型。容器能创建,但Python程序报open /dev/rknpu: No such file or directory。排查后发现是挂载权限问题:虽然Allocate里返回了设备节点,但容器里没有加载rknpu驱动的knl层,而且有些嵌入式板子的/dev/rknpu创建依赖udev规则,设备节点根本没在容器内创建。

解决办法是给Pod的securityContext加上特权或者至少privileged: true,并把设备挂载改为和宿主机同名路径:

securityContext: privileged: true

如果你是更严格的k8s环境,不想用privileged,可以改用如下方式显式声明device:

resources: limits: rockchip.com/npu: "1"

但devices字段是1.30以后才有的alpha特性,如果你集群版本不够老,最省事的还是开privileged。边缘集群,安全性要求没那么苛刻,可接受。

另一个细节:RK3588某些内核版本下,/dev/rknpu的权限默认是0660,用户组是root。容器内如果不以root运行,也打不开。所以我在业务镜像里把执行用户设置为root,或者加到group为0。为了省事,推荐业务镜像直接以root运行,别折腾非root用户,除非你熟悉设备cgroup的授权规则。

5.4 说好的监控呢?补充NPU利用率采集

标题里有"监控NPU资源",这里我也简单补上。K8s能把NPU调度起来只解决了一半问题,运维还得看它到底忙不忙。好在RK3588的debugfs暴露了一个非常方便的利用率文件:

cat /sys/kernel/debug/rknpu/load

也有另一种路径常见于较新内核:

cat /sys/class/devfreq/fdab0000.npu/load

输出类似:

current_freq: 1000000000 load: 78%

把这两行包装成Prometheus文本格式,用node-exporter的textfile collector收集,就能在Grafana里画出每个节点的NPU使用率曲线。

我的做法是在节点上跑一个5秒一次的脚本,把load写入/var/lib/node_exporter/textfile_collector/npu_usage.prom。Prometheus里能查到的metrics长这样:

rk_npu_usage_percent{node="rk3588-node-01"} 78 rk_npu_freq_hz{node="rk3588-node-01"} 1000000000

这个监控和Pod的调度联动,能发现一个常见业务问题:Pod申领了2个核心,但实际NPU负载一直很低,说明模型根本没跑在NPU上,或者是推理循环没有控制好帧率,导致NPU空闲。有数值才能及时调整调度策略。

5.5 扩展思路:这还能再往前走一步

Device Plugin这套机制做完后,我最大的感慨是:K8s的抽象其实没你想的那么死板。

当前实现按"整核心"分配,Pod之间不会共享NPU核心,因此不存在算力互相挤兑的问题。但如果你想让多个轻量Pod共享一个核心,那就要引入更细粒度的调度策略,比如在部署上层加一层"算力配额"的概念,或者在应用层用进程内分时复用。RK3588的NPU驱动目前没有提供硬隔离,所以跨Pod共享同一个核心仍需要业务层让步,这一点在文档里要写清楚。

另外,如果你手头有多种板卡,包括RK3588和RK3568,可以给每个型号上报不同资源名,比如rockchip.com/npu-rk3588: "3"和rockchip.com/npu-rk3568: "1",这样调度器能天然帮你把不同负载分发到对应型号节点,比打标签加NodeSelector强得多。

还有个方向是结合K8s的调度器扩展能力,做"剩余NPU核心数优先"的调度策略。默认的LeastRequestedPriority已经很够用,但如果你的集群里同时有RK3588和RK3568,而不同模型的路演优先级不一样,那就可以用scheduler extender或者写一个独立的调度插件,把"节点剩余NPU算力"作为评分项加进去。这块涉及K8s调度框架的扩展点,有兴趣的可以看kube-scheduler的framework插件接口,写起来比想象中简单。

说实话,把单一节点的NPU接进K8s不是个了不起的技术,但它确实解决了我在生产里最头疼的问题:任务部署流程、资源审计、编排升级全部统一到了K8s的标准模型里。官方没做,不代表不能做,只看你是否愿意把这条路走通。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 23:07:07

工业相机镜头选型:靶面匹配、焦距计算与畸变控制六步法

1. 为什么镜头选型是机器视觉项目里最常被低估的“生死线” 干过三年以上工业视觉项目的人都知道&#xff0c;一个缺陷检测系统跑不起来&#xff0c;八成不是算法写错了&#xff0c;也不是光源没调好&#xff0c;而是镜头——这个夹在相机和工件之间、看起来最不起眼的玻璃圆筒…

作者头像 李华
网站建设 2026/9/29 23:06:59

直播拆解还在手动记笔记?这6款AI工具实测,选对省下90%时间

你是不是也遇到过这种情况&#xff1a;刷到一个干货满满的达人直播&#xff0c;内容信息量爆炸&#xff0c;想记重点手速完全跟不上。直播结束了&#xff0c;脑子里只剩下“刚刚讲得太好了”&#xff0c;具体好在哪里&#xff0c;一句话都想不起来。或者你自己就是个内容创作者…

作者头像 李华
网站建设 2026/9/29 23:06:21

RocketMQ消息堆积实战:从排查链路到应急处理全攻略

RocketMQ的消息堆积问题&#xff0c;几乎是我面试中被问到概率最高的实战题。说实话&#xff0c;这个问题能看出一个人是只会用MQ还是真在生产环境扛过事。这次不聊那些"提高消费能力""扩容"之类的官话&#xff0c;我直接把这几年在处理堆积问题时踩过的坑…

作者头像 李华
网站建设 2026/9/29 23:04:06

普及一下AI应用开发,需要达到的强度!

在AI应用开发领域&#xff0c;仅仅会调接口和构建基础RAG系统已无法满足企业需求。企业更看重的是能够将大模型融入具体业务并确保线上服务稳定的人才。文章提出了五项关键技能&#xff1a;扎实的Python工程基础、掌握RAG数据链、理解框架原理而非仅调包、独立部署线上服务以及…

作者头像 李华
网站建设 2026/9/29 23:03:44

股票分析MCP服务stock-scanner-mcp配TaoToken:settings.json与config.toml骨架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华