- 一、整体思路
- 二、联网侧:一键下载 K3s 与 MindCluster 组件
- 三、联网侧:麒麟 V10 RPM 依赖离线下载
- 四、目标机:系统准备(01-system-prepare.sh)
- 五、目标机:安装 K3s(02-install-k3s.sh)
- 六、目标机:containerd 配置昇腾 Runtime(03-configure-containerd-runtime.sh)
- 七、目标机:导入镜像并重打标签(04-import-images.sh)
- 八、目标机:创建用户与日志目录(05-create-user-dirs.sh)
- 九、Master:部署 MindCluster 组件(06-deploy-mindcluster.sh)
- 十、Master:节点打标签(07-label-nodes.sh)
- 十一、Master:验证(08-verify.sh)
- 十二、Worker 节点加入流程
- 十三、踩坑速查
- 结语
内网麒麟 V10(ARM64)+ 昇腾 910 环境,没有外网,要把 K3s 和 MindCluster 26.0.0 全套(Volcano、ClusterD、Ascend Operator、Device Plugin 等)拉起来。整套流程我拆成了 11 个脚本,从联网侧下载到集群验证全覆盖,跑通后集群已稳定运行。本文按执行顺序过一遍,顺带记录踩过的坑。
一、整体思路
整个部署分两个阶段:
- 联网机器(x86_64 或 ARM64 的 Linux/Mac 都行):下载 K3s 离线包、MindCluster 组件包、导出容器镜像、下载麒麟 RPM 依赖,打成一个小目录整体拷进内网;
- 内网目标机(麒麟 V10 SP2 ARM64 + 昇腾 910):系统准备 → 装 K3s → 配 containerd → 导镜像 → 建用户目录 → 部署组件 → 打标签 → 验证。
拷进内网的离线包就一个目录,结构如下(脚本都按相对路径找兄弟目录,别打散):
offline-k3s-mindcluster/
├── k3s/ # k3s 二进制 + airgap 镜像 + install.sh
├── mindcluster/ # 7 个组件 zip(volcano/clusterd/...)
├── images/ # master-*.tar / worker-*.tar
├── rpms/ # 麒麟 V10 aarch64 RPM 依赖
└── scripts/ # 00 ~ 08 + worker-join-guide
版本组合:K3s v1.35.5+k3s1、MindCluster v26.0.0(组件内 Volcano 为 v1.9.0)、麒麟 V10 SP2。
前置条件(不在脚本范围内,但必须先有):NPU 节点已装好昇腾驱动/固件,npu-smi info 能看到卡;Ascend-Docker-Runtime 已安装且 /usr/local/Ascend/Ascend-Docker-Runtime/ascend-docker-runtime 存在,HwHiAiUser 组已创建。
二、联网侧:一键下载 K3s 与 MindCluster 组件
00-download-all.sh 干三件事:下载 K3s 离线资源、下载 MindCluster 组件 zip、拉取并导出容器镜像。
K3s 走 gh-proxy 加速,MindCluster 组件从 gitcode 的 mind-cluster 仓库拿:
K3S_VERSION="v1.35.5+k3s1"
K3S_URL="https://gh-proxy.org/https://github.com/k3s-io/k3s/releases/download/${K3S_VERSION}"
GITCODE_BASE="https://gitcode.com/Ascend/mind-cluster/releases/download/v26.0.0"
declare -A PACKAGES=(
["volcano"]="Ascend-mindxdl-volcano_26.0.0_linux-aarch64.zip"
["clusterd"]="Ascend-mindxdl-clusterd_26.0.0_linux-aarch64.zip"
["ascend-operator"]="Ascend-mindxdl-ascend-operator_26.0.0_linux-aarch64.zip"
["infer-operator"]="Ascend-mindxdl-infer-operator_26.0.0_linux-aarch64.zip"
["noded"]="Ascend-mindxdl-noded_26.0.0_linux-aarch64.zip"
["npu-exporter"]="Ascend-mindxdl-npu-exporter_26.0.0_linux-aarch64.zip"
["device-plugin"]="Ascend-mindxdl-device-plugin_26.0.0_linux-aarch64.zip"
)
容器镜像一共 8 个,按节点角色分两组,docker pull 后立刻 docker save 成 tar。注意 volcano 镜像的 tag 是双版本号 v1.9.0-v26.0.0——前面是 Volcano 内核版本,后面是 MindCluster 版本:
| 角色 | 镜像 |
|---|---|
| Master | vc-scheduler:v1.9.0-v26.0.0、vc-controller-manager:v1.9.0-v26.0.0、clusterd:v26.0.0、ascend-operator:v26.0.0、infer-operator:v26.0.0 |
| Worker | noded:v26.0.0、npu-exporter:v26.0.0、ascend-k8sdeviceplugin:v26.0.0 |
镜像源是 swr.cn-south-1.myhuaweicloud.com/ascendhub/。下载机架构自适应:ARM64 直接拉,x86_64 自动加 --platform linux/arm64——所以这台联网机是 x86 的也没关系,QEMU 的事交给 Docker。
三、联网侧:麒麟 V10 RPM 依赖离线下载
K3s 和 MindCluster 在麒麟上跑需要一堆系统依赖(conntrack、iptables、nftables、nfs-utils、openssh、selinux-policy 等),这些包在 CentOS/openEuler 源里凑不齐,得用麒麟自己的源。
思路:用 Docker 跑一个麒麟 V10 SP2 ARM64 容器(x86_64 下载机上靠 QEMU binfmt 模拟),容器里配麒麟官方源 + 华为 Ascend 源,yumdownloader --resolve 一把梭,把 rpm 全量下出来拷回宿主机。
# 容器内配置的源(Dockerfile 片段)
[kylin-base]
name=Kylin V10 SP2 Base aarch64
baseurl=https://update.cs2c.com.cn/NS/V10/V10SP2/os/adv/lic/base/aarch64/
[kylin-update]
name=Kylin V10 SP2 Updates aarch64
baseurl=https://update.cs2c.com.cn/NS/V10/V10SP2/os/adv/lic/updates/aarch64/
[ascend-docker]
name=Ascend Docker CE aarch64
baseurl=https://ascend-repo.obs.cn-east-2.myhuaweicloud.com/docker-ce/linux/centos/7/aarch64/stable/
麒麟基础镜像按顺序尝试 macrosan/kylin:v10-sp2 → v10-sp1 → kylinv10/kylin:b09。这里有两个坑,都写进了脚本:
iptables-ebtables这个包名在麒麟源里不存在,yumdownloader 会直接报错缺包。正确写法是ebtables。这个坑不修,整条下载链路就断在半路。- 麒麟镜像拉不到时千万不要回退 openEuler——源不兼容,命令显示"成功",实际 0 个 rpm。脚本里直接
exit 1禁止回退,并加了三层校验:容器内查/rpms、docker cp后复查、宿主机统计 rpm 数量,任何一层发现 0 个立刻报错退出。
另外 docker build --no-cache 强制全新下载,开工前清掉旧 rpm,避免旧缓存层骗人。sshpass、inotify-tools 两个特殊包走单独的 wget(一个来自 ascend-repo,一个来自华为云 epel 镜像)。
四、目标机:系统准备(01-system-prepare.sh)
所有节点执行。内容是标准 K8s 前置:关 SELinux、关 firewalld、关 swap、加载内核模块、设 sysctl,最后把 rpms/ 里的包一口气装上。
# 内核模块
cat > /etc/modules-load.d/k3s.conf <<EOF
br_netfilter
overlay
ip_vs
ip_vs_rr
ip_vs_wrr
ip_vs_sh
nf_conntrack
EOF
# 内核参数
cat > /etc/sysctl.d/k3s.conf <<EOF
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
net.ipv4.conf.all.forwarding = 1
net.ipv6.conf.all.forwarding = 1
EOF
# 离线安装依赖(rpms 目录由第三步产出)
cd rpms && rpm -ivh *.rpm --nodeps --force
跑完建议重启一次再继续。
五、目标机:安装 K3s(02-install-k3s.sh)
纯离线三板斧:放二进制、放 airgap 镜像包、跑 install.sh(跳过下载)。
cp k3s-arm64 /usr/local/bin/k3s && chmod +x /usr/local/bin/k3s
cp k3s-airgap-images-*.tar.zst /var/lib/rancher/k3s/agent/images/
# Master
INSTALL_K3S_SKIP_DOWNLOAD=true bash install.sh server \
--write-kubeconfig-mode 644 \
--disable traefik
# Worker(在另一台机器上)
K3S_URL=https://<MASTER_IP>:6443 K3S_TOKEN=<node-token> \
INSTALL_K3S_SKIP_DOWNLOAD=true bash install.sh agent
--disable traefik 是为了内网少一批用不上的镜像。Master 装完脚本会自动把加入命令(含 IP 和 token)打印出来,worker-join-guide.sh 也是干这个的,忘了 token 随时补看:cat /var/lib/rancher/k3s/server/node-token。
六、目标机:containerd 配置昇腾 Runtime(03-configure-containerd-runtime.sh)
这一步不做,NPU 容器起来也挂不上设备——设备注入靠 Ascend Docker Runtime 完成。K3s 的做法是写一份 config.toml.tmpl,把默认 runtime 直接切成 ascend:
[plugins.cri.containerd]
snapshotter = "overlayfs"
default_runtime_name = "ascend"
[plugins.cri.containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
[plugins.cri.containerd.runtimes.ascend]
runtime_type = "io.containerd.runc.v2"
[plugins.cri.containerd.runtimes.ascend.options]
BinaryName = "/usr/local/Ascend/Ascend-Docker-Runtime/ascend-docker-runtime"
SystemdCgroup = false
写到 /var/lib/rancher/k3s/agent/etc/containerd/config.toml.tmpl,然后 systemctl restart k3s(worker 是 k3s-agent)生效。
七、目标机:导入镜像并重打标签(04-import-images.sh)
把第二阶段导出的 tar 导进 K3s 自带的 containerd:
# Master 节点
for f in images/master-*.tar; do
k3s ctr -n k8s.io images import "$f"
done
# Worker 节点导 worker-*.tar,同理
打标签这步不能省。MindCluster 的 yaml 里 image 写法五花八门:volcanosh/vc-scheduler:v1.9.0、docker.io/volcanosh/...、docker.io/library/... 都有。在线环境无所谓,registry 会兜底;离线环境 yaml 引用哪个名字,本地 containerd 里就必须存在哪个名字。脚本把每个镜像按三种前缀各 tag 了一遍:
k3s ctr -n k8s.io images tag \
swr.cn-south-1.myhuaweicloud.com/ascendhub/vc-scheduler:v1.9.0-v26.0.0 \
volcanosh/vc-scheduler:v1.9.0
k3s ctr -n k8s.io images tag \
swr.cn-south-1.myhuaweicloud.com/ascendhub/vc-scheduler:v1.9.0-v26.0.0 \
docker.io/volcanosh/vc-scheduler:v1.9.0
k3s ctr -n k8s.io images tag \
swr.cn-south-1.myhuaweicloud.com/ascendhub/vc-scheduler:v1.9.0-v26.0.0 \
docker.io/library/volcanosh/vc-scheduler:v1.9.0
不加这步,Pod 全部卡 ImagePullBackOff,日志里只有一句 pull access denied,非常误导。
八、目标机:创建用户与日志目录(05-create-user-dirs.sh)
MindCluster 组件按华为规范跑在专用用户下:
useradd -d /home/hwMindX -u 9000 -m -s /sbin/nologin hwMindX
usermod -a -G HwHiAiUser hwMindX # HwHiAiUser 由 NPU 驱动创建
日志目录按节点角色创建,属主是有讲究的:
- 所有节点(root 属主):
/var/log/mindx-dl/{devicePlugin,noded,npu-exporter} - Master 另加(hwMindX 属主):
ascend-operator、clusterd、volcano-controller、volcano-scheduler
权限统一 750。目录缺失时组件会写日志失败,症状是 Pod 反复重启但 kubectl logs 没内容。
九、Master:部署 MindCluster 组件(06-deploy-mindcluster.sh)
主流程四步:建命名空间 → 解压组件包 → 统一修 imagePullPolicy → 依次 apply。
命名空间两个:mindx-dl(MindCluster 组件)和 cluster-system(clusterd)。解压后先把所有 yaml 的 imagePullPolicy 统一改成 IfNotPresent——离线环境 Always 必挂(每次都去拉 registry),Never 又强依赖镜像名完全匹配,IfNotPresent 两头兼顾:
find "$MC_DIR" -name "*.yaml" -exec grep -l "imagePullPolicy" {} \; | while read yaml; do
sed -i 's/imagePullPolicy: Never/imagePullPolicy: IfNotPresent/g' "$yaml"
sed -i 's/imagePullPolicy: Always/imagePullPolicy: IfNotPresent/g' "$yaml"
done
部署顺序:Volcano → ClusterD → Ascend Operator → Infer Operator → 可选的 NodeD / NPU Exporter / Device Plugin。
这里有个本次部署最大的坑:Volcano 的 zip(Ascend-mindxdl-volcano_26.0.0_linux-aarch64.zip)里同时打包了 volcano-v1.7.0 和 volcano-v1.9.0 两套 yaml。最初的写法是:
VOLCANO_YAML=$(find "$MC_DIR/volcano/extracted" -name "volcano-v*.yaml" -type f | head -1)
find | head -1 按目录序取第一个,拿到的是 v1.7.0——和导入的 v1.9.0 镜像不匹配。修复后版本写法:精确优先 + 版本号兜底:
VOLCANO_VERSION="v1.9.0"
pick_yaml() {
find "$1" -name "$2" -type f 2>/dev/null | sort -V | tail -1
}
VOLCANO_YAML=$(pick_yaml "$MC_DIR/volcano/extracted" "volcano-${VOLCANO_VERSION}.yaml")
if [ -z "$VOLCANO_YAML" ]; then
VOLCANO_YAML=$(pick_yaml "$MC_DIR/volcano/extracted" "volcano-v*.yaml")
fi
kubectl apply -f "$VOLCANO_YAML"
其余组件(clusterd、ascend-operator 等)也统一走 pick_yaml 取最高版本,杜绝同类问题。
十、Master:节点打标签(07-label-nodes.sh)
MindCluster 的 DaemonSet(noded、npu-exporter、device-plugin)靠标签做 nodeSelector,不打标 Pod 一个都起不来:
# Master 侧
kubectl label nodes -l node-role.kubernetes.io/control-plane \
masterselector=dls-master-node --overwrite
# Worker 侧(单节点集群给 master 也补上)
kubectl label nodes -l '!node-role.kubernetes.io/control-plane' \
workerselector=dls-worker-node --overwrite
# 芯片标签
kubectl label nodes -l workerselector=dls-worker-node \
accelerator=huawei-Ascend910 --overwrite
打完等几分钟再查 Pod,DaemonSet 按 label 陆续调度。
十一、Master:验证(08-verify.sh)
验证脚本覆盖七项:节点状态、全量 Pod、volcano-system / mindx-dl / kube-system 三个命名空间、DaemonSet 副本数、异常 Pod 过滤、containerd 镜像核对,最后是NPU 资源检查——device-plugin 正常的标志是节点 capacity 里出现昇腾资源:
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.capacity}{"\n"}{end}' \
| grep -o "huawei.com/ascend[0-9]*[^ ]*"
能看到 huawei.com/ascend910-XX 和 npu-smi info 里的卡数对上,整条链路(驱动 → runtime → device-plugin → K8s 资源模型)才算真正打通。
十二、Worker 节点加入流程
Worker 侧不用动 Master 上的东西,按顺序跑五个脚本即可(worker-join-guide.sh 会把 Master IP 和 token 直接打出来,照抄就行):
01-system-prepare.sh02-install-k3s.sh worker03-configure-containerd-runtime.sh04-import-images.sh worker05-create-user-dirs.sh
全部加入后,回 Master 补 07-label-nodes.sh 和 08-verify.sh。
十三、踩坑速查
| 坑 | 现象 | 解法 |
|---|---|---|
| Volcano zip 内含 v1.7.0 / v1.9.0 两套 yaml | find | head -1 按目录序选中旧版 | 版本变量精确匹配 + sort -V 取最高版本 |
iptables-ebtables 包不存在 | yumdownloader 缺包中断 | 改用 ebtables |
| 麒麟镜像拉不到回退 openEuler | 命令"成功"但 0 个 rpm(源不兼容) | 禁止回退 + 三层空结果校验快速失败 |
| yaml 的 imagePullPolicy 为 Always/Never | 离线拉取失败,Pod 卡 Pending | 统一 sed 成 IfNotPresent |
| yaml 引用的镜像名与导入名不一致 | ImagePullBackOff,pull access denied | 按三种前缀(volcanosh/、docker.io/、docker.io/library/)各 tag 一份 |
| NPU 容器挂不上设备 | 容器内看不到卡 | containerd 默认 runtime 切 ascend(config.toml.tmpl) |
| load average 很高但 CPU 空闲 | 误判机器过载 | 昇腾驱动常驻线程(dev*_sq_task 等)D 状态计入 load,正常现象 |
结语
整套东西固化成脚本之后,重跑成本非常低:新节点入集群就是 01→02→03→04→05 五连,Master 侧组件重部署一个 06 全搞定。离线部署的复杂度全在"名字对得上"和"版本选得对"这两件事上,把这两处用代码钉死,剩下的就是等 Pod 变绿。
以上,简记。





