k8s集群接入昆仑芯P800这件事,我前前后后折腾了小一个月,踩了不少坑,也积累了些实在经验。P800是国产AI加速卡里相当能打的一代,规模集群用起来,核心诉求很简单:让Kubernetes能像调度GPU一样调度NPU。但真正操作起来会发现,难度不在于装个驱动,而在于理解Kubernetes异构算力接入的完整链路——驱动、运行时、设备插件、调度器,环环相扣,哪一环断掉都白搭。
这篇文章就把我摸索出来的整套方案拆开来讲,从硬件环境准备到设备插件部署,再到调度策略配置和问题排查,尽量把底层逻辑说明白。无论你是刚接触NPU集群的小白,还是已经踩过一部分坑的运维,应该都能从里面找到参考。
1. 整体思路拆解:Kubernetes如何纳管异构算力
1.1 昆仑芯P800的基本构成与接入前提
昆仑芯P800是一张AI加速卡,和GPU的定位类似,但底层架构不一样,接口、驱动、开发栈都是独立生态。用在服务器上,最常见的是PCIe插卡形态。一张卡在系统里会暴露这样几类东西:
- 字符设备节点:用于应用与驱动交互,通常在/dev/kunlun路径下,比如 /dev/kunlun/device0 。
- 内核驱动模块:提供驱动的ko文件,加载后系统才能识别设备。
- 用户态运行时库:Python SDK、推理引擎、npu-smi等工具,用于开发、调试和监控。
要让Kubernetes调度它,第一步就是让kubelet能在节点上“看到”这些设备,然后通过设备插件机制把设备数量、设备健康状态上报给API Server,再让调度器把这些资源当作可调度的对象。
关键点在于,Kubernetes原生并不认识P800这种NPU设备,必须通过Extended Resource也就是扩展资源的方式来做资源建模。常见的做法是定义一个自定义资源名,比如 kunlun.com/npu ,类似nvidia.com/gpu的做法,由设备插件负责上报数量,调度器负责匹配,kubelet负责任命器和Pod生命周期内的设备分配与注入。
1.2 Kubernetes纳管NPU的核心链路
整个接入链路可以从下往上拆成五层:
- 硬件层:服务器装上P800加速卡,BIOS开启Resizable BAR(最好开启),系统能正常识别设备。
- 驱动层:内核驱动加载成功,/dev/kunlun设备节点出现,npu-smi能看到卡的信息。
- 运行时层:容器运行时(containerd或docker)在启动容器时,能够把设备节点和用户态库“翻译”进容器,让容器内的进程认为它直接访问设备。
- 设备插件层:实现Kubernetes Device Plugin API的组件,通过gRPC与kubelet通信,上报设备数量和健康状态,处理Allocate请求。
- 调度器层:Kubernetes调度器通过Extended Resource感知节点上的NPU数量,在调度时根据Pod的request匹配节点,同时配合拓扑感知调度或自定义调度插件,处理分配策略。
每一层都有各自的坑。驱动层最容易出现版本不匹配、固件不配套的问题;运行时层最容易被忽略,因为大部分文档默认你用的是docker,但生产环境普遍换成containerd后,很多配置就变了;设备插件层则是整个链路里唯一需要自己动手大量改代码的地方;调度器层看起来简单,实际上一旦涉及多卡分配、同节点亲和性,就得加代码。
先有整体图景,后面操作起来才不至于像无头苍蝇。下面按我实际操作的顺序来讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与前置条件
2.1 硬件、系统与固件要求
在动软件之前,先用 nvidia-smi 的思路,找到对应的NPU巡检工具,确认底层环境是好的。对于昆仑芯P800,常见的巡检命令是 npu-smi info 。如果命令都跑不出来,后面的工作全白搭。
我这次用的测试节点配置如下:
| 项 | 配置 |
|---|---|
| CPU | 某品牌x86架构,支持IOMMU |
| 内存 | 512GB |
| P800卡 | 4张,PCIe Gen4 x16 |
| 操作系统 | 某服务器发行版,内核版本5.10+ |
| 容器运行时 | containerd 1.7.x |
| Kubernetes | v1.28.3 |
| 架构 | amd64 |
系统层面有几个点务必提前确认:
- 确认BIOS中Above 4G Decoding、Resizable BAR功能已开启,否则PCIe BAR空间可能不足,导致卡识别不正常。
- 确认系统的IOMMU配置不影响设备DMA。部分服务器在开启Virsh、VTD后,需要在内核参数里配置iommu=pt,直通模式下设备性能损失更小。
- 内核版本不要太老,建议5.10以上,驱动对较老内核的适配一般相对滞后。
提示:先不要在Kubernetes还没装的情况下就急着装驱动。先把系统干净态的准备做好,内核参数、BIOS设置都确认后再装驱动,能减少后面的排查成本。
2.2 从软件源获取驱动与固件
动驱动之前,先要把厂商的软件源配好。昆仑芯驱动和固件的发布方式类似NVIDIA的cuda-toolkit,会给一个软件仓库,安装方式一般是yum/apt加rpm/deb包,或者直接一个.run安装器。
以我接触到的常见安装流程举例:
bash复制# 添加软件源到 /etc/yum.repos.d/
curl -fsSL https://mirrors.example.com/kunlun/rpm/kunlun.repo -o /etc/yum.repos.d/kunlun.repo
# 安装内核驱动
yum install -y kunlun-driver
# 安装用户态工具包
yum install -y kunlun-toolkit
# 加载驱动模块
modprobe kunlun
# 检查是否识别
npu-smi info
如果npu-smi info能看到卡的信息,带设备温度、显存占用等,那驱动层基本OK。但要注意,npu-smi在宿主机上能看到设备,这只是第一步,后续容器内能不能看到是另一回事,这个问题后面在设备注入的部分会重点讲。
驱动安装完毕后,检查一下设备节点:
bash复制ls /dev/kunlun/
正常情况下会列出每个设备的编号,比如device0到device3,与物理卡一一对应。如果列表为空,多半是驱动模块没加载成功,或者固件不匹配。可以查内核日志:
bash复制dmesg | grep -i kunlun
看是否有硬件报错。如果报错信息提示固件版本不匹配,去下载对应版本的固件包重新安装,不要硬着头皮跑后面的流程,不然后面问题排查起来会绕很大一圈。
驱动与固件务必保持配套版本。我遇到过一个节点,驱动是最新的,固件还是半年前的,结果卡在初始化阶段,npu-smi info报错显示“ Device status: abnormal ”。后面刷回跟驱动配套的固件版本后,一切正常。
2.3 容器运行时的设备注入机制
容器运行时的目标只有一个:让容器内的进程能访问宿主机上的设备节点和库文件。这听起来简单,但实现机制上,不同运行时差别很大。
对于docker,做法相对粗暴,直接在容器启动时添加--device参数和-v挂载参数。但Kubernetes不使用docker的原生设备参数,而是由Device Plugin在Allocate阶段返回设备对应的设备节点和挂载路径列表,再由kubelet把这些信息转换成容器的DeviceRequest和Mount。
对于containerd,kubelet同样把这些信息转换成containerd对应的消息。真正让NPU驱动等库文件进入容器,通常有两种方式:
- 挂载节点路径:如果宿主机的用户态库路径固定,在设备插件Allocate阶段把库目录作为mount挂载进容器。
- 镜像内置库:直接把用户态库打到NPU应用镜像里。这个方案部署简单,但镜像会变得很大,而且库版本升级时需要重做镜像。
生产环境更推荐第一种。但这里有个很隐蔽的权限问题:挂载设备节点时,容器内的进程是不是root?如果是非root用户运行的Pod,设备节点的权限组是否正确?
驱动设备节点一般的权限默认是root:root,普通用户无法访问。因此在设备插件里,需要给Pod注入额外的privileged权限,或者给设备节点设置合适的group,才能让非root用户容器进程访问。我们的做法是在Allocate阶段给Pod的容器添加:
yaml复制securityContext:
privileged: true
对于短期验证可以这么做,长期生产环境不应该给每个Pod开privileged,建议使用runtimeClass级别的权限控制,或者在设备插件里把设备节点的组ID改成特定用户组。
注意:设备节点访问权限问题,是这个方案里最常见的故障来源之一,后面常见问题部分会再展开。
2.4 镜像准备与运行时链路验证
在正式交给Kubernetes之前,先手动验证一下容器内能不能用到NPU,这样能快速把问题和Kubernetes本身剥离。
一个最简验证镜像的Dockerfile可以是这样:
dockerfile复制FROM registry.example.com/linux-base:latest
COPY --from=kunlun-toolkit-image /usr/local/kunlun /usr/local/kunlun
ENV LD_LIBRARY_PATH=/usr/local/kunlun/lib:${LD_LIBRARY_PATH}
然后先在宿主机上手动跑一遍容器:
bash复制docker run --rm -it \
--device=/dev/kunlun/device0 \
-v /usr/local/kunlun:/usr/local/kunlun \
-e LD_LIBRARY_PATH=/usr/local/kunlun/lib \
registry.example.com/kunlun-base:latest \
npu-smi info
如果这一步能识别到卡,说明硬件、驱动、用户态库都OK。如果这一步就挂了,那问题大概率出在驱动或设备节点,而不是Kubernetes,先解决掉再往下走。很多人在集群里排查半天,最后发现其实是驱动源版本不对,这种基础检查能省下大量时间。
3. 设备插件开发与部署实操
3.1 Device Plugin API实现细节
Kubernetes设备插件是标准gRPC接口。在实现时,核心接口是两个:
- ListAndWatch:向kubelet报告设备的ID列表和健康状态变化。kubelet会一直保持这个流的监听,一旦发现设备健康状态变化,实时更新节点资源。
- Allocate:当调度器决定把Pod调度到某个节点,且这个Pod需要该扩展资源时,kubelet调用这个接口,传递要分配的设备ID列表与容器信息,插件返回容器运行时的设备注入配置。
设备插件的核心逻辑不复杂,但有几个容易出错的地方:
- 需要向kubelet健康检查Endpoint注册。
- ListAndWatch返回的设备ID必须是节点上真实存在的设备ID。
- Allocate返回的Envs、Mounts、Devices列表要尽量完整,否则容器起不来。
- 设备插件崩溃后,kubelet会标记该扩展资源不可用,已调度的Pod也会被拒绝启动。因此生产环境必须用DaemonSet跑,设置适当的readinessProbe与livenessProbe。
下面是我实现的一个简化版设备插件的伪代码框架:
go复制package main
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"time"
"google.golang.org/grpc"
"k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1"
)
type KunlunDevicePlugin struct {
devices map[string]*v1beta1.Device
socketPath string
devicePathMap map[string][]string
}
func (p *KunlunDevicePlugin) ListAndWatch(empty *v1beta1.Empty, stream v1beta1.DevicePlugin_ListAndWatchServer) error {
// 初始上报所有设备
devices := make([]*v1beta1.Device, 0, len(p.devices))
for _, dev := range p.devices {
devices = append(devices, dev)
}
stream.Send(&v1beta1.ListAndWatchResponse{Devices: devices})
for {
time.Sleep(30 * time.Second)
// 实际应该在这里检查设备健康状态变化,比如npu-smi异常
// 如果设备状态变化,重新Send
}
}
func (p *KunlunDevicePlugin) Allocate(ctx context.Context, reqs *v1beta1.AllocateRequest) (*v1beta1.AllocateResponse, error) {
var responses []*v1beta1.ContainerAllocateResponse
for _, req := range reqs.ContainerRequests {
var mounts []*v1beta1.Mount
var devices []*v1beta1.DeviceSpec
// 将用户态库目录挂载进容器
mounts = append(mounts, &v1beta1.Mount{
HostPath: "/usr/local/kunlun",
ContainerPath: "/usr/local/kunlun",
})
// 根据请求的设备ID列表,映射设备节点
for _, id := range req.DevicesIDs {
devices = append(devices, &v1beta1.DeviceSpec{
HostPath: filepath.Join("/dev/kunlun", id),
ContainerPath: filepath.Join("/dev/kunlun", id),
Permissions: "rwm",
})
}
responses = append(responses, &v1beta1.ContainerAllocateResponse{
Mounts: mounts,
Devices: devices,
})
}
return &v1beta1.AllocateResponse{ContainerResponses: responses}, nil
}
func main() {
// 注册到kubelet
plugin := &KunlunDevicePlugin{...}
plugin.Register()
// 启动gRPC服务
serve(plugin.socketPath)
}
这段代码简化为核心逻辑,真实实现还需要考虑插件的日志、优雅退出、设备状态更新和Socket文件清理。核心就三步:定义设备、启动gRPC服务、注册给kubelet。
3.2 构建镜像与部署为DaemonSet
写好了设备插件程序,下一步是构建镜像并部署。设备插件本身不依赖NPU设备,但为了监控设备健康状态,需要调用npu-smi,所以镜像里需要包含工具链。
DaemonSet的YAML看起来大致如下:
yaml复制apiVersion: apps/v1
kind: DaemonSet
metadata:
name: kunlun-device-plugin
namespace: kube-system
spec:
selector:
matchLabels:
app: kunlun-device-plugin
template:
metadata:
labels:
app: kunlun-device-plugin
spec:
priorityClassName: system-node-critical
tolerations:
- key: CriticalAddonsOnly
operator: Exists
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: kunlun-device-plugin
image: registry.example.com/kunlun-device-plugin:1.0.0
securityContext:
privileged: true
env:
- name: KUBELET_SOCKET
value: /var/lib/kubelet/device-plugins/kubelet.sock
volumeMounts:
- name: device-plugins
mountPath: /var/lib/kubelet/device-plugins
- name: kunlun-dev
mountPath: /dev/kunlun
- name: kunlun-lib
mountPath: /usr/local/kunlun
resources:
requests:
cpu: 50m
memory: 50Mi
limits:
memory: 200Mi
volumes:
- name: device-plugins
hostPath:
path: /var/lib/kubelet/device-plugins
- name: kunlun-dev
hostPath:
path: /dev/kunlun
- name: kunlun-lib
hostPath:
path: /usr/local/kunlun
有几个细节值得注意:
- 设备插件的Socket文件目录是 /var/lib/kubelet/device-plugins,这个目录必须挂载进插件容器,而且是hostPath类型。
- 如果插件容器崩溃,会一直重启,所以在Pod里要加restartPolicy: Always,同时通过readinessProbe探测Socket文件是否存在。
- securityContext.privileged: true,不加上大概率无法正常访问设备。这不是好习惯,但当前阶段先跑通再说,后面再用更细粒度的权限方案收紧。
部署完成后,检查状态:
bash复制kubectl -n kube-system get pods | grep kunlun
kubectl -n kube-system logs -l app=kunlun-device-plugin
3.3 节点资源上报与验证
设备插件启动后,kubelet会在一定时间内调用ListAndWatch获取设备列表。正常情况下,可以通过以下命令看到节点的扩展资源:
bash复制kubectl describe node node-01
输出里应该能看到类似这样的一行:
code复制Capacity:
kunlun.com/npu: 4
Allocatable:
kunlun.com/npu: 4
如果根本没有kunlun.com/npu这一项,排查顺序是这样的:
- 设备插件Pod是否Running:日志显示连不上kubelet?Socket文件权限不对?
- 插件是否成功调用Register:看kubelet日志,确认注册成功。
- 扩展资源名是否符合规范:名称必须是DNS子域格式,比如 kunlun.com/npu,如果自定义名字里包含大写或下划线,会被API Server拒绝。
扩展资源名一旦确定,不要轻易改。因为Pod调度和Quota都可能绑定这个名字。改一次,所有相关的ResourceQuota、LimitRange、Workload的resource request都要同步改。
还要检查Allocatable是否为0,如果是0,说明设备插件上报的可用设备数量为0,大概率是设备健康检查逻辑或设备检测逻辑有问题。看插件日志,确认设备数量是否成功获取。
4. 调度策略与工作负载验证
4.1 用一份最小Pod验证NPU调度
设备插件上报成功后,跑一个最简单的Pod来验证调度和注入。比如这样一个YAML:
yaml复制apiVersion: v1
kind: Pod
metadata:
name: npu-test
spec:
restartPolicy: Never
containers:
- name: npu-test
image: registry.example.com/kunlun-base:latest
command: ["/bin/sh", "-c"]
args:
- |
npu-smi info
sleep 3600
resources:
limits:
kunlun.com/npu: "1"
这里为什么只写limits不写requests?在Kubernetes的Extended Resource用法中,request和limit必须相等,而且如果没有显式写requests,Kubernetes会自动把requests设为等于limits的值。所以只写limits就会自动带上requests=1。
提交后查看Pod状态:
bash复制kubectl apply -f npu-test.yaml
kubectl get pod npu-test -o wide
正常情况Pod会调度到有NPU的节点,执行npu-smi info能看到卡的信息。如果Pod一直Pending,很可能是没有节点能被调度器选中。用kubectl describe pod查看Pending原因,会看到类似“0/4 nodes available: 4 Insufficient kunlun.com/npu”的提示。
4.2 单Pod多卡、整卡分配与任务编排
如果单卡验证没问题,再验证多卡场景。P800单卡推理一般不够用,常见的是一个Pod申请2张卡或4张卡,做张量并行或数据并行推理。
先看2卡分配:
yaml复制apiVersion: v1
kind: Pod
metadata:
name: npu-test-2
spec:
containers:
- name: npu-test
image: registry.example.com/kunlun-base:latest
command: ["/bin/sh", "-c"]
args:
- |
npu-smi info -l
sleep 3600
resources:
limits:
kunlun.com/npu: "2"
在设备插件Allocate的时候会收到两个设备ID,需要把两张卡的设备节点同时挂进容器。注意此时物理设备之间的互联方式,P800如果是通过PCIe Switch互联,多卡通信性能会受限于PCIe拓扑。需要确认算法对卡间通信带宽的要求。
如果涉及MPI或分布式训练框架,推荐用集群调度器给Pod加亲和性约束,把多卡任务放在同一节点上。这要求设备插件具备同节点多卡分配能力。
如果Pod申请2张卡,但不要出现Pod1被分到卡0和卡1,Pod2被分到卡0和卡2的情况。这种跨设备组的组合可能导致性能下降,所以要实现基于设备组的分配策略,让设备插件在Allocate时优先分配相邻编号的卡。
如果设备插件只是简单按顺序分配,不做亲和性处理,多卡任务很可能出现性能波动。一个能用的策略是,在设备插件内部维护一个设备组的标签,Allocate时尽量选择同一组的设备。
4.3 调度器层面的资源感知与优选策略
Kubernetes默认调度器调度Extended Resource时,只关注节点上的数量是否满足请求。也就是说,如果有4个节点,每个节点上有4张卡,某Pod要4张卡,调度器会随机选择一个可用节点,不会考虑该节点上是否有其他Pod已占用部分卡。
这种调度导致的问题很典型:
- 节点A还剩2张卡,节点B还剩4张卡,同时有一个请求4张卡的Pod,调度器可能仍然把它调度到节点A,因为从节点维度看,A的Allocatable是4,可用是2,但它不知道Pod需要4张卡,所以不会把内存碎片问题纳入评分。
- 大数据量推理任务对延迟敏感,需要独占节点上的全部NPU,否则资源争抢会导致性能不稳定。
解决方案是使用调度器插件,实现一个类似于nvidia-mig的调度扩展。核心思路是给每个节点打上“可用NPU数量”的标签,或者在调度器里写一个Filter插件,只把剩余NPU数量>=请求数量的节点纳入候选。这是比较繁琐的工作,但如果集群里跑的是要独占整节点的任务,这一步不能省。
一段调度器扩展的Filter伪代码如下:
go复制type NPUFilter struct{}
func (p *NPUFilter) Filter(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
// 获取节点当前NPU可分配数量
allocatable := nodeInfo.Allocatable.ScalarResources["kunlun.com/npu"]
requested := 0
for _, podInfo := range nodeInfo.Pods {
for _, ctr := range podInfo.Pod.Spec.Containers {
if v, ok := ctr.Resources.Requests["kunlun.com/npu"]; ok {
requested += int(v.Value())
}
}
}
podRequest := getNPURequestFromPod(pod)
if allocatedTest := allocatable - int64(requested); allocatedTest < podRequest {
return framework.NewStatus(framework.Unschedulable, "insufficient kunlun.com/npu")
}
return nil
}
这种策略能让调度器真正“看到”节点剩余NPU资源,而不是只看到总数。对于多租户场景,这一步几乎必须做,否则资源分配不均会很严重。
4.4 集成推理服务与在线验证
调通了调度,就该跑真实业务了。最常见的是一个Nginx加一个推理引擎,或者在Pod内直接跑一个HTTP推理服务。以vLLM类推理框架为例(非昆仑专有,但类似框架在P800上也有适配):
yaml复制apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-inference
spec:
replicas: 1
selector:
matchLabels:
app: llm-inference
template:
metadata:
labels:
app: llm-inference
spec:
containers:
- name: inference
image: registry.example.com/llm-kunlun:latest
ports:
- containerPort: 8000
env:
- name: KUNLUN_VISIBLE_DEVICES
value: "0"
- name: LD_LIBRARY_PATH
value: /usr/local/kunlun/lib
resources:
limits:
kunlun.com/npu: "1"
此时通过服务地址访问推理接口,能正常返回结果,说明Kubernetes对P800的纳管真正打通了。上面这段KUNLUN_VISIBLE_DEVICES,类似CUDA_VISIBLE_DEVICES机制,具体名称以厂商SDK为准。
从这批验证里得到的经验是:Kubernetes接入NPU成功与否,最终要落在业务sidecar或推理服务能真正使用设备上,而不是只看Pod启动成功。应该提前准备一个最小推理脚本,验证张量能正常在卡上执行矩阵运算,这比单纯跑npu-smi更有说服力。
5. 常见问题与排查技巧实录
5.1 设备插件注册失败
现象:部署设备插件后,节点状态里没有kunlun.com/npu资源,设备插件Pod日志里出现“failed to register device plugin”或“socket file not found”。
排查思路:
- 先看插件是否能连上kubelet的Socket。进入插件容器,检查 /var/lib/kubelet/device-plugins/kubelet.sock 是否存在。如果不存在,说明kubelet的DevicePlugins功能没有启动,或者cgroup配置把挂载目录弄错了。
- 确认kubelet启动参数里有--feature-gates=DevicePlugins=true,新版K8s一般默认开启,但有些老版本不是。
- 确认插件进程使用的Socket路径和kubelet监听的Socket路径一致。因为kubelet默认的Socket路径是 /var/lib/kubelet/device-plugins/kubelet.sock,插件的“Register”请求也必须是这个路径。
- 可能是设备插件容器里没有挂载 /dev/kunlun,导致插件一开始就无法探测设备,自然也不会注册。这类问题看日志会直接看到获取设备数为0。
排查技巧:在kubelet日志里搜索“device plugin”关键词,能看到插件注册的详细记录,包括注册失败的具体原因。
5.2 Pod调度成功但容器内看不到NPU设备
这个坑我踩得最深,表现是:Pod状态Running,npu-smi也能执行,但报错“ No device found ”,或者直接段错误。
排查思路:
- 确认设备插件Allocate返回的ContainerPath是否和容器运行时的设备挂载路径一致。比如/dev/kunlun/device0,容器内路径是否也是这个。
- 检查Pod里容器SecurityContext的privileged设置。如果不加privileged,普通容器进程没有权限访问宿主机设备节点。
- 检查设备节点的权限。ls -l /dev/kunlun/ 看一下权限位。如果设备节点默认是root:root且权限只有rw-------,容器内非root用户无法读写。解决办法是设备插件返回DeviceSpec时,把Permissions设为“rwm”,同时给Pod加对应的SecurityContext;或者在宿主机上把这些设备节点的group改成容器用户所属的组。
- 确认用户态运行时库是否被正确挂载或打进镜像。如果容器里执行 npu-smi 时提示找不到libkunlun.so,那大概率是挂载路径不对或LD_LIBRARY_PATH不对。
经验:这个问题排查时要先隔离变量,先手动用docker跑一次容器,验证宿主机设备节点和库是否OK,如果docker里能访问,那问题就出在Kubernetes与设备插件的配合上;如果docker里也访问不了,问题在驱动或权限层面,不用在K8s里折腾。
5.3 节点资源上报数量不正确
现象:节点上明明插了4张卡,但Kubernetes只显示1张或2张可用。
可能的原因和排查方向:
- 设备插件获取设备时,可能只扫描了某个路径下的部分设备,比如只扫描了 /dev/kunlun/device0,而实际还有其他设备。检查设备插件的设备发现逻辑,是否遍历了 /dev/kunlun/ 下所有节点。
- 某些设备健康状态异常,被设备插件标记为 unhealthy。查看插件日志或npu-smi输出,确认所有设备都处于正常状态。
- kubelet的CPU和内存限制导致的性能问题也可能影响,但这种情况不常见。
- 还有可能是Allocatable不等于Capacity。如果之前有Pod失败释放资源不及时,会出现Allocatable低于Capacity的情况,等待一段时间或重启设备插件即可。
5.4 内核驱动升级导致已有Pod全部异常
有一次,我为了修复一个内核CVE,把节点内核从5.10升到5.14,结果重启后所有NPU Pod都报设备访问异常。原因很简单:驱动模块是针对旧内核编译的,新内核下没有自动重编。
当时排查时发现npu-smi在宿主机上也无法使用,重启kubelet、重建设备插件都没有用。最后重装了kunlun-driver包,重新modprobe才恢复。
这里有一个非常重要的认知:NPU驱动和内核版本强绑定,升级内核前必须确认驱动是否兼容新内核,并且要预留驱动重新编译/安装的时间窗口。
操作时还要注意,先在一个非关键节点上测试驱动升级,确认无误后再全集群分批升级。全部节点同时升级,一旦驱动与新内核不兼容,整个集群的NPU业务会同时中断。
5.5 卡间通信性能异常
多卡Pod能跑,但训练速度上不去,npu-smi显示卡间通信带宽很低。这种情况大概率是拓扑问题。比如两张卡接在不同的PCIe Switch下,跨Switch通信要走主板上的QPI/UPI链路,延迟和带宽都受影响。
解决思路:
- 优先把同一台服务器上拓扑相邻的卡分配给同一个Pod,避免跨CPU/NUMA访问。
- 设备插件内部可以维护一个拓扑亲和组,比如把同一PCIe Switch下的卡作为一个组,在Allocate时优先选择同组设备。
- 如果业务需要多机多卡,网络是关键,确认RDMA/RoCE配置正常,跨机通信延迟能达到预期。
6. 经验总结与后续优化方向
这套流程整体跑通之后,有几个体会特别深。
一是设备插件看起来简单,但真正要稳定,需要考虑的东西远超想象。健康检查、资源上报、Socket清理、异常恢复,每个环节都要做到位。二是Extended Resource模式有天花板,一旦涉及资源碎片化和多卡亲和性,就必须考虑上调度器插件,或者调研厂商是否提供了更成熟的调度方案。三是版本管理一定要严格。驱动版本、固件版本、SDK版本、容器镜像里的库版本,任何一个不一致,都可能让你花费一整天排查一个“不存在”的问题。
后续如果有余力,可以考虑这几个方向:
- 把设备插件升级为支持拓扑感知分配,结合node的NUMA信息做精细化调度。
- 接入Prometheus监控NPU卡的状态和利用率,配合设备插件上报健康状态,实现自动驱逐故障节点上的Pod。
- 如果有大规模训练任务,建议关注厂商是否提供类似MPI Operator的集成方案,让Kubernetes直接调度多机多卡训练任务,比自己写调度器省事得多。
最后分享一个实用小技巧:设备插件的开发调试阶段,先用单节点测试,不要上DaemonSet全集群推广。在一台节点上手动启动插件进程,配合kubectl describe node实时观察资源上报情况,排查效率会高很多。等单节点完全稳定了,再打镜像、上DaemonSet,能省掉大量因为环境差异导致的排查时间。
