1. 缘起:一块被“闲置”的算力
手里有一块 RK3588 的板子,6 TOPS 的 NPU 算力,跑个 YOLOv8 推理能到几十帧,功耗还低得感人。这东西放在边缘侧做视觉检测、做小模型推理,性价比几乎没对手。但问题来了:当你手里不止一块 RK3588,而是五块、十块,甚至一个机柜里插了一排,你怎么管?
我最初的方案很土:每块板子 SSH 上去,手动npu-smi看一眼,然后scp模型过去,写个 systemd 服务跑起来。三五块还能忍,上了十块之后,模型版本不一致、某块板子 NPU 被占满、某块板子挂了没人知道,运维成本直接爆炸。这时候自然会想到 K8s——毕竟 GPU 能被 K8s 调度,NPU 凭什么不行?
现实很骨感。翻遍 RK3588 的官方文档、Rockchip 的 GitHub、各种论坛,你会发现一个尴尬的事实:官方从来没有提供过 RK3588 NPU 的 K8s Device Plugin。NVIDIA 有nvidia-device-plugin,华为昇腾有ascend-device-plugin,连 Intel 的 GPU 都有intel-gpu-plugin,唯独 RK3588 的 NPU,官方只给了你一个librknnrt.so和rknn_server,剩下的全靠自己。
这篇文章就是记录我怎么把这个坑填上的。核心思路是:用 K8s 的 Device Plugin 机制,把 RK3588 的 NPU 抽象成一种可调度资源,让 Pod 像申请nvidia.com/gpu一样申请rockchip.com/npu。整套方案我已经在几块 RK3588 上跑通了,YOLOv8 推理服务通过 K8s 部署,NPU 资源自动分配,Prometheus 能监控到每块板子的 NPU 占用率。
如果你也在做边缘 AI 部署,手里有 RK3588 或者类似的国产 SoC,想把它们纳入统一的容器编排体系,这篇内容应该能帮你省掉至少两周的摸索时间。我会从 Device Plugin 的原理讲起,到具体的代码实现、部署配置、监控接入,最后把我踩过的坑一个个列出来。
2. 先搞明白:K8s 到底怎么“看见”一块 NPU
2.1 Device Plugin 机制的核心逻辑
K8s 原生只认识 CPU 和内存这两种资源。GPU、NPU、FPGA 这些异构设备,K8s 本身是不认识的。那 NVIDIA 的 GPU 是怎么被调度的?靠的就是Device Plugin这个扩展机制。
Device Plugin 的本质是一个运行在节点上的 gRPC 服务,它做三件事:
第一,注册。Plugin 启动后,通过 kubelet 暴露的 Unix Socket(默认/var/lib/kubelet/device-plugins/kubelet.sock)向 kubelet 注册自己,告诉 kubelet:“我是管理rockchip.com/npu这种资源的”。
第二,上报设备列表。kubelet 会调用 Plugin 的ListAndWatch接口,Plugin 返回当前节点上所有可用的 NPU 设备 ID 列表。kubelet 拿到这个列表后,会把这些设备作为扩展资源上报给 API Server。比如节点上有 3 个 NPU 核心,那节点的可分配资源里就会多出rockchip.com/npu: 3。
第三,分配设备。当有 Pod 申请rockchip.com/npu: 1时,调度器会找一个有可用 NPU 的节点,然后 kubelet 调用 Plugin 的Allocate接口,Plugin 返回这个 Pod 应该挂载哪些设备文件、设置哪些环境变量。kubelet 据此配置容器。
整个流程里,Device Plugin 不负责调度决策,调度是 K8s 调度器基于 kubelet 上报的资源量做的。Plugin 只负责“如实上报”和“按需分配”。
2.2 RK3588 NPU 的特殊性在哪里
理解了 Device Plugin 机制,再看 RK3588 的 NPU,会发现几个和 GPU 不一样的地方,这些差异直接决定了实现方案。
第一,RK3588 的 NPU 不是一个独立的 PCIe 设备。NVIDIA 的 GPU 是 PCIe 卡,有独立的设备文件/dev/nvidia0、/dev/nvidiactl等。RK3588 的 NPU 是 SoC 内部的一个 IP 核,它通过内核驱动暴露出来的设备节点通常是/dev/rknpu或者/dev/dri/renderD129这类。具体是哪个,取决于你用的内核版本和 RKNPU 驱动版本。我用的 5.10 内核加上 RKNPU2 驱动,暴露的是/dev/dri/renderD129。
第二,NPU 的“核心数”概念和 GPU 不同。RK3588 的 NPU 官方标称 6 TOPS,实际上是 3 个 NPU 核心,每个 2 TOPS。这三个核心可以独立工作,也可以协同。在rknn_server的视角里,它管理的是这三个核心。所以我们在 Device Plugin 里上报资源时,可以按核心数上报,比如rockchip.com/npu: 3。但这里有个坑:如果你不做核心隔离,多个 Pod 同时用 NPU 会互相干扰。这个后面细说。
第三,RK3588 的 NPU 依赖用户态库。光有设备文件不够,容器里还需要librknnrt.so这个运行时库,以及rknn_server这个后台服务。GPU 容器通常只需要挂载设备文件和驱动库,但 RK3588 的 NPU 还需要一个常驻的 server 进程来管理硬件。这意味着我们的 Device Plugin 不仅要分配设备,还要确保容器里有正确的库和 server。
第四,没有官方的设备健康检查机制。NVIDIA 的 Device Plugin 有完善的健康检查,GPU 挂了会从可分配列表里移除。RK3588 的 NPU 没有现成的健康检查接口,我们只能通过npu-smi或者直接读 sysfs 来判断。这个我在实现里加了一个简单的健康检查逻辑。
2.3 方案选型:为什么不用现成的
在动手之前,我调研过几个可能的替代方案。
方案一:用rknn_server做集中式推理服务。就是每块板子上跑一个rknn_server,然后所有推理请求都通过 gRPC 发给它。这个方案的问题在于,它绕过了 K8s 的调度体系。你没法用 K8s 的资源模型来管理 NPU,也没法做细粒度的资源配额。而且rknn_server本身是单点的,挂了就全挂了。
方案二:用 K8s 的 Extended Resource 手动上报。K8s 支持通过 Node 的status.capacity手动设置扩展资源,比如你手动kubectl patch node加上rockchip.com/npu: 3。但这种方式只是“假装”有资源,实际分配时 K8s 不会帮你做任何设备挂载和环境配置。Pod 起来了但用不了 NPU,等于白搭。
方案三:用 Generic Device Plugin。社区有个generic-device-plugin项目,可以通过配置来暴露任意设备。我试过,对于简单的设备文件挂载是可行的,但它不支持 RK3588 NPU 需要的rknn_server生命周期管理,也不支持多核心的细粒度分配。而且它的健康检查机制太简单,NPU 出问题了它发现不了。
所以最终还是决定自己写一个专用的 Device Plugin。代码量其实不大,核心逻辑也就几百行,但能把 RK3588 NPU 的特殊需求都照顾到。
3. 动手实现:一个能用的 RK3588 NPU Device Plugin
3.1 环境准备与依赖确认
在写代码之前,先把环境理清楚。我用的环境是:
- 硬件:RK3588 开发板(具体型号不限,只要 NPU 驱动正常就行)
- 系统:Ubuntu 20.04(Rockchip 官方 BSP 或者 Armbian 都可以)
- 内核:5.10(RKNPU2 驱动)
- K8s:v1.28(kubeadm 部署的单 master 集群)
- 容器运行时:containerd
首先确认 NPU 驱动和运行时是否正常。在板子上执行:
# 检查 NPU 设备节点 ls -l /dev/dri/ # 应该能看到 renderD129 或类似的节点 # 检查 rknn_server 是否在运行 ps aux | grep rknn_server # 用 rknn 工具查 NPU 信息 cat /sys/kernel/debug/rknpu/version如果/dev/dri/renderD129不存在,说明 RKNPU 驱动没加载。需要先确认内核配置里CONFIG_ROCKCHIP_RKNPU是开启的,然后modprobe rknpu。
rknn_server通常在/usr/bin/rknn_server,它是 RKNN Toolkit 的一部分。如果你只装了librknnrt.so没装rknn_server,需要从 Rockchip 的 GitHub 仓库下载rknpu2的 runtime 包。
注意:
rknn_server的版本必须和librknnrt.so的版本匹配,否则会出现“版本不兼容”的错误。我一开始用了 1.4.0 的 server 配 1.5.0 的库,结果推理直接段错误。后来统一到 1.5.2 才正常。
3.2 Device Plugin 的代码骨架
Device Plugin 的核心是实现几个 gRPC 接口。我用 Go 来写,因为 K8s 生态的 Device Plugin 示例基本都是 Go,库也齐全。
先定义资源名称和 socket 路径:
const ( resourceName = "rockchip.com/npu" socketPath = "/var/lib/kubelet/device-plugins/rknpu.sock" serverSock = "/var/lib/kubelet/device-plugins/kubelet.sock" )然后实现DevicePluginServer接口。核心是三个方法:
GetDevicePluginOptions:返回 Plugin 的选项,一般返回空就行。
ListAndWatch:这是最重要的方法。它返回一个流,kubelet 通过这个流获取设备列表。我们的实现是:启动时扫描/dev/dri/下的 render 节点,每个节点对应一个 NPU 核心。然后定期(比如每 30 秒)重新扫描一次,如果发现设备数量变化或者设备不健康,就通过流通知 kubelet。
func (p *RKNpuPlugin) ListAndWatch(empty *pluginapi.Empty, stream pluginapi.DevicePlugin_ListAndWatchServer) error { // 初始设备列表 devices := p.discoverDevices() resp := &pluginapi.ListAndWatchResponse{Devices: devices} if err := stream.Send(resp); err != nil { return err } // 定期健康检查 ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for range ticker.C { newDevices := p.discoverDevices() if !devicesEqual(devices, newDevices) { devices = newDevices resp := &pluginapi.ListAndWatchResponse{Devices: devices} if err := stream.Send(resp); err != nil { return err } } } return nil }discoverDevices的逻辑是:遍历/dev/dri/renderD*,对每个节点检查它是不是 NPU。怎么判断?RK3588 的 NPU 对应的 render 节点通常是renderD129,但这不是绝对的。更可靠的方法是读/sys/class/drm/renderD129/device/uevent,看里面有没有DRIVER=rknpu之类的标识。我实际用的是检查/sys/kernel/debug/rknpu/目录是否存在,以及对应的 render 节点是否可读。
Allocate:当 kubelet 决定把某个 NPU 分配给 Pod 时,会调用这个方法。我们需要返回一个AllocateResponse,里面包含:
- 要挂载的设备文件(比如
/dev/dri/renderD129) - 要设置的环境变量(比如
RKNPU_DEVICE_ID=0) - 要挂载的库文件(
librknnrt.so)
func (p *RKNpuPlugin) Allocate(ctx context.Context, req *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) { resp := &pluginapi.AllocateResponse{} for _, container := range req.ContainerRequests { containerResp := &pluginapi.ContainerAllocateResponse{ Devices: []*pluginapi.DeviceSpec{}, Envs: map[string]string{}, Mounts: []*pluginapi.Mount{}, } for _, deviceID := range container.DevicesIDs { // 根据 deviceID 找到对应的设备节点 devPath := p.deviceIDToPath(deviceID) containerResp.Devices = append(containerResp.Devices, &pluginapi.DeviceSpec{ ContainerPath: devPath, HostPath: devPath, Permissions: "rw", }) containerResp.Envs["RKNPU_DEVICE"] = deviceID } // 挂载 rknn 库 containerResp.Mounts = append(containerResp.Mounts, &pluginapi.Mount{ ContainerPath: "/usr/lib/librknnrt.so", HostPath: "/usr/lib/librknnrt.so", ReadOnly: true, }) resp.ContainerResponses = append(resp.ContainerResponses, containerResp) } return resp, nil }这里有个关键点:rknn_server怎么处理?我的做法是,rknn_server不在容器里跑,而是在宿主机上跑一个全局的 server。容器里的librknnrt.so通过 Unix Socket 或者网络和宿主机的rknn_server通信。这样做的原因是,rknn_server需要直接访问 NPU 硬件,如果每个容器都跑一个,会互相抢设备。宿主机跑一个,容器通过 socket 连过去,既简单又稳定。
所以Allocate里还需要挂载rknn_server的 socket 文件。默认路径是/var/run/rknn_server.sock或者/tmp/rknn_server.sock,具体看你的rknn_server配置。
3.3 注册与启动逻辑
Plugin 的main函数需要做几件事:
- 连接 kubelet 的 socket,注册自己。
- 启动 gRPC server,监听自己的 socket。
- 处理信号,优雅退出。
func main() { // 先启动 gRPC server lis, err := net.Listen("unix", socketPath) if err != nil { log.Fatalf("failed to listen: %v", err) } grpcServer := grpc.NewServer() plugin := NewRKNpuPlugin() pluginapi.RegisterDevicePluginServer(grpcServer, plugin) go grpcServer.Serve(lis) // 等待 socket 就绪 time.Sleep(2 * time.Second) // 连接 kubelet 并注册 conn, err := grpc.Dial(serverSock, grpc.WithInsecure(), grpc.WithBlock(), grpc.WithDialer(func(addr string, timeout time.Duration) (net.Conn, error) { return net.DialTimeout("unix", addr, timeout) })) if err != nil { log.Fatalf("failed to connect kubelet: %v", err) } client := pluginapi.NewRegistrationClient(conn) req := &pluginapi.RegisterRequest{ Version: pluginapi.Version, Endpoint: filepath.Base(socketPath), ResourceName: resourceName, } if _, err := client.Register(context.Background(), req); err != nil { log.Fatalf("failed to register: %v", err) } // 等待退出信号 sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) <-sigCh grpcServer.Stop() }注册成功后,kubelet 会在日志里打印类似Registered device plugin for 'rockchip.com/npu' with kubelet的信息。这时候你kubectl describe node就能看到节点的可分配资源里多了rockchip.com/npu。
3.4 部署为 DaemonSet
Device Plugin 需要跑在每个有 NPU 的节点上,所以用 DaemonSet 部署最合适。但这里有个鸡生蛋蛋生鸡的问题:DaemonSet 本身也是 Pod,它需要被调度。如果节点上还没有 NPU 资源,调度器不会把 Pod 调度上去。
解决办法是用nodeSelector或者affinity来指定节点,而不是依赖 NPU 资源。比如给所有有 NPU 的节点打上标签hardware=rk3588-npu,然后 DaemonSet 的nodeSelector选这个标签。
apiVersion: apps/v1 kind: DaemonSet metadata: name: rknpu-device-plugin namespace: kube-system spec: selector: matchLabels: name: rknpu-device-plugin template: metadata: labels: name: rknpu-device-plugin spec: nodeSelector: hardware: rk3588-npu tolerations: - key: node-role.kubernetes.io/master effect: NoSchedule containers: - name: rknpu-device-plugin image: your-registry/rknpu-device-plugin:v1.0 securityContext: privileged: true volumeMounts: - name: device-plugin mountPath: /var/lib/kubelet/device-plugins - name: dev-dri mountPath: /dev/dri - name: rknn-lib mountPath: /usr/lib/librknnrt.so - name: rknn-sock mountPath: /var/run/rknn_server.sock volumes: - name: device-plugin hostPath: path: /var/lib/kubelet/device-plugins - name: dev-dri hostPath: path: /dev/dri - name: rknn-lib hostPath: path: /usr/lib/librknnrt.so - name: rknn-sock hostPath: path: /var/run/rknn_server.sock注意:
securityContext.privileged: true是必须的,因为 Device Plugin 需要访问宿主机的设备节点和 kubelet socket。虽然看起来不太安全,但这是 Device Plugin 的标准做法,NVIDIA 的 plugin 也是 privileged。
4. 实战:用 K8s 部署一个 YOLOv8 推理服务
4.1 构建带 RKNN 运行时的推理镜像
要让 Pod 能用 NPU,镜像里必须有librknnrt.so和 Python 的rknn-toolkit-lite2。我基于arm64v8/ubuntu:20.04构建了一个镜像。
Dockerfile 大概长这样:
FROM arm64v8/ubuntu:20.04 RUN apt-get update && apt-get install -y \ python3 python3-pip libglib2.0-0 libsm6 libxext6 libxrender-dev \ && rm -rf /var/lib/apt/lists/* # 拷贝 RKNN 运行时库 COPY librknnrt.so /usr/lib/ COPY rknn_toolkit_lite2-1.5.2-cp38-cp38-linux_aarch64.whl /tmp/ RUN pip3 install /tmp/rknn_toolkit_lite2-1.5.2-cp38-cp38-linux_aarch64.whl # 拷贝推理脚本 COPY infer.py /app/infer.py WORKDIR /app CMD ["python3", "infer.py"]infer.py的核心逻辑是加载 RKNN 模型,然后循环推理:
from rknnlite.api import RKNNLite import numpy as np import cv2 rknn = RKNNLite() ret = rknn.load_rknn('yolov8n.rknn') ret = rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_0) img = cv2.imread('test.jpg') img = cv2.resize(img, (640, 640)) outputs = rknn.inference(inputs=[img]) print(outputs)这里有个细节:init_runtime的core_mask参数可以指定用哪个 NPU 核心。NPU_CORE_0、NPU_CORE_1、NPU_CORE_2分别对应三个核心,NPU_CORE_AUTO是自动选择。如果你在 Device Plugin 里做了核心隔离,这里就可以根据环境变量RKNPU_DEVICE来指定核心。
4.2 部署 YAML 与资源申请
有了镜像,部署就简单了。关键是resources.limits里申请 NPU:
apiVersion: apps/v1 kind: Deployment metadata: name: yolov8-inference spec: replicas: 3 selector: matchLabels: app: yolov8-inference template: metadata: labels: app: yolov8-inference spec: containers: - name: inference image: your-registry/yolov8-rknn:latest resources: limits: rockchip.com/npu: 1 env: - name: RKNPU_DEVICE valueFrom: fieldRef: fieldPath: metadata.annotations['rockchip.com/npu-device']注意:
rockchip.com/npu: 1表示申请一个 NPU 核心。如果你有 3 个核心,最多可以起 3 个这样的 Pod(每个节点)。K8s 调度器会自动把 Pod 分散到有可用 NPU 的节点上。
部署之后,kubectl get pods -o wide可以看到 Pod 被调度到了哪些节点。kubectl describe node可以看到 NPU 资源的分配情况。
4.3 验证 NPU 是否真的被用上了
Pod 跑起来不代表 NPU 真的在工作。验证方法有几个:
方法一:在 Pod 里执行npu-smi。如果镜像里带了npu-smi工具,可以直接看 NPU 的占用率。但npu-smi通常需要访问/sys/kernel/debug/rknpu/,容器里可能没有这个路径。可以在 Device Plugin 的Allocate里把这个目录也挂载进去。
方法二:在宿主机上看 NPU 负载。执行cat /sys/kernel/debug/rknpu/load,会输出类似NPU load: 45%的信息。如果 Pod 在推理,这个数字会明显上升。
方法三:看推理延迟。在 Pod 日志里打印每次推理的耗时。如果 NPU 正常工作,YOLOv8n 在 640x640 输入下的推理时间应该在 20-40ms 左右。如果走了 CPU,会慢一个数量级。
我实测下来,3 个 Pod 各占一个 NPU 核心,同时跑 YOLOv8n,每个 Pod 的推理延迟稳定在 30ms 左右,NPU 总负载在 70%-80% 之间。这个表现已经能满足大部分边缘视觉场景了。
5. 监控与运维:让 NPU 资源看得见
5.1 用 Prometheus 采集 NPU 指标
K8s 集群里通常已经有 Prometheus 了。我们要做的是把 NPU 的指标暴露出来。最简单的方式是写一个小的 exporter,跑在宿主机上,读取/sys/kernel/debug/rknpu/load和/sys/kernel/debug/rknpu/version,然后暴露成 Prometheus 格式。
Exporter 的核心逻辑:
from prometheus_client import start_http_server, Gauge import time npu_load = Gauge('rknpu_load_percent', 'NPU load percentage') npu_temp = Gauge('rknpu_temp_celsius', 'NPU temperature') def collect(): with open('/sys/kernel/debug/rknpu/load', 'r') as f: load = int(f.read().strip().split(':')[1].strip().rstrip('%')) npu_load.set(load) # 温度可能在其他路径,比如 /sys/class/thermal/thermal_zone0/temp with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f: temp = int(f.read().strip()) / 1000.0 npu_temp.set(temp) if __name__ == '__main__': start_http_server(9101) while True: collect() time.sleep(5)然后把这个 exporter 也做成 DaemonSet,Prometheus 通过hostNetwork或者 Service 来抓取。
5.2 Grafana 面板配置
有了指标,Grafana 面板就简单了。我配了几个关键图表:
- NPU 负载趋势:用
rknpu_load_percent做时间序列图,可以看到每个节点的 NPU 负载变化。 - NPU 温度:用
rknpu_temp_celsius,超过 80 度就要注意散热了。 - Pod 与 NPU 映射:这个需要结合 K8s 的指标,通过
kube_pod_container_resource_limits和自定义标签来关联。
注意:RK3588 的 NPU 在满载时温度会比较高,如果散热不好会降频。我在一块没有散热片的板子上跑满载推理,10 分钟后 NPU 温度到了 95 度,推理延迟从 30ms 涨到了 80ms。后来加了风扇才稳定。
5.3 告警规则
Prometheus 的告警规则可以这样配:
groups: - name: rknpu rules: - alert: RKNpuHighLoad expr: rknpu_load_percent > 90 for: 5m labels: severity: warning annotations: summary: "NPU load is above 90% for 5 minutes" - alert: RKNpuHighTemp expr: rknpu_temp_celsius > 85 for: 2m labels: severity: critical annotations: summary: "NPU temperature is above 85°C"这样当 NPU 过载或者过热时,能及时收到告警。
6. 踩坑记录与常见问题排查
6.1 Device Plugin 注册失败
现象:Plugin 启动后,kubelet 日志里没有注册成功的消息,kubectl describe node看不到rockchip.com/npu资源。
排查思路:
第一,检查 socket 路径。kubelet 的 device plugin socket 默认在/var/lib/kubelet/device-plugins/kubelet.sock。如果你的 K8s 是用 kubeadm 部署的,这个路径是对的。但如果你改了 kubelet 的--root-dir,路径会变。用ps aux | grep kubelet看一下实际参数。
第二,检查权限。Plugin 容器需要 privileged 模式,否则无法访问 kubelet socket。如果用的是 containerd,还要确认securityContext.privileged被正确传递。
第三,看 Plugin 日志。如果注册时返回rpc error: code = Unavailable,通常是 kubelet socket 不存在或者权限不对。如果返回already registered,说明之前有残留的 Plugin 没清理干净,重启 kubelet 或者删掉 socket 文件再试。
6.2 Pod 一直 Pending,提示“Insufficient rockchip.com/npu”
现象:节点上明明有 NPU,但 Pod 调度不上去,事件里显示0/3 nodes are available: 3 Insufficient rockchip.com/npu。
原因:kubelet 上报的资源量是 Plugin 通过ListAndWatch返回的设备数量。如果 Plugin 返回了 0 个设备,节点上就不会有可分配资源。
排查:在 Plugin 容器里执行ls /dev/dri/,看能不能看到 render 节点。如果看不到,说明 DaemonSet 的 volume 挂载有问题。检查hostPath是否正确,以及宿主机上/dev/dri/是否存在。
还有一种可能是设备被识别为“不健康”。我的 Plugin 里加了一个健康检查:如果/dev/dri/renderD129存在但不可读,就标记为 unhealthy。kubelet 不会把 unhealthy 的设备计入可分配资源。可以在 Plugin 日志里看到device renderD129 is unhealthy这样的信息。
6.3 容器里推理报错“librknnrt.so not found”
现象:Pod 起来了,但执行推理时报ImportError: librknnrt.so: cannot open shared object file。
原因:镜像里没有librknnrt.so,或者路径不对。
解决:确认 Dockerfile 里COPY librknnrt.so /usr/lib/这一步成功了。另外,librknnrt.so可能有依赖库,比如libstdc++的特定版本。用ldd librknnrt.so检查依赖是否齐全。
我遇到过一次,
librknnrt.so依赖libcurl.so.4,但基础镜像里没有。后来在 Dockerfile 里加了apt-get install -y libcurl4才解决。
6.4 多个 Pod 同时推理时性能下降严重
现象:单个 Pod 推理延迟 30ms,3 个 Pod 同时跑,每个延迟涨到 100ms 以上。
原因:没有做 NPU 核心隔离。三个 Pod 可能都在抢同一个 NPU 核心,或者rknn_server没有正确分配核心。
解决:在 Device Plugin 的Allocate里,根据分配的 deviceID 设置环境变量RKNPU_DEVICE,然后在推理脚本里根据这个环境变量指定core_mask。比如:
import os device_id = int(os.environ.get('RKNPU_DEVICE', '0')) core_mask = [RKNNLite.NPU_CORE_0, RKNNLite.NPU_CORE_1, RKNNLite.NPU_CORE_2][device_id] rknn.init_runtime(core_mask=core_mask)这样每个 Pod 固定用一个核心,互不干扰。实测下来,3 个 Pod 各占一个核心,每个延迟稳定在 35ms 左右,总吞吐量是单 Pod 的 2.8 倍。
6.5 rknn_server 挂了导致所有推理失败
现象:所有 Pod 的推理突然全部报错,日志显示connect to rknn_server failed。
原因:宿主机的rknn_server进程挂了。rknn_server本身没有守护机制,崩溃后不会自动重启。
解决:用 systemd 给rknn_server配一个守护服务:
[Unit] Description=RKNN Server After=network.target [Service] Type=simple ExecStart=/usr/bin/rknn_server Restart=always RestartSec=5 [Install] WantedBy=multi-user.target这样rknn_server挂了会自动重启。另外,Device Plugin 里也可以加一个健康检查,如果发现rknn_server的 socket 不存在,就把所有 NPU 设备标记为 unhealthy,避免 Pod 被调度到有问题的节点上。
6.6 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| Plugin 注册失败 | socket 路径不对或权限不足 | 检查 kubelet 日志和 Plugin 日志 | 确认 socket 路径,开启 privileged |
| 节点无 NPU 资源 | Plugin 未上报设备 | 在 Plugin 容器里ls /dev/dri/ | 检查 volume 挂载和驱动加载 |
| Pod Pending | 资源不足或节点标签不匹配 | kubectl describe pod看事件 | 检查 nodeSelector 和资源量 |
| 推理报错找不到库 | 镜像缺少 librknnrt.so | ldd librknnrt.so | 在 Dockerfile 里补全依赖 |
| 多 Pod 性能下降 | 未做核心隔离 | 看 NPU 负载分布 | 设置 RKNPU_DEVICE 环境变量 |
| rknn_server 崩溃 | 无守护进程 | ps aux | grep rknn_server | 用 systemd 配置自动重启 |
| NPU 温度过高 | 散热不足 | 读/sys/class/thermal/thermal_zone0/temp | 加散热片或风扇 |
7. 还能怎么扩展
这套方案跑通之后,其实还有很多可以优化的地方。
第一,支持 NPU 核心的细粒度分配。目前是按核心数分配,一个 Pod 占一个核心。但有些场景下,一个 Pod 可能只需要半个核心的算力。RK3588 的 NPU 支持时间片轮转,理论上可以做到更细粒度的共享。不过这需要改rknn_server的调度逻辑,工作量比较大。
第二,集成到 K8s 的调度框架里。目前用的是 Device Plugin 的原生调度,调度器只知道“有几个 NPU”,不知道“NPU 的负载是多少”。可以写一个调度器扩展(Scheduler Extender)或者用 K8s 的NodeResourceTopologyAPI,把 NPU 的实时负载也纳入调度决策。这样可以把新 Pod 调度到负载较低的节点上。
第三,支持模型预热和缓存。每次 Pod 启动都要重新加载 RKNN 模型,如果模型比较大(比如 YOLOv8x),加载时间可能好几秒。可以在宿主机上做一个模型缓存,Pod 启动时直接从缓存加载,减少冷启动时间。
第四,多节点 NPU 池化。如果集群里有多种异构设备(RK3588 NPU、昇腾 NPU、NVIDIA GPU),可以用 K8s 的Device Plugin+Node Feature Discovery来做统一的异构资源池。这样用户只需要申请accelerator: 1,调度器自动选择合适的设备类型。
这套东西我还在持续迭代,目前最稳定的还是核心隔离 + 宿主机 rknn_server 的方案。如果你也在折腾 RK3588 的 K8s 集成,希望这篇内容能帮你少走点弯路。有问题的可以在评论区交流,我尽量回复。