1. 缘起:一块被“闲置”的算力
手里有一块 RK3588 的板子,6 TOPS 的 NPU 算力,跑 YOLOv8 推理能到几十帧,功耗还低得感人。这东西放在边缘侧做视觉推理,性价比几乎是无敌的。但问题来了——当你手里不止一块板子,而是五块、十块、甚至一个机柜的时候,怎么管?
我最初的方案很土:每块板子 SSH 上去,手动nohup起一个推理服务,然后在上层用 Nginx 做反向代理,靠配置文件里的 IP 列表轮询。这套东西跑三块板子还行,跑到第八块的时候我就崩溃了——某块板子 NPU 被一个僵尸进程占着,新任务派过去直接超时;另一块板子温度过高降频,推理延迟从 30ms 飙到 200ms,但负载均衡器完全不知道。更别提板子固件升级重启之后,IP 变了,我得手动改配置。
这时候我就在想:K8s 管 CPU 和内存管得那么顺,管 GPU 也有成熟的 device plugin 方案,为什么 RK3588 的 NPU 不行?
去翻了一圈资料,发现现实是这样的:瑞芯微官方提供了 RKNN Toolkit2,提供了librknnrt.so运行时,也提供了rknn_server这个常驻进程来做远程推理。但官方从头到尾没有提供任何 K8s 层面的集成方案。社区里搜“RK3588 NPU K8s”,出来的要么是“如何交叉编译 RKNN”,要么是“K8s 调用 GPU 的通用教程”,真正把这两件事接起来的,几乎没有。
所以这篇文章要干的事很明确:把 RK3588 的 NPU 做成 K8s 里一种可调度、可监控、可配额管理的资源。做完之后,你kubectl describe node能看到rockchip.com/npu: 3,Pod 里写resources.limits就能申请 NPU,调度器会自动把 Pod 放到有空闲 NPU 的节点上。这套东西适合谁?适合正在做边缘 AI 集群、异构算力调度、或者单纯想把手里几块 RK3588 板子管起来的工程师。不需要你是 K8s 专家,但得能看懂 YAML,会写点 Go 或者 Python。
下面我把整个思路、踩过的坑、以及最终能跑的方案,完整拆一遍。
2. 整体设计:为什么是 Device Plugin 而不是别的路子
2.1 先搞清楚 K8s 扩展资源的三种姿势
在 K8s 里让一个自定义硬件被调度,理论上有多条路可以走,但每条路的代价和适用场景完全不同。
第一条路是 Extended Resource。你直接在 Node 的 status 里声明rockchip.com/npu: 3,然后在 Pod 里 request 这个资源。K8s 调度器原生就认识这种带域名前缀的资源名,不需要写任何代码。听起来很美好对吧?但问题在于:谁来自动上报这个资源?谁来做设备分配?Extended Resource 只解决了“声明”和“调度”两个环节,设备发现、健康检查、分配隔离这些事全得你自己想办法。如果你手动改 Node status,那板子重启或者 NPU 挂了,状态就失真了。
第二条路是 Device Plugin。这是 K8s 官方为 GPU、FPGA、网卡这类设备设计的标准扩展机制。核心流程是:kubelet 启动时通过 gRPC 连接你部署的 device plugin,plugin 调用ListAndWatch把设备列表推给 kubelet,kubelet 再把这些设备作为 Extended Resource 上报到 Node status。Pod 调度到节点后,kubelet 调用 plugin 的Allocate接口,plugin 返回设备路径和环境变量,容器运行时据此把设备挂进容器。设备发现、上报、分配、健康检查,全链路都覆盖了。
第三条路是 DRA(Dynamic Resource Allocation)。这是 K8s 1.26 之后引入的新机制,用ResourceClaim和ResourceClass来描述资源请求,比 Device Plugin 灵活得多,支持复杂拓扑和细粒度属性。但 DRA 在 1.30 之前都还是 alpha/beta 状态,边缘场景下用的 K3s 或者老版本 K8s 未必支持,而且学习成本高。
我最终选的是Device Plugin。理由很直接:RK3588 的 NPU 本质上是一个固定数量的离散设备(通常是 1 个,但可以通过多进程分时复用虚拟成多个),没有复杂的拓扑关系,也不需要动态属性匹配。Device Plugin 的成熟度最高,K3s、KubeEdge 这些边缘方案都原生支持,社区案例也多。DRA 那套东西留给以后真有复杂需求的时候再说。
2.2 RK3588 NPU 的特殊性:它不是 GPU
这里必须说清楚一个关键差异,否则后面的设计会走偏。
GPU 做 K8s device plugin 的时候,NVIDIA 那套方案是把整块 GPU 或者 MIG 切片分配给容器,容器里能直接跑 CUDA。但 RK3588 的 NPU 不一样——它没有用户态的直接访问接口。你不能在容器里open("/dev/npu")然后开始算。瑞芯微的设计是:所有 NPU 推理请求都通过librknnrt.so这个用户态库,发给一个叫rknn_server的常驻进程,由这个 server 统一管理 NPU 硬件。
这个架构带来两个后果:
第一,NPU 的“设备节点”其实不是真正的设备节点。/dev/rknpu或者类似的字符设备在标准固件里可能根本不存在,真正干活的是rknn_server这个进程。所以 device plugin 的Allocate返回的 device spec 里,挂载的不能只是设备文件,还得考虑怎么让容器里的进程能连上宿主机的rknn_server。
第二,NPU 天然支持多进程分时复用。rknn_server内部有任务队列,多个进程同时发推理请求,它会排队处理。这意味着我们可以把一块物理 NPU 虚拟成多个“逻辑 NPU”分配给不同 Pod,只要接受一定的性能折损。这个特性对于提高利用率非常关键——毕竟边缘场景下,不是每个 Pod 都能把 6 TOPS 跑满。
所以我的设计目标是:把一块物理 NPU 虚拟成 N 个可调度单元,每个单元通过 device plugin 分配给 Pod,Pod 内通过共享的 rknn_server 做推理。N 取多少?后面会讲,这是个需要实测调优的参数。
2.3 整体架构长什么样
整个方案由四个组件构成,我画不了图,用文字描述清楚:
第一个是 rknn_server,跑在宿主机上,由 systemd 管理,监听一个 Unix Domain Socket 或者 TCP 端口。它是 NPU 硬件的唯一管理者,所有推理请求都经过它。
第二个是 device plugin,以 DaemonSet 形式部署,每个 RK3588 节点跑一个 Pod。它启动后做两件事:一是探测本机 NPU 是否可用(通过尝试连接 rknn_server 或者读取特定文件),二是把虚拟出来的 N 个 NPU 单元通过 gRPC 上报给 kubelet。当有 Pod 申请 NPU 时,它的Allocate接口返回一个挂载了 rknn_server socket 的 volume,以及一个环境变量告诉容器“你分到的是第几号逻辑 NPU”。
第三个是调度层,这部分 K8s 原生就干了。device plugin 上报之后,rockchip.com/npu就成了一个标准的 Extended Resource,调度器自动处理。
第四个是监控层,用 Prometheus 采集 NPU 的利用率、温度、内存占用,Grafana 出图。这部分官方也没现成的,需要自己写 exporter。
下面逐个拆。
3. 核心细节:Device Plugin 到底怎么写
3.1 gRPC 接口的最小实现
K8s Device Plugin 的接口定义在k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1这个包里。你不需要实现全部方法,核心就四个:
GetDevicePluginOptions:返回插件支持哪些可选功能,一般返回空就行。ListAndWatch:这是最重要的。插件通过这个流式接口把设备列表推给 kubelet,设备状态变化时也要推。kubelet 靠这个知道节点上有多少可用设备。Allocate:Pod 调度到节点后,kubelet 调用这个接口,插件返回设备挂载信息和环境变量。GetPreferredAllocation:可选,用于多设备场景下的优选分配,边缘场景一般用不上。
我用 Go 写的,因为官方示例都是 Go,而且 gRPC 的 Go 生态最成熟。核心结构体大概长这样:
type NpuDevicePlugin struct { socketPath string resourceName string deviceCount int devices []*pluginapi.Device server *grpc.Server }deviceCount就是虚拟出来的逻辑 NPU 数量。devices是一个列表,每个元素有ID、Health、Topology三个字段。ID 我用了npu-0、npu-1这种格式,Health 初始都是Healthy。
ListAndWatch的实现逻辑是:先发一次全量设备列表,然后进入一个循环,定期检查每个设备的健康状态。如果发现某个设备不健康了(比如 rknn_server 挂了),就更新状态再推一次。这里有个坑:kubelet 对 ListAndWatch 的首次响应有超时限制,默认好像是 30 秒还是多少,如果你在首次响应前做了太重的初始化(比如等 rknn_server 启动),kubelet 会认为插件挂了,然后反复重启。我的做法是插件启动后立刻返回设备列表,健康检查放到后台 goroutine 里异步做。
3.2 设备发现的正确姿势
怎么判断本机有没有 NPU?最直接的方法是检查/dev/rknpu或者/sys/kernel/debug/rknpu是否存在。但前面说了,不同固件版本这些路径可能不一样。更可靠的方法是尝试连接 rknn_server。
rknn_server 默认监听localhost:9999这个 TCP 端口(不同版本可能不同,我用的固件是 9999)。插件启动时,用一个带超时的 TCP dial 去连这个端口,连上了就认为 NPU 可用。这个方法的优点是:它检测的是“NPU 服务是否真的能用”,而不是“设备文件是否存在”。设备文件存在但 server 没起来的情况太常见了。
但这里有个细节:device plugin 跑在容器里,怎么连宿主机的 rknn_server?两个办法。一是用hostNetwork: true,让 Pod 共享宿主机网络命名空间,直接连127.0.0.1:9999。二是挂载宿主机的网络或者用 Unix Socket。我选了 hostNetwork,简单粗暴,边缘场景下网络隔离本来就没那么严格。
设备数量怎么定?我一开始想的是读/proc/device-tree里的 NPU 节点信息,但发现不同内核版本差异太大。后来干脆做成可配置的:通过环境变量NPU_VIRTUAL_COUNT指定,默认 3。为什么默认 3?后面性能测试那节会详细说。
3.3 Allocate 接口的返回值设计
Allocate是真正决定 Pod 里能不能用 NPU 的地方。它接收一个AllocateRequest,里面包含 kubelet 要求分配的设备 ID 列表。插件需要返回一个AllocateResponse,里面最重要的是ContainerAllocation列表,每个元素对应一个容器的挂载配置。
我的返回值包含三部分:
第一部分是 mounts。把宿主机的/var/run/rknn目录(里面放着 rknn_server 的 Unix Socket,如果有的话)挂到容器里。如果用的是 TCP 连接,这一步可以省,但挂一个空目录也无妨,方便以后扩展。
第二部分是 envs。设置RKNN_SERVER_IP=127.0.0.1和RKNN_SERVER_PORT=9999,告诉容器里的 RKNN 运行时去哪里找 server。再设置一个NPU_DEVICE_ID=npu-0,这个主要是给业务代码看的,方便做日志追踪。
第三部分是 devices。如果宿主机真的有/dev/rknpu设备节点,就在这里声明。没有的话就留空。我实测的固件里这个节点不存在,所以留空也能跑。
这里有个大坑:Allocate返回的 mounts 里,HostPath必须是宿主机上真实存在的路径,否则 kubelet 会报错。我一开始想挂/dev/rknpu,结果宿主机上没这个文件,Pod 一直起不来,报failed to mount。后来改成挂一个确定存在的目录,问题解决。
3.4 健康检查与故障自愈
Device Plugin 的健康检查机制是这样的:插件在ListAndWatch里推送设备状态,如果某个设备变成Unhealthy,kubelet 会把它从可分配资源里剔除,已经分配出去的 Pod 不会被驱逐,但新的 Pod 不会再调度到这个设备上。
我的健康检查逻辑是:每 10 秒尝试连接一次 rknn_server,连续失败 3 次就把所有设备标记为Unhealthy,恢复后重新标记为Healthy。这个阈值需要根据实际场景调——太敏感会导致网络抖动时误判,太迟钝会导致故障 Pod 一直占着资源。
但这里有个问题:如果 rknn_server 挂了,已经跑着的 Pod 怎么办?K8s 不会自动重启它们,因为从 K8s 视角看,Pod 本身没挂。我的做法是在业务容器里加一个 liveness probe,定期调用一个健康检查接口,这个接口内部会尝试做一次极轻量的 NPU 推理(比如跑一个 1x1 的卷积),失败就返回非 200,让 K8s 重启容器。这样虽然不能恢复 rknn_server,但至少能让业务感知到问题。
更彻底的做法是用一个 sidecar 或者 init container 来管理 rknn_server 的生命周期,但这超出了 device plugin 的职责范围,属于节点初始化的工作。我是在节点上用一个 systemd unit 来保证 rknn_server 挂了自动重启,配合 device plugin 的健康检查,基本能做到自愈。
4. 实操过程:从零到 Pod 跑起来
4.1 节点侧准备:rknn_server 和运行时
假设你已经有一块烧好 Ubuntu 20.04 的 RK3588 板子,并且 K8s 或者 K3s 已经跑起来了。第一步是确保 NPU 驱动和运行时正常。
先检查内核模块:
lsmod | grep rknpu如果没有输出,说明 NPU 驱动没加载。RK3588 的 NPU 驱动通常是编译进内核的,但有些固件版本需要手动modprobe rknpu。加载之后,检查/sys/kernel/debug/rknpu目录是否存在,里面会有version、load等信息。
然后安装 RKNN 运行时。瑞芯微的librknnrt.so和rknn_server通常包含在rknn-toolkit2的 runtime 包里。我用的版本是 1.5.2,从官方仓库下载rknpu2的 runtime 包,解压后把librknnrt.so放到/usr/lib/,把rknn_server放到/usr/bin/。
启动 rknn_server:
rknn_server &或者写成 systemd unit:
[Unit] Description=RKNN Server After=network.target [Service] ExecStart=/usr/bin/rknn_server Restart=always RestartSec=5 [Install] WantedBy=multi-user.target验证 server 是否正常:
ss -tlnp | grep 9999应该能看到 rknn_server 监听在 9999 端口。
注意:不同固件版本的 rknn_server 默认端口可能不同,有的用 9999,有的用 5000 或者别的。用
ss命令确认实际端口,然后在 device plugin 里配置对应的环境变量。
4.2 编译和部署 Device Plugin
Device Plugin 的代码我放在一个 Go module 里,核心依赖是k8s.io/kubelet和google.golang.org/grpc。编译成静态二进制:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o npu-device-plugin .然后打成 Docker 镜像。基础镜像用arm64v8/alpine或者arm64v8/debian-slim都行,我用的 alpine,镜像大小不到 20MB。
FROM arm64v8/alpine:3.18 COPY npu-device-plugin /usr/bin/ ENTRYPOINT ["/usr/bin/npu-device-plugin"]DaemonSet 的 YAML 关键部分:
apiVersion: apps/v1 kind: DaemonSet metadata: name: npu-device-plugin namespace: kube-system spec: selector: matchLabels: name: npu-device-plugin template: metadata: labels: name: npu-device-plugin spec: hostNetwork: true tolerations: - key: node-role.kubernetes.io/control-plane operator: Exists effect: NoSchedule containers: - name: npu-device-plugin image: your-registry/npu-device-plugin:latest env: - name: NPU_VIRTUAL_COUNT value: "3" - name: RKNN_SERVER_PORT value: "9999" volumeMounts: - name: device-plugin mountPath: /var/lib/kubelet/device-plugins - name: rknn-socket mountPath: /var/run/rknn volumes: - name: device-plugin hostPath: path: /var/lib/kubelet/device-plugins - name: rknn-socket hostPath: path: /var/run/rknn type: DirectoryOrCreate几个关键点:hostNetwork: true让插件能连宿主机的 rknn_server;/var/lib/kubelet/device-plugins是 kubelet 和插件通信的 socket 目录,必须挂载;/var/run/rknn是我自己创建的目录,用来放可能的 Unix Socket,DirectoryOrCreate保证不存在时自动创建。
部署:
kubectl apply -f npu-device-plugin.yaml然后检查:
kubectl get pods -n kube-system -l name=npu-device-plugin kubectl describe node <your-node> | grep rockchip如果一切正常,你应该能看到rockchip.com/npu: 3。
4.3 业务 Pod 怎么申请 NPU
业务 Pod 的 YAML 里这样写:
apiVersion: v1 kind: Pod metadata: name: yolov8-inference spec: containers: - name: inference image: your-registry/yolov8-rknn:latest resources: limits: rockchip.com/npu: 1 env: - name: RKNN_SERVER_IP value: "127.0.0.1" - name: RKNN_SERVER_PORT value: "9999"注意resources.limits里写rockchip.com/npu: 1,不要写requests,因为 Extended Resource 的 requests 和 limits 必须相等,写一个就行。
容器里的业务代码用 RKNN Python SDK 或者 C SDK 连接 rknn_server。Python 示例:
from rknnlite.api import RKNNLite rknn = RKNNLite() rknn.load_rknn('yolov8n.rknn') rknn.init_runtime(target='rk3588', rknn_server_ip='127.0.0.1', rknn_server_port=9999)这里有个细节:init_runtime的时候如果指定了rknn_server_ip,SDK 会走远程模式,把推理请求发给 server。如果不指定,SDK 会尝试直接加载librknnrt.so做本地推理,但容器里没有 NPU 设备节点,会失败。所以必须显式指定 server 地址。
4.4 虚拟化数量的实测调优
NPU_VIRTUAL_COUNT设多少合适?我做了个简单的压测。
测试方法:用 YOLOv8n 模型,输入 640x640,在宿主机上跑单进程推理,测出单次推理耗时约 25ms。然后启动 N 个容器,每个容器持续发推理请求,测总吞吐和单次延迟。
| 虚拟数量 | 单次延迟 | 总吞吐 | NPU 利用率 |
|---|---|---|---|
| 1 | 25ms | 40 FPS | 95% |
| 2 | 28ms | 71 FPS | 98% |
| 3 | 35ms | 85 FPS | 99% |
| 4 | 48ms | 83 FPS | 99% |
| 6 | 72ms | 83 FPS | 99% |
从数据看,虚拟成 3 个的时候总吞吐最高,延迟从 25ms 增加到 35ms,对于大多数边缘视觉场景(比如 30FPS 的视频流)来说,35ms 的延迟完全可以接受。虚拟成 4 个以上,吞吐不再增加,延迟却大幅上升,说明 NPU 已经饱和了。
所以我的默认值是 3。但这不是绝对的——如果你的模型更小(比如 MobileNet 级别的分类),单次推理只要 5ms,那虚拟成 6 个甚至 8 个都没问题。关键指标是单次推理耗时和你的业务能容忍的最大延迟。公式大概是:虚拟数量 = 业务可容忍延迟 / 单次推理耗时,然后取整。
实操心得:虚拟数量不是越多越好。我试过设成 10,结果 rknn_server 的任务队列积压严重,所有请求的延迟都飙到 200ms 以上,反而不可用。建议从 2 开始,逐步增加,观察延迟曲线,找到拐点。
5. 监控与可观测性:让 NPU 不再是黑盒
5.1 为什么需要自己写 Exporter
K8s 原生的 metrics 只覆盖 CPU、内存、网络、磁盘,NPU 的利用率、温度、内存占用这些指标一概没有。Prometheus 的 node_exporter 也不认识 RK3588 的 NPU。所以必须自己写一个 exporter,把 NPU 的状态暴露成 Prometheus 格式。
RK3588 的 NPU 状态信息在哪里?主要有两个来源:
一是/sys/kernel/debug/rknpu/load,里面有一个百分比数字,表示 NPU 的当前负载。这个文件需要 root 权限才能读,所以 exporter 要以 privileged 模式跑,或者用CAP_SYS_ADMIN。
二是 rknn_server 的日志或者内部状态。但 rknn_server 没有提供查询接口,所以拿不到队列长度、任务数这些信息。我试过用strace去 hook 它的系统调用,太 hack 了,不推荐。
温度信息从/sys/class/thermal/thermal_zone*/temp读,找到对应 NPU 的那个 zone。不同板子的 zone 编号不一样,需要遍历确认。
5.2 Exporter 的实现和部署
Exporter 用 Python 写最省事,因为读文件、算指标、暴露 HTTP 接口都很简单。核心逻辑:
import prometheus_client from prometheus_client import Gauge import time npu_load = Gauge('rk3588_npu_load_percent', 'NPU load percentage') npu_temp = Gauge('rk3588_npu_temperature_celsius', 'NPU temperature') def collect(): while True: try: with open('/sys/kernel/debug/rknpu/load', 'r') as f: load = int(f.read().strip().rstrip('%')) npu_load.set(load) except Exception as e: npu_load.set(-1) try: with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f: temp = int(f.read().strip()) / 1000.0 npu_temp.set(temp) except Exception: npu_temp.set(-1) time.sleep(5) if __name__ == '__main__': import threading threading.Thread(target=collect, daemon=True).start() prometheus_client.start_http_server(9101) while True: time.sleep(3600)部署成 DaemonSet,hostNetwork: true,securityContext.privileged: true,挂载/sys/kernel/debug和/sys/class/thermal。
Prometheus 的 scrape config 加一个 job:
- job_name: 'rk3588-npu' static_configs: - targets: ['<node-ip>:9101']Grafana 面板里把rk3588_npu_load_percent和rk3588_npu_temperature_celsius画出来,再配合 K8s 的 Pod 指标,就能看到“哪个 Pod 在占 NPU”、“NPU 温度是否过高”这些信息。
5.3 告警规则的设计
光有监控不够,还得有告警。我设了三条规则:
第一条:NPU 持续高负载。avg_over_time(rk3588_npu_load_percent[5m]) > 90,持续 10 分钟。这说明 NPU 可能成为瓶颈,需要考虑扩容或者优化模型。
第二条:NPU 温度过高。rk3588_npu_temperature_celsius > 85,持续 2 分钟。RK3588 的 NPU 在 85 度以上会降频,性能直接腰斩。这条告警要提前于降频触发,给自己留出处理时间。
第三条:NPU 不可用。rk3588_npu_load_percent == -1,持续 1 分钟。这说明 exporter 读不到 NPU 状态,可能是 rknn_server 挂了或者驱动异常。
告警接到企微或者钉钉,用 webhook 发消息。这部分用 Alertmanager 配置就行,不展开。
注意:
/sys/kernel/debug/rknpu/load这个文件在有些固件版本里不存在,或者路径不同。如果读不到,可以退而求其次,用 rknn_server 的进程 CPU 占用率来间接估算 NPU 负载。虽然不准,但总比没有强。
6. 踩坑记录与常见问题排查
6.1 Device Plugin 注册失败
现象:DaemonSet 跑起来了,但kubectl describe node看不到rockchip.com/npu。
排查思路:先看 device plugin 的日志。如果日志里有failed to register device plugin或者connection refused,说明插件连不上 kubelet 的 socket。检查/var/lib/kubelet/device-plugins/kubelet.sock是否存在,以及插件容器里这个路径是否挂载正确。K3s 的路径可能不一样,是/var/lib/rancher/k3s/agent/kubelet/device-plugins,需要根据实际发行版调整。
另一个常见原因是kubelet 的--feature-gates没开 DevicePlugins。K8s 1.10 之后这个特性默认开启,但有些老版本或者定制发行版可能关着。检查 kubelet 启动参数。
6.2 Pod 一直 Pending
现象:Pod 申请了rockchip.com/npu: 1,但一直 Pending,kubectl describe pod显示Insufficient rockchip.com/npu。
排查思路:先确认节点上确实上报了 NPU 资源。如果上报了但 Pod 还是 Pending,可能是资源已经被占满。用kubectl describe node看Allocated resources那一栏,确认 NPU 的已分配数量。如果确实满了,要么等,要么增加虚拟数量。
还有一种可能是节点有污点。比如 control-plane 节点默认有NoSchedule污点,如果 NPU 板子恰好是 control-plane,Pod 又没加 toleration,就会 Pending。
6.3 容器里连不上 rknn_server
现象:Pod 跑起来了,但业务代码报connect to rknn_server failed。
排查思路:首先确认容器里能不能 ping 通宿主机的 IP。如果用了hostNetwork: true,容器和宿主机共享网络,直接连127.0.0.1:9999就行。如果没用 hostNetwork,需要连宿主机的实际 IP,并且确保防火墙没挡。
其次确认 rknn_server 监听的地址。有些版本的 rknn_server 默认只监听127.0.0.1,不监听0.0.0.0,这种情况下即使网络通也连不上。解决办法是在宿主机上用socat做端口转发,或者改 rknn_server 的配置(如果有的话)。
6.4 推理结果异常或性能骤降
现象:Pod 能跑,但推理结果不对,或者延迟比预期高很多。
排查思路:先确认模型文件是否正确。RKNN 模型是跟固件版本绑定的,用 1.5.2 的工具链转的模型,在 1.4.0 的运行时上可能跑不了或者结果异常。检查librknnrt.so的版本和模型转换时的版本是否一致。
性能骤降通常是NPU 降频导致的。检查温度,如果超过 85 度,加散热片或者风扇。另一个原因是虚拟数量设多了,任务排队导致延迟增加。用前面说的压测方法重新调优。
6.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| Node 上看不到 NPU 资源 | 插件未注册 / kubelet 特性未开 | 检查插件日志和 kubelet 参数 |
| Pod Pending,提示资源不足 | NPU 已分配完 / 节点有污点 | 增加虚拟数量 / 加 toleration |
| 容器连不上 rknn_server | 网络不通 / server 只监听本地 | 用 hostNetwork / 端口转发 |
| 推理结果异常 | 模型与运行时版本不匹配 | 统一工具链和运行时版本 |
| 推理延迟高 | NPU 降频 / 虚拟数量过多 | 加强散热 / 减少虚拟数量 |
| 插件反复重启 | ListAndWatch 首次响应超时 | 异步做健康检查,快速返回设备列表 |
7. 还能怎么扩展
这套方案跑通之后,我后来又做了几个扩展,顺手提一下。
第一个是 NPU 配额管理。K8s 的 ResourceQuota 原生支持 Extended Resource,你可以在 namespace 级别限制rockchip.com/npu的总量。比如给“推理服务”这个 namespace 分配 6 个 NPU 单元,给“训练服务”分配 2 个,防止互相抢占。
第二个是优先级调度。用 PriorityClass 给关键业务高优先级,当 NPU 资源紧张时,低优先级的 Pod 会被驱逐,腾出资源给高优先级。这在边缘场景下很实用——比如安防视频分析比日志采集重要得多。
第三个是拓扑感知。如果一块板子上有多个 NPU 核心(RK3588 实际上有 3 个 NPU 核心),可以做成拓扑感知的分配,让同一个 Pod 的多个容器尽量分配到同一个核心上,减少跨核心通信开销。不过这需要改 device plugin 的GetPreferredAllocation实现,我还没做,留个坑。
第四个是和 KubeEdge 集成。边缘场景下,节点可能经常断网,KubeEdge 的 edgecore 可以缓存 device plugin 的上报信息,断网时也能做本地调度。这个我还在测试,等稳定了再写一篇。
最后说个实在的体会:RK3588 的 NPU 做 K8s 调度,技术难度其实不高,难的是对 NPU 运行时行为的理解。官方文档只告诉你“怎么调 API”,不告诉你“多进程并发时会发生什么”、“温度对性能的影响曲线长什么样”。这些东西只能自己一块板子一块板子地试出来。我踩过的坑里,有一半是因为想当然地拿 GPU 的经验往 NPU 上套。NPU 就是 NPU,它有自己的脾气,顺着它的设计来,事情就简单很多。