ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

K8s调度NPU实践:为RK3588自研Device Plugin实现边缘推理集群

K8s调度NPU实践:为RK3588自研Device Plugin实现边缘推理集群 1. 为什么官方没有做以及K8s调度NPU到底难在哪先说一个真实场景。我手里有几块RK3588开发板每块板上都带一个6 TOPS算力的NPU跑YOLOv8目标检测模型非常流畅。项目规模变大以后需要在多块板子上统一分发推理任务不能再靠人肉SSH上去手动启动进程了于是我想到了用Kubernetes来管理这些节点。但问题来了——K8s压根看不见这个NPU。1.1 先搞清楚NPU在板子上的真实面目RK3588的NPU不是一颗独立芯片它集成在SoC内部和CPU、GPU共享内存。在Linux系统下RKNPU驱动加载成功后会在设备节点里暴露一个/dev/rknpu。所有对NPU的访问本质上都是通过这个字符设备发起的IOCTL操作。这和GPU不太一样。GPU有独立的显存体系nvidia-smi能报出显存大小、核心利用率等一堆指标而RK3588的NPU是统一内存架构它没有独立的显存概念你能直接感知到的就只有一个/dev/rknpu设备节点一套RKNN Runtime的C/C或Python API内部若干硬件上下文context支持并发推理这就带来一个根本性矛盾K8s的资源模型是可量化的数值比如cpu: 2、memory: 4Gi调度器根据这些数字决定Pod放哪台节点。而NPU的算力、并发能力、内存占用都无法简单用一个整数表达清楚。1.2 调度模型冲突K8s只认可量化的资源K8s调度器的工作原理可以简化为三步过滤Filter、打分Score、绑定Bind。过滤阶段把不满足资源请求的节点剔除打分阶段根据资源充裕度给剩余节点排序。整个过程全部建立在节点上报的status.allocatable和status.capacity之上。节点信息来源于kubelet启动时主动向API Server注册的资源清单。默认情况下这个清单里只有CPU、内存、临时存储、PID数量等内置资源。GPU之所以能被调度是因为NVIDIA贡献了Device Plugin插件由插件向kubelet上报nvidia.com/gpu这类扩展资源。RK3588的情况是Rockchip官方提供了完整的Linux驱动包括内核模块和RKNN Runtime库但整套体系停留在单机使用层面。官网文档写得很清楚——下载RKNN Toolkit在板子上跑推理就完了。没有K8s适配层、没有CSI驱动、没有Metrics Exporter更别说Device Plugin了。1.3 官方没做到底缺了什么我梳理下来官方没做的事其实包含三块资源抽象层没有定义多少NPU资源算一个单位这个量化标准。算力说6 TOPS但每个模型实际消耗多少算力、能并发几路完全取决于模型大小和RKNN Runtime的实现。K8s插件生态没有Device Plugin、没有监控Exporter、没有Operator。这意味着K8s既无法知道这台节点的NPU是否存在也无法在Pod生命周期中注入访问NPU所需的环境。多节点调度方案没有做集群层面的统一调度逻辑。每块板子是独立的信息孤岛任务分发要么走外部消息队列要么就得靠人工。所以我的目标很明确自研一套轻量级的NPU Device Plugin把RK3588的NPU抽象成K8s的扩展资源让调度器能够感知它并分配给Pod。2. Device Plugin方案把NPU翻译成K8s认识的语言K8s从1.8版本开始提供了Device Plugin机制专门解决自定义硬件接入的问题。这套机制的本质是让kubelet通过Unix Socket和插件进程通信插件上报设备列表kubelet把设备数量注册到节点资源里等到Pod调度到节点上时插件再决定把哪个设备分配给哪个容器。2.1 自定义资源与Device Plugin的分工这里需要区分两个概念Extended Resource扩展资源和Device Plugin设备插件。扩展资源是API Server层面的概念解决的是节点上有没有这个资源、有多少的问题。定义方式很简单只要在Pod的resources.limits里写一个新的资源名比如rocknpu.device/npu: 1调度器就会拿着这个数字去过滤节点。但仅有扩展资源还不够。Pod被调度到节点之后还需要把/dev/rknpu设备挂载进容器设置必要的环境变量确保容器有权限访问设备节点这些设备如何交给容器用的具体操作就是Device Plugin的活。两者关系是扩展资源负责调度层的声明Device Plugin负责运行时的落地。2.2 整体架构NPU资源如何从硬件一路走到Pod里我实现的架构可以用一条链路描述RK3588 NPU硬件 - 内核驱动创建 /dev/rknpu - Device Plugin守护进程扫描设备节点 - 通过Unix Socket与kubelet通信Register、ListAndWatch、Allocate - kubelet将 rocknpu.device/npu 计数上报到API Server - 调度器根据资源量分配Pod到节点 - kubelet触发Allocate回调插件返回设备文件和环境变量 - 容器内RKNN Runtime通过 /dev/rknpu 访问NPU这条链路里最关键的两个接口是ListAndWatch和Allocate。前者的作用是让插件持续向kubelet报告设备健康状态后者则在Pod启动时被调用插件需要返回三样东西环境变量、设备挂载列表和容器内设备访问权限配置。2.3 为什么不能直接用GPU的Device Plugin改改凑合用我一开始也想过偷懒——把NVIDIA的Device Plugin源码拉下来改个设备名、换个资源名就完事。但深入看代码后发现行不通原因有三个第一设备发现方式完全不同。NVIDIA插件通过NVML库查询GPU数量、显存、利用率而RK3588只有/dev/rknpu这一个设备节点。RKNN Runtime也没有暴露类似NVML的查询接口你无法从驱动层面获知当前NPU有几个硬件上下文在使用。第二资源量化方式不一致。GPU以显存和核心数为单位做切片是合理的但NPU是统一内存架构算力并发度不是线性的。简单把一块NPU当成1个单位上报意味着整块板子同时只能跑一个推理任务浪费严重但如果上报成100个单位又缺乏对真实并发能力的约束。第三RKNN Runtime有自己的一套运行时状态。它通过rknn_init创建上下文通过rknn_run执行推理这套过程绕过了K8s的cgroup隔离也就是说即使K8s把NPU分配给了容器A容器B如果拿到设备节点照样也能发起推理请求——设备本身没有强隔离能力。所以直接改NVIDIA插件这条路走不通必须针对RK3588的特性重新设计资源抽象逻辑。3. 从设备探测到Pod启动亲手补上NPU调度的完整实现明确了方案后我按四个步骤落地硬件侧确认设备节点、编写Device Plugin核心逻辑、注册扩展资源、验证容器内推理。3.1 硬件侧确认NPU设备节点的存在在RK3588开发板上先确认内核模块已经加载lsmod | grep rknpu # 输出类似rknpu 409600 0 if [ -e /dev/rknpu ]; then echo NPU device node exists ls -l /dev/rknpu fi正常情况下的输出是c 10:0类型的字符设备。主板厂商包括讯为、友善之臂等主流RK3588板卡的出厂固件里rknpu驱动默认是加载的不需要手动编译内核。这里有个容易踩的坑很多定制化Ubuntu镜像比如自己手动移植Ubuntu 20.04到RK3588的固件可能没有包含rknpu这个内核模块或者固件里关闭了它。如果在lsmod里看不到rknpu需要检查内核配置项CONFIG_ROCKCHIP_RKNPU是否开启。我在移植Ubuntu 20.04.5根文件系统的时候就遇到过这事当时固件里压根没有这个模块只能重新打包内核。3.2 写Device Plugin注册、上报、分配三步走Device Plugin的核心接口定义在K8s官方仓库的pkg/kubelet/apis/deviceplugin/v1beta1包里。我用Go语言实现了一个最小可用的插件核心逻辑分三块第一步注册。import ( google.golang.org/grpc k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1 ) func main() { // 连接kubelet的Unix Socket conn, err : grpc.Dial(unix:///var/lib/kubelet/device-plugins/kubelet.sock, grpc.WithInsecure(), grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { return net.Dial(unix, addr) })) client : v1beta1.NewDevicePluginClient(conn) // 注册插件告诉kubelet这个插件负责哪种资源 _, err client.Register(ctx, v1beta1.RegisterRequest{ Version: v1beta1.Version, Endpoint: rocknpu-device-plugin.sock, ResourceName: rocknpu.device/npu, }) }注册之后kubelet会在/var/lib/kubelet/device-plugins/目录下创建对应的Socket路径准备与插件进行长连接通信。第二步ListAndWatch持续上报设备状态。// 插件向kubelet上报NPU设备列表。RK3588单板只有一个NPU // 但为了支持集群场景我保留了多个节点的上报能力。 func (p *NPUPlugin) ListAndWatch(e *v1beta1.Empty, s v1beta1.DevicePlugin_ListAndWatchServer) error { devices : []*v1beta1.Device{ { ID: p.npuID, Health: v1beta1.Healthy, }, } s.Send(v1beta1.ListAndWatchResponse{Devices: devices}) // 持续监听设备健康状态变化如果有变更则重新发送 for { select { case -p.stop: return nil case -time.After(10 * time.Second): // 实际项目里这里可以周期性检查 /dev/rknpu 是否可访问 } } }第三步Allocate返回容器运行时配置。// 当Pod申请了 rocknpu.device/npu 且被调度到本节点时kubelet调用这个回调 func (p *NPUPlugin) Allocate(ctx context.Context, reqs *v1beta1.AllocateRequest) (*v1beta1.AllocateResponse, error) { var responses []*v1beta1.ContainerAllocateResponse for range reqs.ContainerRequests { responses append(responses, v1beta1.ContainerAllocateResponse{ // 把NPU设备节点挂载进容器 Devices: []*v1beta1.DeviceSpec{ { HostPath: /dev/rknpu, ContainerPath: /dev/rknpu, Permissions: rw, }, }, // 设置RKNN Runtime识别NPU设备的环境变量 Envs: map[string]string{ RKNPU_DEVICE: /dev/rknpu, }, }) } return v1beta1.AllocateResponse{ContainerResponses: responses}, nil }这里有一个关键决策每个容器申请多少NPU资源。我调研了RKNN Runtime的实现后发现RK3588的NPU支持多个硬件上下文并发推理但实际可并发数量受限于模型大小和算力占用。实测下来跑一个YOLOv8s模型同时开3路并发推理没有问题跑大模型比如类似于Qwen3.5那类7B级别模型当然8GB内存的板子跑不太动或者同时跑多个模型时2路就已经比较吃力。所以我的量化策略是把一块RK3588的NPU上报为rocknpu.device/npu: 2。每个Pod在limits里写rocknpu.device/npu: 1意味着它占用了50%的NPU算力配额。当然这个比例可以根据实际模型负载动态调整理论上也可以上报为10让调度粒度更细。3.3 K8s侧Extended Resource注册与调度验证Device Plugin启动后kubelet会自动完成扩展资源的注册不需要手动去改节点对象。验证方法很直接kubectl describe node rk3588-node-01 | grep rocknpu # 输出类似 # rocknpu.device/npu: 2 # rocknpu.device/npu: 2capacity和allocatable两行都出现了这个资源说明注册成功。接着定义一个测试Pod来验证调度apiVersion: v1 kind: Pod metadata: name: rknn-test spec: containers: - name: rknpu-inference image: myregistry/rknn-yolov8:latest resources: limits: rocknpu.device/npu: 1 volumeMounts: - name: rknn-models mountPath: /models volumes: - name: rknn-models hostPath: path: /opt/npu-models3.4 容器侧让RKNN真正能访问到NPU容器镜像里需要包含RKNN Runtime的库文件。Rockchip官方发布的librknnrt.so动态库是闭源的但可以全部拷贝进镜像里。Dockerfile的核心要点FROM ubuntu:20.04 # 拷贝Rockchip官方RKNN Runtime库 COPY librknnrt.so /usr/lib/librknnrt.so # 拷贝RKNN Toolkit Python绑定 COPY rknn-toolkit2 /opt/rknn-toolkit2 # 设置动态库搜索路径 ENV LD_LIBRARY_PATH/usr/lib # 确保容器有权限访问NPU设备 RUN echo KERNELrknpu, MODE0666 /etc/udev/rules.d/99-rknpu.rules容器启动后执行一个简单的RKNN推理程序看能否正常打开设备from rknn.api import RKNN rknn RKNN() # 加载YOLOv8转好的rknn模型 ret rknn.load_rknn(/models/yolov8s.rknn) assert ret 0 # 初始化运行时绑定NPU ret rknn.init_runtime() assert ret 0 # 如果能看到 INIT SUCCESS 且没有 FAIL说明容器内NPU访问正常这一步如果报错E ... failed to open /dev/rknpu那就要检查Allocate回调里返回的Permissions: rw是否正确以及Pod YAML里securityContext是否限制了设备访问权限。4. 并发推理才见真章多个任务同时请求NPU会发生什么单Pod跑通NPU推理只是第一步。真正考验调度能力的是并发场景——多个推理任务同时进来K8s该怎么把孩子分给有限的NPU资源。4.1 多个RK3588节点组成的NPU集群我在测试环境里用了两台节点节点名硬件NPU资源上报量备注rk3588-node-01Rockchip RK3588开发板rocknpu.device/npu: 26 TOPS算力rk3588-node-02Rockchip RK3588S板卡rocknpu.device/npu: 13 TOPS算力RK3588S的NPU部分被阉割请注意RK3588S与RK3588在NPU规格上不同前者只有3 TOPS。在真实集群里不同节点上报的NPU资源数量完全取决于硬件差异和插件的配置这是K8s的资源模型天然支持的。然后我创建了三个Pod每个申请rocknpu.device/npu: 1。调度结果kubectl get pods -o wide # 输出 # rknn-task-1 Running rk3588-node-01 # rknn-task-2 Running rk3588-node-01 # rknn-task-3 Running rk3588-node-02两个任务被分到了node-01因为它有2个单位的NPU资源一个任务被分到了node-02。符合预期。4.2 并发推理时的设备竞争问题但这里有一个非常隐蔽的坑K8s认为资源已分配不等于硬件真的被独占。由于/dev/rknpu只有一个物理设备节点即使K8s层面把2个单位资源都分出去了如果RKNN Runtime对每个Pod创建了独立的进程并且两个Pod同时访问这个设备节点实际驱动层的并发调度由rknpu内核模块和RKNN Runtime内部机制处理。实测过程中我发现在RK3588上同时跑两个独立的YOLOv8s实例每个实例开2线程预处理推理帧率确实会下降。单实例跑满的话大约能到35 FPS两个实例同时跑的话各自只有18-20 FPS左右——NPU算力被平分了但总体吞吐量比单实例提升接近一倍。这个结果是合理的。真正的风险在于如果某个Pod里的程序写得很奔放同时创建了太多RKNN上下文有可能把NPU资源吃满影响其他Pod的正常推理。对此我的建议是两件事在镜像层面做限制每个容器内通过环境变量比如RKNN_MAX_CONTEXTS1限制单个Pod创建的RKNN上下文数量。在应用层做排队如果推理服务是一个常驻进程比如通过HTTP server暴露推理接口那么在应用内部要做好请求排队机制避免瞬时并发把NPU打爆。4.3 资源分配的两种模式对比我实验了两种NPU分配策略整理如下策略资源上报量优点缺点适用场景整卡独占模式上报为1语义清晰每个Pod独占整块NPU浪费算力一块板上只能跑一个推理任务大模型单路推理分片配额模式上报为2或更多充分利用NPU并发能力多任务共享算力需要控制单个Pod的实际负载否则会互相干扰小模型多路推理、边缘批处理我实际项目中用的是分片配额模式上报为2。这个选择背后的逻辑是我手头的业务场景是多个轻量级检测模型YOLOv8s、PicoDet并发跑每个模型实际占用的NPU计算单元大约在30%-50%之间留出裕量后用2个单位做配额比较合理。5. 排错实录Pod申请NPU后一直Pending的排查过程开发过程中遇到的最折磨人的问题是Pod一直卡在Pending状态。这里我把完整的排查链路写下来比直接给答案有用得多。5.1 现象Pod一直Pending创建了一个申请了rocknpu.device/npu: 1的Pod通过kubectl get pods看到状态一直是Pending。查看Pod详情kubectl describe pod rknn-test # 事件 # 0s Normal Scheduled node/rk3588-node-01 # 0s Warning FailedScheduling node/rk3588-node-01很反常前一条事件显示Pod已经被调度到了节点紧接着又来了一条FailedScheduling事件说明调度器中途又反悔了。5.2 排查链路一kubelet是否注册了资源第一反应是查节点资源有没有注册上去kubectl describe node rk3588-node-01 | grep -A2 rocknpu # 输出为空节点上根本没有rocknpu.device/npu这个资源。说明Device Plugin可能没有成功启动或者启动后没和kubelet完成注册。5.3 排查链路二Device Plugin为什么没上报检查Device Plugin的日志kubectl logs -f rknpu-device-plugin-xxx -n kube-system # 日志 # failed to register device plugin: rpc error: code Unknown desc failed to create socket for device plugin: cannot find socket directory这里锁定了根因Device Plugin启动时需要在kubelet的socket目录里创建自己的Socket文件/var/lib/kubelet/device-plugins/但这个目录在容器里不存在。原因是我把Device Plugin部署为K8s DaemonSet时没有挂载这个hostPath目录volumeMounts: - name: device-plugins mountPath: /var/lib/kubelet/device-plugins volumes: - name: device-plugins hostPath: path: /var/lib/kubelet/device-plugins补上挂载之后重新部署节点上就能看到rocknpu.device/npu资源了。5.4 容器内访问失败的坑资源注册成功、Pod也变成Running了但容器内部执行RKNN推理时报了一个很误导的错E RKNN: rknn_init, get device id failed!这个问题排查了快一天。表面上看是驱动问题但实际上是因为容器内/dev/rknpu的权限不对。由于Device Plugin的Allocate回调里返回了Permissions: rw如果Pod没有以privileged模式运行或者没有单独设置设备访问权限容器内的用户仍然无法访问主机上的设备文件。解决方案有两种给容器加securityContext.privileged: true——简单粗暴但不够优雅安全性差。在Device Plugin的Allocate回调里设置ContainerAllocateResponse.Devices[].Permissions为rwm同时把/dev/rknpu的宿主机权限改宽松。比如chmod 666 /dev/rknpu这个方法更精准。另外还有个隐蔽点如果Pod里运行的是非root用户比如我用的是Uid: 1000因为RKNN Runtime会尝试往/tmp或/dev/shm写临时文件需要在镜像里提前chmod 777 /tmp否则跑起来也会报文件权限错误。5.5 另外一种常见Pending原因资源名填错还有一个我后来不小心踩过的坑Pod里资源名写成rocknpu.device/npu: 1但Device Plugin注册的是rocknpu.device/npu前的名字。K8s对扩展资源名有严格约束——域名前缀必须是小写字母数字和.-如果你的名字里有大写字母、下划线或者域名格式不对kubelet注册时直接忽略。比如Rockchip.device/npu这种写法注册时会报错正确格式是rocknpu.device/npu。这个细节API Server不会提示你只会静默忽略非常坑。6. 进阶扩展NPU利用率监控与跨节点调度策略当多个RK3588节点组成NPU集群之后光有调度还不够还得知道每块板子的NPU到底忙不忙、有没有Pod在空转。这一步我接到了Prometheus Grafana做可视化监控。6.1 NPU利用率监控的现有方案瓶颈由于RKNN Runtime没有暴露类似nvidia-smi的API无法直接从用户态读取NPU利用率。我采用了一种折中方案通过读取/sys/kernel/debug/rknpu/下的寄存器计数器和任务调度状态来估算负载。具体做法是写一个轻量的Exporter守护进程周期性地从内核调试节点读取NPU当前活跃的任务数和频率信息然后通过Prometheus指标暴露出来// 伪代码从内核debugfs读取NPU状态 func readNPULoad() float64 { data, _ : os.ReadFile(/sys/kernel/debug/rknpu/load) // 解析出类似 50 的百分比数值 return parsePercentage(data) }但要注意debugfs在默认Ubuntu内核上是只读挂载的需要手动设置权限或者在系统里做一次临时挂载mount -t debugfs none /sys/kernel/debug如果不想依赖debugfs还有另一个间接方案——从Pod运行时的推理帧率反推负载。每个Pod里跑一个stats采集器把本Pod的推理延迟、吞吐量指标通过/metrics端点暴露给Prometheus。虽然无法精确到硬件层面但作为业务负载的观测手段绰绰有余。6.2 跨节点调度的灰度策略当节点数多起来后我调整了Device Plugin上报的资源量同样是RK3588有些节点上报为2有些上报为3。这样调度器就能天然地把任务倾向分发给资源量更充裕的节点。这个思路本质上是用K8s原生的节点亲和性nodeAffinity来给性能强的节点加分。如果要更细粒度的调度策略还可以在Pod的topologySpreadConstraints里配置跨节点的分布约束。比如我要确保推理任务平均分散到每块板子上topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: rknn-inference这个配置的含义是新Pod只会调度到NPU资源最紧缺的节点相对上保证各节点负载尽量均匀。6.3 后续可以怎么扩展补上Device Plugin只是第一步。踩过这些坑之后我觉得后面可以做的事还有不少与RKNN Toolkit集成做模型版本管理把转换好的.rknn模型放在类似OCI镜像仓库的地方Pod启动时按需拉取指定版本的模型配合K8s的滚动更新机制做模型升级。动态调整资源上报量目前的资源量是静态配置的如果能根据NPU实时负载动态调整上报量就可以实现超卖调度把小模型任务密集塞进算力宽裕的节点。接入HPA自动扩缩容如果推理服务是基于HTTP的可以配置HorizontalPodAutoscaler根据请求QPS自动扩展Pod数量结合NPU资源限制自动扩容。这个方向目前的生态不算成熟RK3588这类设备在边缘侧的口碑大多数时候是性能够用但软件生态凑合。不过自己动手把K8s这一层补齐之后整套系统从单机跑模型进化成了集群化、可观测、可弹性伸缩的推理平台投入产出比还是相当值得的。如果你也在做相关的板子集群统一调度或者正打算把NPU资源接入K8s我的建议是先从最小的闭环开始——先跑通Device Plugin注册和Allocate回调再逐步加监控、加调度策略。这套链路本身不复杂真正费时间的是在硬件差异化和并发场景里把细节调顺。希望我这篇实操笔记能帮你省掉几天的排查时间。
返回列表