资讯动态

K8s Device Plugin 实战:RK3588 NPU 异构算力调度

发布时间:2026/9/28 18:54:10 来源:尧图企业网站定制
1. 缘起一块被 Kubernetes 遗忘的算力孤岛手里攥着几块 RK3588 开发板6 TOPS 的 NPU 算力明晃晃地摆在那里可每次想把它塞进 K8s 集群统一调度就感觉像拿着一把没开刃的刀——看着锋利真要用的时候使不上劲。官方文档翻了个遍RKNN Toolkit 的部署教程倒是不少但清一色都是单机裸跑rknn_server往后台一挂Python 脚本直接调跟 K8s 的 Pod 生命周期、资源模型、调度器完全不在一个频道上。这个问题的本质其实很清晰K8s 默认只认识 CPU 和内存这两种可压缩/不可压缩资源GPU 靠的是 NVIDIA 自己维护的 Device Plugin 生态而 NPU 这类异构加速器尤其是 RK3588 上这颗瑞芯微自研的 NPU官方压根没打算往 K8s 生态里凑。你搜遍全网能找到的要么是rknn_server的 Docker 封装要么是手动指定节点跑推理的土办法真正把 NPU 抽象成 K8s 可调度资源、让 Deployment 像申请nvidia.com/gpu一样申请rockchip.com/npu的方案几乎是一片空白。我花了大概三周时间踩了无数坑从 Device Plugin 的 gRPC 接口一路啃到 kubelet 的设备管理逻辑中间还因为Allocate响应里忘了填Envs导致容器里死活找不到 NPU 设备排查了整整一个下午。最终跑通的方案不算复杂但每一步都有它存在的理由。下面我把整个思路、实现细节、实操步骤和踩坑记录完整地摊开来讲适合手里有 RK3588 板子、想把 NPU 算力纳入 K8s 统一调度的运维和嵌入式开发者参考。如果你只是想在单机上跑个 YOLOv8 玩玩那这篇文章可能对你来说有点重但如果你面对的是多板卡、多任务的推理集群那接下来的内容应该能帮你省下不少试错时间。2. 整体设计为什么非得走 Device Plugin 这条路2.1 K8s 扩展异构资源的三种姿势对比在动手之前我先梳理了一下 K8s 集群里接入异构算力的几条常见路径免得一头扎进去才发现选错了方向。方案核心机制优点缺点适用场景Extended Resource 自定义调度器通过 Extended Resource 声明资源自己写调度逻辑灵活不依赖 kubelet需要维护调度器Pod 生命周期管理复杂资源类型极多、调度策略高度定制Device Pluginkubelet 通过 gRPC 与插件通信自动上报设备原生集成kubelet 自动管理设备分配需要实现 gRPC 服务调试门槛较高GPU、NPU、FPGA 等标准异构设备自定义 Operator 旁路调度Operator 监听 Pod手动绑定节点并注入设备实现简单不碰 kubelet绕过调度器资源视图不统一容易冲突临时方案、PoC 验证我最终选了Device Plugin理由很直接它是 K8s 官方为异构设备设计的标准接入方式kubelet 会自动完成设备发现、健康检查和分配不需要我再去动调度器。NVIDIA 的 GPU Operator 本质上也是这套东西只不过人家包装得更厚实。RK3588 的 NPU 虽然官方没做但 Device Plugin 的接口是公开的自己实现一个完全可行。2.2 RK3588 NPU 的设备模型与资源抽象RK3588 的 NPU 在 Linux 系统里表现为/dev/rknpu这个字符设备用户态通过librknnrt.so和内核驱动通信。一块 RK3588 通常只有一个 NPU 核心但支持多进程分时复用。这里有个关键决策点资源粒度怎么定如果按“整卡”分配一个 Pod 占了 NPU其他 Pod 就得排队利用率太低。如果按“算力百分比”分配Device Plugin 本身不支持这种细粒度切分得靠上层框架自己做。我最终选择了“整卡分配 多容器共享”的折中方案Device Plugin 上报的资源数量为 1但允许多个 Pod 同时申请实际运行时通过rknn_server的并发能力做分时复用。这样既保证了 K8s 资源模型的简洁性又不会把 NPU 锁死在一个 Pod 里。注意这种共享模式适合推理任务因为推理通常是短时、间歇性的。如果是长时间训练任务建议还是独占分配避免相互干扰。2.3 整体架构拆解整个方案由三个核心组件构成Device Plugin 服务以 DaemonSet 形式部署在每个 RK3588 节点上负责向 kubelet 注册rockchip.com/npu资源并实现ListAndWatch、Allocate、GetDevicePluginOptions等 gRPC 接口。设备发现与健康检查模块定期扫描/dev/rknpu是否存在、librknnrt.so是否可加载并通过ListAndWatch流实时上报设备状态。容器运行时注入逻辑在Allocate响应中返回设备路径、挂载点和必要的环境变量确保容器内rknn_server能正常访问 NPU。这套架构的好处是对现有集群零侵入不需要改 kubelet 配置不需要动调度器只需要在 RK3588 节点上打一个 DaemonSet集群里就能看到rockchip.com/npu这个资源。应用侧只需要在 YAML 里写一行rockchip.com/npu: 1剩下的交给 K8s。3. 核心细节Device Plugin 的 gRPC 接口到底怎么实现3.1 设备发现从 /dev/rknpu 到资源上报Device Plugin 启动后第一件事是向 kubelet 注册自己。注册方式有两种直接连 kubelet 的 Device Plugin Socket/var/lib/kubelet/device-plugins/kubelet.sock或者通过--device-plugin-path指定。我选的是标准路径因为 DaemonSet 里挂载宿主机目录更方便。注册时需要提供三个信息资源名称我定为rockchip.com/npu遵循vendor-domain/resource-type的命名规范。API 版本v1beta1目前最稳定的版本。Endpoint插件自己的 Unix Socket 路径比如npu.sock。注册成功后kubelet 会主动连接这个 Socket调用ListAndWatch方法。这个方法返回一个流插件通过这个流持续推送设备列表。每个设备用Device结构体描述包含ID、Health和Topology信息。我的实现里设备 ID 直接用了npu-0这种简单命名健康状态通过定期检查/dev/rknpu的可读性来判断。// 简化的 ListAndWatch 实现逻辑 func (p *NPUDevicePlugin) ListAndWatch(empty *pluginapi.Empty, stream pluginapi.DevicePlugin_ListAndWatchServer) error { devices : []*pluginapi.Device{ {ID: npu-0, Health: pluginapi.Healthy}, } // 首次全量上报 stream.Send(pluginapi.ListAndWatchResponse{Devices: devices}) // 定期健康检查 ticker : time.NewTicker(30 * time.Second) for range ticker.C { health : checkNPUHealth() // 检查 /dev/rknpu 是否可访问 devices[0].Health health stream.Send(pluginapi.ListAndWatchResponse{Devices: devices}) } return nil }这里有个容易忽略的点健康检查不能只判断文件是否存在。我一开始只做了os.Stat结果 NPU 驱动崩溃后设备节点还在kubelet 以为设备健康继续往上面调度 Pod导致容器启动后推理直接失败。后来改成实际打开设备并尝试加载librknnrt.so才算靠谱。3.2 Allocate 响应容器里怎么才能看到 NPUAllocate是 Device Plugin 最核心的方法。当 kubelet 决定把某个 NPU 分配给一个 Pod 时它会调用Allocate传入设备 ID 列表插件需要返回一组ContainerAllocateResponse告诉容器运行时怎么把设备挂进去。我的响应里包含三部分Devices把/dev/rknpu映射到容器内的/dev/rknpu权限设为rw。Mounts把宿主机的/usr/lib/librknnrt.so挂载到容器内同一路径因为 RKNN 运行时依赖这个库。Envs设置RKNN_NPU_DEVICE/dev/rknpu方便应用代码读取。resp : pluginapi.ContainerAllocateResponse{ Devices: []*pluginapi.DeviceSpec{ { ContainerPath: /dev/rknpu, HostPath: /dev/rknpu, Permissions: rw, }, }, Mounts: []*pluginapi.Mount{ { ContainerPath: /usr/lib/librknnrt.so, HostPath: /usr/lib/librknnrt.so, ReadOnly: true, }, }, Envs: map[string]string{ RKNN_NPU_DEVICE: /dev/rknpu, }, }提示librknnrt.so的路径在不同固件版本里可能不一样有的在/usr/lib有的在/usr/local/lib。建议在插件启动时动态探测或者通过环境变量传入。3.3 设备共享与并发控制前面提到我选择了“整卡分配 多容器共享”的模式。这意味着Allocate可以被多次调用每次返回相同的设备路径。但这里有个隐患多个容器同时写/dev/rknpu会不会冲突实测下来RK3588 的 NPU 驱动本身支持多进程打开rknn_server也做了请求队列。但如果你直接在容器里跑rknn_init而不走rknn_server并发时会出现RKNN_ERR_DEVICE_UNAVAILABLE。所以我的建议是在容器里统一通过rknn_server做推理应用代码只负责发请求。这样 Device Plugin 只需要保证设备节点可见并发控制交给rknn_server自己处理。如果你确实需要独占模式可以在 Device Plugin 里维护一个分配表Allocate时检查设备是否已被占用已占用则返回错误。但这样会降低利用率我个人的场景里没这么做。4. 实操落地从零搭建可调度的 NPU 节点4.1 环境准备与依赖确认在开始部署之前先确认你的 RK3588 节点满足以下条件系统Ubuntu 20.04 或 22.04内核版本 5.10 以上。我用的是讯为 RK3588 平台烧写的 Ubuntu 20.04.5 根文件系统NPU 驱动已经预装。NPU 驱动/dev/rknpu存在dmesg | grep rknpu能看到驱动加载日志。RKNN 运行时librknnrt.so已安装rknn_server可执行文件在/usr/bin/下。K8s 节点已经加入集群kubelet 正常运行容器运行时推荐 containerd 或 Docker。检查命令如下# 确认 NPU 设备节点 ls -l /dev/rknpu # 确认驱动版本 cat /sys/kernel/debug/rknpu/version 2/dev/null || dmesg | grep -i rknpu # 确认 RKNN 运行时 ldconfig -p | grep rknnrt which rknn_server如果librknnrt.so找不到需要从 RKNN Toolkit2 的运行时包里拷贝到/usr/lib/然后执行ldconfig。4.2 编写 Device Plugin 的 Docker 镜像Device Plugin 本身是一个二进制程序我把它打包成轻量级镜像。Dockerfile 如下FROM golang:1.21-alpine AS builder WORKDIR /app COPY . . RUN CGO_ENABLED0 go build -o npu-device-plugin . FROM alpine:3.19 RUN apk add --no-cache libc6-compat COPY --frombuilder /app/npu-device-plugin /usr/bin/ ENTRYPOINT [/usr/bin/npu-device-plugin]编译出来的二进制大概 15MB镜像总共不到 30MB对节点资源占用可以忽略。注意如果你的集群是 ARM64 架构构建镜像时记得指定--platform linux/arm64否则跑不起来。4.3 部署 DaemonSet 与验证资源上报DaemonSet 的 YAML 需要挂载几个关键路径apiVersion: apps/v1 kind: DaemonSet metadata: name: npu-device-plugin namespace: kube-system spec: selector: matchLabels: app: npu-device-plugin template: metadata: labels: app: npu-device-plugin spec: nodeSelector: kubernetes.io/arch: arm64 rockchip.com/npu: true containers: - name: npu-device-plugin image: your-registry/npu-device-plugin:v1 securityContext: privileged: true volumeMounts: - name: device-plugin mountPath: /var/lib/kubelet/device-plugins - name: dev mountPath: /dev - name: sys mountPath: /sys readOnly: true volumes: - name: device-plugin hostPath: path: /var/lib/kubelet/device-plugins - name: dev hostPath: path: /dev - name: sys hostPath: path: /sys部署后给 RK3588 节点打上标签kubectl label node node-name rockchip.com/nputrue然后应用 DaemonSetkubectl apply -f npu-device-plugin.yaml等 Pod 跑起来后查看节点资源kubectl describe node node-name | grep -A 5 Capacity你应该能看到rockchip.com/npu: 1出现在 Capacity 和 Allocatable 里。如果没出现先看 DaemonSet 的日志大概率是 Socket 路径不对或者权限不够。4.4 跑一个 YOLOv8 推理 Pod 验证调度验证资源可调度最直接的方式就是跑一个真实的推理任务。我准备了一个基于 YOLOv8 的测试镜像里面集成了rknn_server和 Python 推理脚本。Pod YAML 如下apiVersion: v1 kind: Pod metadata: name: yolov8-npu-test spec: containers: - name: yolov8 image: your-registry/yolov8-rknn:latest resources: limits: rockchip.com/npu: 1 command: [python3, /app/infer.py] env: - name: RKNN_NPU_DEVICE value: /dev/rknpu创建后用kubectl describe pod查看事件应该能看到调度器正常分配了节点并且容器启动时挂载了/dev/rknpu。进入容器执行ls /dev/rknpu确认设备存在然后跑推理脚本输出检测结果。如果 Pod 一直 Pending检查节点标签是否匹配、资源是否已耗尽。如果容器启动失败重点看Allocate返回的挂载路径在宿主机上是否存在。5. 踩坑实录那些官方文档不会告诉你的问题5.1 常见问题速查表现象可能原因排查方法解决方案节点看不到rockchip.com/npu资源Device Plugin 未注册成功查看 DaemonSet 日志检查 Socket 路径确认/var/lib/kubelet/device-plugins挂载正确权限为 755Pod 一直 Pending节点标签不匹配或资源不足kubectl describe pod查看事件给节点打标签确认资源数量容器内找不到/dev/rknpuAllocate 响应未正确返回设备检查 Device Plugin 日志中的 Allocate 调用确认 Devices 字段路径正确权限为 rw推理时报RKNN_ERR_DEVICE_UNAVAILABLE多容器并发冲突查看rknn_server日志统一通过rknn_server推理避免直接调用rknn_initNPU 健康状态异常但设备节点存在驱动崩溃后节点未清理dmesg查看内核日志重启驱动或节点Device Plugin 增加实际读写检测5.2 独家避坑技巧技巧一Socket 路径不要硬编码。kubelet 的 Device Plugin Socket 路径可以通过--device-plugin-path参数自定义不同发行版可能不一样。我的做法是在 DaemonSet 里通过环境变量传入插件启动时读取避免换了个环境就跑不起来。技巧二Allocate 响应里的 Mounts 要判空。我一开始没检查librknnrt.so是否存在直接返回挂载路径结果在某些固件版本上容器启动直接失败。后来改成先os.Stat存在才挂载不存在就只挂设备节点应用侧自己处理库依赖。技巧三健康检查频率别太高。我最初设了 5 秒一次结果 kubelet 日志被刷屏NPU 驱动也频繁被唤醒。改成 30 秒后资源占用和日志量都正常了。健康检查的目的是发现设备掉线不是实时监控没必要太激进。技巧四用 Prometheus Grafana 监控 NPU 资源。Device Plugin 本身不暴露指标但你可以通过rknn_server的日志或者自定义 exporter 采集 NPU 利用率和温度。我在节点上跑了一个小 exporter把/sys/kernel/debug/rknpu/load的数据推到 PrometheusGrafana 里能看到每个 Pod 的 NPU 使用情况排查性能问题很方便。技巧五多板卡场景下用节点亲和性做分组。如果你有多个 RK3588 节点可以通过节点标签把不同型号或不同算力等级的板子分组然后在 Pod 里用nodeAffinity指定调度到特定组。比如rockchip.com/npu-model: rk3588和rockchip.com/npu-model: rk3576分开避免调度到算力不匹配的节点。6. 扩展思考从单机到集群的 NPU 调度演进这套方案跑通之后我陆续在几个场景里做了扩展。一个是多任务队列管理通过 K8s 的 ResourceQuota 限制每个命名空间的 NPU 配额避免某个团队把 NPU 占满。另一个是推理服务自动扩缩容用 HPA 根据rknn_server的请求队列长度动态调整 Pod 数量虽然 NPU 本身不能像 CPU 那样细粒度切分但通过多副本共享同一个 NPU 设备也能实现一定程度的弹性。还有一个值得关注的方向是算子级别的调度。RK3588 的 NPU 对某些算子支持不好比如大核卷积或者非标准激活函数这时候需要回退到 CPU 或 GPU 执行。如果能在 K8s 层面感知到算子的硬件亲和性把不同算子调度到不同设备上理论上能进一步提升利用率。不过这需要更上层的框架支持目前我还没看到成熟的方案。最后分享一个实际使用中的小发现RK3588 的 NPU 在连续高负载运行后会有明显的温度墙频率会降下来。如果你在 K8s 里跑的是长时间推理服务建议在 Pod 里加上温度监控超过阈值时主动降低并发或者迁移 Pod。我在节点上加了thermald配合自定义脚本效果还不错。这个细节官方文档里不会写但实际生产环境里很关键。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑