事情得从我把RK3588开发板接入K8s集群那天说起。节点注册、kubelet运行都很顺利kubectl get nodes全部 Ready可当我把自己打包好的 YOLOv8 推理镜像扔进集群时Pod 全部落在 CPU 密集型的 worker 节点上性能惨不忍睹。原因一句话就能讲清楚Kubernetes 不认识 RK3588 上的 NPU。K8s 官方只内置了 CPU 和内存的调度模型GPU 靠 NVIDIA 的 device plugin 补齐NPU 这块完全是空白。瑞芯微官方 SDK 给了完善的单机调用工具但没有提供任何 K8s 设备插件所以“让多台 RK3588 板子上的 NPU 被集群统一调度”这件事只能自己动手补上。这篇文章就聊聊我是怎么从零写了一个 RK3588 NPU device plugin让 K8s 能按资源量去调度 NPU并在其上顺利跑起 YOLOv8 推理服务的完整过程。如果你手上有 RK3588 板子想把它们组建成一个能跑 AI 推理的边缘集群或者正在研究怎么让异构算力NPU、DSP、加速卡接入 Kubernetes这篇文章应该能给你省下不少弯路。1. 先说结论官方缺席的那块板子撑起了整个集群的大脑1.1 RK3588 的 NPU 到底是什么RK3588 是瑞芯微的旗舰级 SoC芯片内部集成了一颗 NPU官方标称算力6 TOPSINT8由三个独立的 NPU 核心组成支持 INT4、INT8、INT16、FP16 混合精度计算。在边缘设备里这个算力意味着可以直接在板端跑 YOLOv8、OCR、人脸识别这类常见模型延迟可以压到几十毫秒而不需要把数据传回云端。这颗 NPU 的访问方式和 GPU 不太一样。它没有独立的显存而是和 CPU 共享 DDR 内存也不用挂载 PCIe 设备用户态通过librknnrt.so这个 runtime 库来调用硬件访问节点是/dev/rknpu。官方推荐的开发路径是在 PC 上把 PyTorch/ONNX 模型用 RKNN Toolkit 转换成.rknn格式然后在板端通过 RKNN Runtime 加载推理。也就是说NPU 的算力是实实在在的只是 K8s 不认识它。在我介入之前这个集群里的 NPU 处于一种“物理存在、逻辑不可见”的尴尬状态。1.2 官方为什么没做而社区只能自己动手Kubernetes 对异构算力的接入有一个标准机制叫 device plugin。NVIDIA 做过一套AMD 也做过一套但瑞芯微官方并没有提供面向 K8s 的 NPU 插件。原因也很好理解官方的主力市场是嵌入式设备绝大多数用户都是单机跑应用根本不需要 K8s而 K8s 场景集中在云原生领域和 RK3588 这种边缘 SoC 的受众重合度不高。所以想在一个 RK3588 组成的集群上做多租户、多服务共享 NPU就必须自己把这块补上。我的目标是让 kubelet 能上报每个节点有多少个 NPU 核心资源让 Pod 可以通过resources.limits声明需要几个 NPU 核心让调度器能根据资源余量把 Pod 调度到合适的节点让容器内真正能访问到/dev/rknpu并完成推理。这套目标实现起来并不复杂核心是理解 K8s 的设备插件协议再结合 RKNN Runtime 的特性做一个薄薄的适配层。2. 动手前必须打通的两块基石扩展资源与 Device Plugin 机制2.1 扩展资源让 K8s 先“看得见”NPUK8s 原生能调度的资源只有 CPU 和内存其他硬件资源要想被调度必须通过扩展资源Extended Resource机制注册进去。扩展资源的名字有严格格式必须是“域名/资源名”比如rk3588.npu.example.com/rknpu。我们习惯简写成rknpu但在 YAML 里声明时还是用完整格式。这里有一个硬性限制扩展资源只能声明为整数不能是 0.5 或 0.1。这是 K8s 调度器的限制也是后面很多问题的根源。RK3588 的 NPU 有三个核心所以我上报的资源总量就是 3。当一个节点被分配了 2 个 NPU 后剩余容量就是 1再有 Pod 要求 2 个 NPU调度器就不会往这个节点放了。资源上报的链路是device plugin 把设备数量告诉 kubelet → kubelet 更新节点状态 → 调度器读取节点状态做调度决策。扩展资源本身不能超卖kubelet 也不会对这个资源的实际使用做强制隔离这和我们熟悉的 CPU 完全不一样。2.2 Device Plugin把“看得见”变成“分得出去”Device plugin 是 K8s 提供的一套插件协议kubelet 通过 Unix socket 和插件通信整体工作流程可以拆成四步插件启动后连接/var/lib/kubelet/device-plugins/kubelet.sock向 kubelet 注册自己声明资源名和插件自己的 socket 路径kubelet 接受注册后通过另一个 Unix socket 和插件通信插件调用ListAndWatch接口持续上报设备列表和健康状态当有 Pod 请求该资源时kubelet 调用插件的Allocate接口插件返回运行时参数环境变量、设备挂载、卷等kubelet 在启动容器时注入这些参数。我用一个日常生活类比来帮助理解kubelet 是厨房前端调度器是点菜台device plugin 是后厨里报菜量的厨师。点菜台只负责“某道菜还剩几份”的计数具体哪口锅来炒这道菜是后厨在出菜那一刻决定的。对应到 K8s调度器只依据节点上的 NPU 资源数量做调度真正“分配哪个 NPU 核心给容器”的动作发生在节点本地的 Allocate 阶段。2.3 这里最容易误解的地方第一调度器并不感知具体设备。它看到的是“节点上有几个 NPU 核心”这种抽象计数器。真正把设备和容器绑定的是节点上的 kubelet 调用的 Allocate 接口。第二Allocate 不是独占锁。device plugin 返回的只是环境变量、设备节点挂载等配置并不会创建一个真正的互斥锁。如果两个容器同时请求 NPU 核心插件完全可以先把两个核心都分出去但实际运行时是否真的会冲突取决于应用层怎么使用 RKNN Runtime。这个问题我在后面的坑里详细展开。第三健康状态必须持续上报。如果 NPU 驱动挂了但插件还在上报 healthy那调度器永远不知道这个节点的 NPU 已经不可用这会导致 Pod 被调度到一台“没有实际算力”的节点上。所以ListAndWatch接口里健康检查必须自己实现。3. Device Plugin 开发实录从 Go 框架到 RKNN Runtime 打通3.1 环境准备与 NPU 可用性自检我的实验环境是一台 RK3588 开发板系统是 Ubuntu 22.04内核自带rknpu驱动用户态装好了librknnrt.soDocker 和 K8skubeadm 搭建单节点先验证也都已经跑通。动手写插件之前先确认三件事/dev/rknpu节点存在用户态 runtime 可用ldconfig -p | grep rknn能看到 librknnrt用官方自带的 demo 程序跑一次 rknn_init确认 NPU 真的能出结果。建议你也先做这一步自检否则插件写完了发现 NPU 本身不可用会浪费很多排查时间。如果 firmware 版本比较旧可以用cat /sys/kernel/debug/rknpu/load看看负载文件是否存在这个路径兼容性并不算好不同固件可能不一样。3.2 设备插件的骨架与三个核心接口设备插件官方推荐用 Go 写因为编译出来的静态二进制可以直接扔进容器不依赖宿主环境的库。核心代码结构如下package main import ( context net os time google.golang.org/grpc pluginapi k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1 ) type rkNPUPlugin struct { resourceName string socketFile string devices []string // 例: [npu-core-0, npu-core-1, npu-core-2] } // GetDevicePluginOptions 返回插件支持的扩展选项 func (p *rkNPUPlugin) GetDevicePluginOptions(context.Context, *pluginapi.Empty) (*pluginapi.DevicePluginOptions, error) { return pluginapi.DevicePluginOptions{}, nil } // ListAndWatch 持续上报健康设备列表 func (p *rkNPUPlugin) ListAndWatch(e *pluginapi.Empty, s pluginapi.DevicePlugin_ListAndWatchServer) error { // 首次上报全部健康设备 s.Send(pluginapi.ListAndWatchResponse{ Devices: p.healthyDevices(), }) // 循环检查健康状态异常时重新上报 for { time.Sleep(10 * time.Second) s.Send(pluginapi.ListAndWatchResponse{ Devices: p.healthyDevices(), }) } }ListAndWatch是设备插件的生命线。每次调用Send都会同步更新 kubelet 节点状态里的资源余量。设备列表里每个设备项有一个Health字段可选值是Healthy和Unhealthy。只要有一个Unhealthy对应节点的可分配资源就会减一。注册部分需要在main函数里完成func main() { os.MkdirAll(pluginapi.DevicePluginPath, 0750) sockPath : pluginapi.DevicePluginPath rkNPU.sock os.Remove(sockPath) p : rkNPUPlugin{ resourceName: rk3588.npu.example.com/rknpu, socketFile: sockPath, devices: []string{npu-core-0, npu-core-1, npu-core-2}, } // 监听插件的 Unix socket lis, _ : net.Listen(unix, sockPath) srv : grpc.NewServer() pluginapi.RegisterDevicePluginServer(srv, p) go srv.Serve(lis) // 向 kubelet 注册 conn, _ : grpc.Dial(pluginapi.KubeletSocket, grpc.WithInsecure(), grpc.WithBlock()) client : pluginapi.NewRegistrationClient(conn) client.Register(context.Background(), pluginapi.RegisterRequest{ Version: pluginapi.Version, Endpoint: rkNPU.sock, ResourceName: p.resourceName, }) select {} }这里有几个细节值得注意。socket 目录是/var/lib/kubelet/device-plugins/kubelet 只认这个目录下的 socket 文件。插件的 socket 名字可以随意但注册消息里的 Endpoint 必须和监听的文件名一致。另外ResourceName一定要带域名否则 kubelet 会直接拒绝注册。3.3 Allocate 不是真正的硬件分配而是资源记账Allocate接口是真正决定“把什么交给容器”的入口。kubelet 在启动容器时会调用它传入请求的设备 ID 列表插件需要返回环境变量、设备挂载、卷挂载等运行时信息。func (p *rkNPUPlugin) Allocate(ctx context.Context, reqs *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) { var resp pluginapi.AllocateResponse for _, req : range reqs.ContainerRequests { // req.DevicesIDs 是被分配的设备 ID 列表 // 在这里给每个容器分配 NPU 核心编号 for i, id : range req.DevicesIDs { resp.ContainerResponses append(resp.ContainerResponses, pluginapi.ContainerAllocateResponse{ Envs: map[string]string{ RKNN_NPU_CORE_INDEX: idxOf(id), }, Devices: []*pluginapi.DeviceSpec{ { HostPath: /dev/rknpu, ContainerPath: /dev/rknpu, Permissions: rw, }, }, }) } } return resp, nil }我这里把“分配 NPU 核心”抽象成了“分配一个核心编号”通过环境变量RKNN_NPU_CORE_INDEX传给容器。在 Pod 内的推理服务启动时会读取这个环境变量用它去初始化 RKNN Runtime 指定核心。这一步很关键——RNN Runtime 本身支持通过RKNN_CORE_0/1/2指定核心运行这也是我们能做精细分配的基础。需要注意的是返回的环境变量并不是硬隔离。容器代码如果不读这个变量照样可以自己创建 runtime 实例抢夺别的核心。所以设备插件只是做资源账本应用层必须配合。3.4 打包成 DaemonSet让每个节点自己上报插件代码编译成二进制后最合理的部署方式是做成 DaemonSet让每个带 NPU 的节点都自动运行一个插件实例自己向本节点的 kubelet 注册。apiVersion: apps/v1 kind: DaemonSet metadata: name: rk3588-npu-plugin namespace: kube-system spec: selector: matchLabels: app: rk3588-npu-plugin template: metadata: labels: app: rk3588-npu-plugin spec: tolerations: - operator: Exists containers: - name: npu-plugin image: registry.example.com/rk3588-npu-plugin:v1 securityContext: privileged: true volumeMounts: - name: kubelet-socket mountPath: /var/lib/kubelet/device-plugins - name: dev-rknpu mountPath: /dev/rknpu volumes: - name: kubelet-socket hostPath: path: /var/lib/kubelet/device-plugins - name: dev-rknpu hostPath: path: /dev/rknpu做 DaemonSet 有两个额外好处新节点加入集群后会自动部署插件插件崩溃后会自动重启不会导致节点长期丢失 NPU 资源。安装完成后通过kubectl logs看到注册成功的日志在节点上用curl localhost:10255/pods或kubectl describe node就可以看到rk3588.npu.example.com/rknpu: 3的容量。4. 让 YOLOv8 跑在 NPU 上调度效果与性能实测4.1 构建推理镜像与声明 NPU 资源设备插件装好了接下来就是让一个真实的推理服务跑起来。我在 PC 上用 rknn-toolkit2 把 YOLOv8s 模型转成了 RKNN 格式然后把推理服务打包成镜像。容器内的推理程序读取环境变量RKNN_NPU_CORE_INDEX决定绑定哪个核心import os from rknnlite.api import RKNNLite core_index int(os.getenv(RKNN_NPU_CORE_INDEX, -1)) mask { 0: RKNNLite.NPU_CORE_0, 1: RKNNLite.NPU_CORE_1, 2: RKNNLite.NPU_CORE_2, }.get(core_index, RKNNLite.NPU_CORE_AUTO) rknn RKNNLite() rknn.load_rknn(/models/yolov8s.rknn) rknn.init_runtime(core_maskmask) # 之后正常的推理流程Pod 的资源声明很直白apiVersion: v1 kind: Pod metadata: name: yolov8-npu spec: containers: - name: inference image: registry.example.com/yolov8-rknn:v1 resources: limits: rk3588.npu.example.com/rknpu: 1注意资源名字必须和 device plugin 注册时完全一致并且声明为字符串1不是数字。4.2 验证调度资源耗尽后 Pod 真的会 Pending验证设备插件是否真的生效最直接的办法就是看调度行为。我先创建上面这个 Podkubectl get pod -o wide看到它被调度到了带 NPU 的节点kubectl exec进去确认环境变量RKNN_NPU_CORE_INDEX0再跑一下推理 demo输出正常。然后我连续创建另外两个同样请求 1 个 NPU 的 Pod这三个都正常调度。第四个 Pod 创建后一直处于 Pending 状态kubectl describe里的事件信息是0/1 nodes are available: 1 Insufficient rk3588.npu.example.com/rknpu.看到这条消息说明调度器已经正确识别扩展资源。到这里设备插件至少达到了“能被 K8s 调度”的目标。接下来关心的就是性能。4.3 实测数据NPU 与 CPU 的延迟对比我跑了同一份 YOLOv8s INT8 模型输入 640×640 的图片分别在 CPU 和 NPU 上做单帧推理结果如下运行方式单帧推理耗时备注RK3588 CPU4×A76420ms 左右官方 runtime 没有对 CPU 做深度优化RK3588 NPU 单核心55ms 左右满意但还能压RK3588 NPU 三核心并行38ms 左右多 batch 数据才能完全发挥这只是我手上这颗芯片和这个模型的实测数字不同 rknn runtime 版本会有差异但趋势很明显NPU 至少比 CPU 快 7 倍以上。多路并发时在一颗核心上跑一路推理大约消耗 25ms 左右单节点 3 个核心可以支撑接近 120 QPS 的吞吐这对于边缘场景已经相当够用。性能验证通过后设备插件的开发算是正式完成。5. 上线后真正折磨人的几个坑5.1 三个 NPU 核心不是三个独立设备这是我在实际运行中踩得最深的坑。RK3588 的 NPU 虽然物理上有三颗核心但它们共享同一套指令发射和内存带宽。如果两个 Pod 都使用默认的NPU_CORE_AUTO模式runtime 会自动选一个空闲核心表面看很智能实际上在多进程抢占场景下AUTO 模式经常会连续几个请求都打在同一颗核心上另外两颗空闲导致吞吐量上不去。后来我在容器里强制指定RKNN_NPU_CORE_INDEX让设备插件分配的核心编号和 runtime 的 core mask 绑定在一起情况才好很多。设备插件分配的核心编号必须在应用层真正落地否则就是纸面分配。5.2 整数资源与“半个核心”需求怎么处理扩展资源只能是整数这让我很头疼。有段时间我想把一个核心分给两个轻量服务每个服务只需要一半算力结果发现根本没有办法通过资源声明表达。硬写 1 又不符合实际用量写 0 又没法被调度。后来我换了个思路把资源粒度切得更细上报 6 个半核心资源每个容器请求 2 个半核心。但这样做的前提是应用层确实能限制自己的吞吐否则就是自欺欺人。5.3 conntrack / runtime 版本和内核驱动版本要一起锁死设备插件本身不依赖 RKNN runtime但 Pod 内的推理服务依赖。一开始我图省事在镜像里用了最新的 librknnrt.so结果宿主内核驱动较旧rknn_init直接报版本不兼容错误。排查了一整天。经验是宿主机的 rknpu 内核模块、用户态 librknnrt、rknn-toolkit2 的模型转换版本这三者必须记录在案并配套升级。别只升级其中某一个否则稍后再排查一次版本问题。5.4 插件健康上报失灵时集群会被“骗”有一段时间某个节点的 NPU 驱动异常/dev/rknpu打开失败但我的插件仍然按固定周期上报 3 个健康设备。结果是调度器照常把 Pod 往这个节点调度容器创建成功唯独推理全部失败。这暴露出一个设计缺陷设备插件的健康检查不能只依赖系统调用是否报错必须主动去探测/dev/rknpu。我后来在插件里加了一个探测逻辑每 10 秒尝试打开一次/dev/rknpu失败则立刻把设备标记为 Unhealthy 并上报。这样至少调度器能避开坏节点。6. 进一步铺开从单机走到集群的 NPU 监控与调度策略6.1 用 PrometheusGrafana 盯住 NPU 实时负载资源调度解决之后监控就是下一个刚需。RK3588 的 NPU 负载可以通过读取/sys/kernel/debug/rknpu/load获得这个节点会输出一个表示当前算力负载的数字。我写了一个非常轻量的 exporterfrom prometheus_client import start_http_server, Gauge import time load_gauge Gauge(rk3588_npu_load_percent, NPU load percentage) def read_npu_load(): try: with open(/sys/kernel/debug/rknpu/load, r) as f: return int(f.read().strip()) except FileNotFoundError: return -1 if __name__ __main__: start_http_server(9200) while True: load_gauge.set(read_npu_load()) time.sleep(5)把这个 exporter 做成 DaemonSet 挂到每个节点Prometheus 配置一个 job 采集 9200 端口再配合 Grafana 面板就能实时看到每个节点的 NPU 利用率、温度、内存占用。监控的价值在后来的资源扩容决策上体现得很明显——我靠它发现有一台节点 NPU 长期超过 90%于是决定把一部分模型迁移到另一台资源充足的节点。6.2 节点打标、污点与专属推理池默认的 kube-scheduler 只会按资源数量调度这在同构节点集群里没问题但在异构集群里会很粗糙。比如某些节点内存大适合运行模型加载某些节点 NPU 算力高适合推理还有一些节点只跑 CPU 应用。我一般的做法是给 NPU 节点打上nputrue标签同时添加污点nputrue:NoSchedule然后在推理服务的 Pod 上配节点亲和性和容忍度spec: tolerations: - key: npu operator: Equal value: true effect: NoSchedule affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: npu operator: In values: - true这样普通业务 Pod 不会被调度到 NPU 节点推理 Pod 则被锁定在 NPU 池里。如果节点数量变多还可以通过配置多个资源池把不同业务的推理隔离到不同节点组。6.3 这个方案能复制到别的边缘设备吗完全可以。设备插件协议是标准化的只要你的硬件有用户态 runtime 和一个设备节点就可以参照这套逻辑把它接入 K8s。核心工作其实是两件事把设备数量用扩展资源的形式上报上去再在 Allocate 阶段把设备信息和环境变量透传给容器。至于你用的是 RK3588、RK3568还是其他厂家的 NPU 盒子差别只是 runtime 的调用方式。我个人在实际操作中的体会是难的不是写 device plugin 本身而是怎么定义“一个 NPU 资源”的语义。CPU 有 cgroup 做隔离内存有 limit 做限制但 NPU 这类加速器在容器里往往没有真正的隔离机制设备插件上报的数字更像是一种“软约束”。所以在设计阶段就要想清楚你设备的并行能力上限、是否存在硬件时间片隔离、怎么在应用层配合资源编号做分发——这些比 K8s 插件代码本身更重要。另外一个让整个机制更顺滑的小技巧把设备插件和推理服务的镜像版本捆绑发布记录每次升级对应的 runtime 版本出问题可以一键回退。集群规模越大这种版本管理的收益越明显。