四机 GPU K3s 集群部署复盘
这篇文章不是按我当时的操作台回放来写的,而是基于 4 台机器的 shell 历史和多段智能体工作记录,反向整理出来的一条“真正把集群跑通”的主线。中间大量重复命令、无关噪音和错误尝试都被我主动删掉了。
适用环境:
k3s v1.34.5+k3s1、containerd v2.1.5-k3s1、多机 NVIDIA GPU 节点。本文重点不是把命令逐条复写,而是把真正影响成败的坑点收拢清楚。
一、集群拓扑
这次一共用了 4 台机器,最终角色和资源如下:
| 角色 | 节点名 | 备注 |
|---|---|---|
| control-plane | 4090-48gx2 |
控制平面 |
| worker-1 | 4090-24gx4 |
4 张 4090 |
| worker-2 | 4090-24gx8 |
8 张 4090 |
| worker-3 | a100-40gx2 |
2 张 A100 |
最终集群状态是:
1 | 4090-24gx4 Ready nvidia.com/gpu=4 |
另外,节点侧排查日志显示这套环境实际跑的是:
k3s v1.34.5+k3s1containerd v2.1.5-k3s1
这一点很关键,因为后面最大的坑就出在 containerd 2.x 的配置方式和旧版本完全不是一回事。
二、先说结论:真正跑通集群的主线
如果只保留最后真正有效、而且和四机 GPU K3s 集群直接相关的路径,这次部署可以概括成 8 步:
如果你只想复现最终结果,先按下面这 8 步走;后面的章节再展开每一步为什么会踩坑。
- 先在每台机器上确认
nvidia-smi正常,并装好nvidia-container-toolkit。 - 用
docker run --gpus all ... nvidia-smi把 GPU runtime 单独验证通。 - 处理代理,把 Docker 和 K3s 的代理分开配置。
- 在控制平面机器安装 K3s Server。
- 取出
/var/lib/rancher/k3s/server/node-token,让 3 台 worker 加入集群。 - 部署
nvidia-device-plugin。 - 发现 GPU 没有正常注册后,不再手改
config.toml.tmpl,而是回到 K3s 原生配置方式。 - 让 K3s 重新生成正确的 containerd 配置,再验证 GPU 调度。
真正难的不是“把 K3s 装上”,而是把 GPU runtime、代理和 containerd 配置一起收敛到一个稳定状态。至于后面把 Kuboard 换成 Headlamp,那是主线完成后的管理面板收尾,我放到文章后半段单独说。
三、先把 GPU 运行时单独打通
虽然 K3s 本身不依赖 Docker,但我这次反而先用 Docker 把 GPU runtime 验证了一遍。这样做的好处很明显:可以先把“驱动问题”和“Kubernetes 问题”切开,避免后面所有问题都堆到 K3s 头上。
几台机器上留下来的主线基本一致:
1 | sudo apt-get update |
然后再用容器做一次最小验证:
1 | docker run --rm --gpus all nvidia/cuda:<cuda-tag> nvidia-smi |
这里的 <cuda-tag> 请替换成 NVIDIA 当前仍可拉取的 CUDA 基础镜像标签。
这一步在历史里非常重要,因为它证明了:
- 显卡驱动大体是正常的。
nvidia-container-toolkit是正常接上的。- 后面如果 K3s 里看不到 GPU,问题大概率就在 containerd/K3s 配置,不在驱动层。
四、代理要分两层配:Docker 一套,K3s 一套
Docker 和 K3s 并不会共享代理配置。
Docker 这一层,我最终是通过 systemd drop-in 方式处理的:
1 | # /etc/systemd/system/docker.service.d/http-proxy.conf |
写完以后必须:
1 | sudo systemctl daemon-reexec |
这里还有一个实际踩坑:有一台机器上,给 Docker 发 SIGHUP 并不能真正把代理切过去,最后只能完整重启 Docker。这一点是从智能体日志里明确确认的。
K3s 这一层也要单独配,比如:
1 | # /etc/systemd/system/k3s.service.d/http-proxy.conf |
Agent 节点则对应:
1 | # /etc/systemd/system/k3s-agent.service.d/http-proxy.conf |
这一段看起来繁琐,但如果不把代理层次分开,后面很容易出现下面这种错觉:
curl能下脚本。- Docker 拉不到镜像。
- K3s 内部的 containerd 又拉不到别的镜像。
看起来像一个问题,其实是三套进程的代理没统一。
五、控制平面和 3 个 Worker 的加入过程
1. 控制平面
控制平面机器的主线很简单:
1 | curl -sfL https://get.k3s.io | INSTALL_K3S_MIRROR=cn sh - |
2. Worker 加入
三台 worker 的入群方式一致,本质都是:
1 | curl -sfL https://get.k3s.io | \ |
把 <control-plane-ip> 和 <REDACTED> 换成控制平面节点地址与实际 node-token 即可。
到这一步为止,K3s 集群本身已经成形,真正的麻烦从 GPU 接入才开始。
六、部署 NVIDIA Device Plugin 后,真正的大坑出现了
部署 device plugin 的动作本身并不复杂:
1 | curl -O https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.14.5/nvidia-device-plugin.yml |
但 apply 之后,历史里马上出现了一串典型排查动作:
1 | kubectl describe node | grep nvidia.com/gpu |
表面现象有几种:
- 节点一直没有
nvidia.com/gpu资源。 nvidia-device-pluginPod 虽然起来了,但日志里是libnvidia-ml.so.1找不到,或者No devices found。- 某些节点甚至变成了
NotReady。 crictl info | grep nvidia看起来像是空的。- 更糟糕时还会刷
failed to find plugin "loopback",说明连 CNI 路径都不对了。
如果只盯着表面看,很容易误以为是:
- 驱动坏了。
nvidia-container-runtime没装好。- K3s 不支持 NVIDIA。
但这三个结论都不对。
七、根因:不要把旧版 containerd 1.x 模板塞给 K3s 1.34
这次最关键的结论,来自几段智能体日志的交叉验证:
- 这套环境实际跑的是
k3s v1.34.5+k3s1 + containerd v2.1.5-k3s1。 - 我手工改过的
/var/lib/rancher/k3s/agent/etc/containerd/config.toml.tmpl用的是旧版 containerd 1.x 的写法。 - 旧模板不但
nvidiaruntime 配置没生效,还把 K3s 自己的 base config 一起覆盖掉了。 - 一旦 base config 被覆盖,K3s 自带的 CNI 路径也丢了,CRI 会回退去读
/etc/cni/net.d,于是节点直接NotReady。
日志里最关键的一句报错是:
1 | Ignoring unknown key in TOML for plugin io.containerd.grpc.v1.cri |
这句话基本等于宣判:这份模板已经不属于当前版本的 containerd 了。
换句话说,这次真正的罪魁祸首不是 GPU 驱动,而是“把旧时代的 containerd 配置硬塞给了新版 K3s”。
八、最终修法:回到 K3s 原生配置,不再手改生成物
真正稳定下来的做法只有一句话:
不要继续手改 K3s 生成的 containerd 配置,应该让 K3s 自己生成。
具体落地上,我最后做了三件事。
1. 先把坏掉的模板挪开
真正应该做的第一步,不是继续修补那份旧模板,而是把它从现场移走。
控制平面和 worker 上都留下了类似备份:
backup-20260326-0935config.toml.tmpl.bak-20260326-1725config.toml.tmpl.bak-20260326-1727
核心动作就是:
- 备份原来的
config.toml.tmpl - 移走这份不兼容的旧模板
- 重启
k3s或k3s-agent - 让 K3s 重新生成正确的
config.toml
这一步的关键不是“删文件”本身,而是把 containerd 的配置生成权交还给 K3s。只要继续手搓旧模板,后面不管是 nvidia runtime 还是 CNI 路径,都会反复被带歪。
2. 用 K3s 原生参数设置默认 runtime
在 worker 节点上,稳定做法是直接给 K3s 配默认 runtime,而不是手搓 containerd 模板。历史里实际用了两种等价方式:
方式一:
1 | # /etc/rancher/k3s/config.yaml |
方式二:
1 | # /etc/systemd/system/k3s-agent.service.d/10-default-runtime.conf |
然后重启:
1 | sudo systemctl restart k3s-agent |
3. 对没有改默认 runtime 的场景,显式写 runtimeClassName: nvidia
控制平面节点的修复记录里,最终是通过让 nvidia-device-plugin DaemonSet 使用:
1 | runtimeClassName: nvidia |
才把 GPU 注册完整拉起来的。
所以我最后的理解是:
- 如果你想让 GPU 节点“默认所有 Pod 都走 NVIDIA runtime”,那就配
default-runtime: nvidia。 - 如果你只想让少数 GPU 工作负载使用 NVIDIA runtime,那就别全局改默认值,而是在 Pod/DaemonSet 里显式写
runtimeClassName: nvidia。
但无论选哪种策略,都不要再去手改旧版 config.toml.tmpl。
九、最终验证:不是“看起来对了”,而是“真的能调度 GPU”
这次最终闭环不是靠猜,而是靠几层验证一起成立:
1. 节点 Ready,GPU 资源出现
1 | k3s kubectl get nodes -o wide |
最终输出就是文章开头那 4 行:
1 | 4090-24gx4 Ready nvidia.com/gpu=4 |
2. crictl info 确认 runtime 和 CNI 都对了
稳定状态下,几台节点的关键信息都变成了:
defaultRuntimeName: "nvidia"或已经存在runtimeHandlers.name: "nvidia"PluginConfDir: /var/lib/rancher/k3s/agent/etc/cni/net.dlastCNILoadStatus: OKNetworkReady: true
这说明不仅 runtime 对了,连之前被带歪的 CNI 也回来了。
3. nvidia-device-plugin 日志恢复正常
历史里的最终日志信号非常一致:
Detected NVML libraryDetected NVML platformRegistered device plugin for 'nvidia.com/gpu' with Kubelet
这才是 GPU 真正被 kubelet 识别的证据。
4. 容器内看到真实 GPU
这一步最硬。
有的节点是直接起了 CUDA 测试容器,有的节点是通过 K3s 的 CRI 拉起容器执行 nvidia-smi。例如 A100 节点最终已经能在容器里看到两张卡:
1 | GPU 0: NVIDIA A100-PCIE-40GB |
4090 节点也有对应的容器级验证,说明这次不是“节点上报了一个数字”,而是 runtime 真能把 GPU 暴露给容器。
十、一个最小 GPU 测试 Pod
如果要做一个干净的集群级验证,我会保留下面这个 YAML。
如果你的节点已经设置了 default-runtime: nvidia,可以去掉 runtimeClassName;如果没有,就保留它。
1 | apiVersion: v1 |
同样,<cuda-tag> 需要替换成当时仍然存在的 CUDA 基础镜像标签。
验证命令:
1 | kubectl apply -f gpu-test.yaml |
如果日志里能看到显卡信息,同时 describe pod 里能看到:
Runtime Class Name: nvidiaLimits: nvidia.com/gpu: 1
那这条 GPU 调度链就算闭环了。
写到这里,其实四机 GPU K3s 集群的主线已经结束了。后面还有一段真实发生过的后续动作:把不健康的 Kuboard 替换成 Headlamp,并把外部访问链路调通。
十一、把 Kuboard 换成 Headlamp
GPU 调度链跑通之后,我又做了一条后续动作:把刚装上的 Kuboard 换成 Headlamp。
这一步不是集群成形的必要条件,但它是当天真实发生过的一段完整过程,而且在操作层面也踩了一个挺有代表性的坑。
当时的现场是:
kuboard在kuboard命名空间里。kuboard-etcd已经CrashLoopBackOff。kuboard-v3也不健康。- 集群里已经有 K3s 自带的
Traefik,并且80/443通过LoadBalancer绑到了各节点 IP 上。
Traefik 当时的对外端口是:
1 | 80 -> 31944 |
所以最终没有继续给 Headlamp 再单开一套 NodePort,而是决定直接挂在现有 Traefik 后面。
实际安装时,落的是一份定制过的清单,不是直接照抄官方示例。核心资源包括:
headlamp命名空间headlampServiceAccountheadlamp-adminClusterRoleBindingheadlampDeploymentheadlampClusterIP Service- Traefik 的 Basic Auth Secret 和 Middleware
headlampIngress
部署思路大概是这样:
1 | apiVersion: v1 |
入口则通过 Traefik Ingress 暴露,并先加了一层 Basic Auth:
1 | apiVersion: traefik.io/v1alpha1 |
Headlamp 部署完成以后,Kuboard 这边就被清理掉了,主动作是:
1 | kubectl delete namespace kuboard |
所以真实顺序不是“同时装两个面板”,而是:
- 先确认
Kuboard已经不健康。 - 再切到
Headlamp。 - 最后删掉
Kuboard残留。
十二、Headlamp 外部访问真正的坑:Basic Auth 还不够
Headlamp 装起来之后,事情其实还没完。
表面上入口已经通了:
- 访问
http://?.?.?.?:31944/会先弹 Traefik 的 Basic Auth。 - 登录后也能进到
Headlamp页面。
但智能体继续排查时发现,浏览器还是会被带到 /c/main/token,要求再贴一次 Kubernetes Token。
根因不是 31944 端口本身有问题,而是:
- Traefik 的
Basic Auth只解决了“进门”问题。 Headlamp在这种服务模式下,前端访问/clusters/main/...时还是要带 Kubernetes 凭据。- 所以它后端的
testAuth链路没有真正打通。
为了把这个问题讲清楚,当时直接拿 selfsubjectrulesreviews 这个接口做了真实验证。
先测经由 Traefik 的请求,只带 Basic Auth:
1 | curl -u '<basic-auth>' \ |
第一次返回的是:
1 | 401 Unauthorized |
但换一个角度,直接绕过 Traefik,打集群内 Headlamp Service,并显式带上 headlamp ServiceAccount 的 Bearer Token,结果却是:
1 | 201 |
这说明:
headlamp这个 ServiceAccount 的 RBAC 没问题。- 真正的问题在 Traefik 入口层。
- 更具体地说,是
Basic和Bearer都占用了同一个Authorization头,没法同时带给后端。
最后的定版方案是:
- 浏览器侧仍然只走 Traefik Basic Auth。
- Traefik 再通过第二个 Middleware,给转发到
Headlamp的请求注入一个集群内 Bearer Token。
对应动作是先创建一个供 Traefik 使用的 Token Secret:
1 | kubectl apply -f - <<EOF |
然后创建新的 Traefik Middleware:
1 | apiVersion: traefik.io/v1alpha1 |
最后把 Ingress 改成串两个中间件:
1 | headlamp-headlamp-auth@kubernetescrd,headlamp-headlamp-k8s-authz@kubernetescrd |
这一步做完以后,再测一次:
- 未认证访问
http://?.?.?.?:31944/,返回401 Unauthorized - 只带 Basic Auth 去打
selfsubjectrulesreviews,返回201
这时从服务端角度,Headlamp 的登录链路才算真正闭环。
这一段最值钱的结论是:
- 只给
Headlamp前面套一层 Basic Auth,并不等于它已经能免 Token 登录。 - 如果
Headlamp跑在 in-cluster 模式下,前端和后端的鉴权链要一起考虑。 - 这次最终可用的方案,是“Traefik Basic Auth + Traefik 注入 Bearer Token”。
十三、这次部署里最值钱的 4 个经验
1. K3s 里的 GPU 问题,先和驱动问题拆开
先跑:
1 | nvidia-smi |
这样后面才能明确知道是 Kubernetes 配置问题,不是驱动问题。
2. Docker 代理和 K3s 代理是两套东西
不要以为一个地方能 curl,另一个地方就一定能拉镜像。
3. K3s 1.34 + containerd 2.1 下,别再照抄旧版 config.toml.tmpl
这是这次最大的坑,也是最容易把节点直接搞 NotReady 的坑。真正稳妥的做法,是让 K3s 自己重新生成 containerd 配置,再通过 default-runtime 或 runtimeClassName 去控制运行时。
4. 镜像拉取失败不等于 GPU runtime 失败
历史里最后还出现过 docker.io 匿名 token EOF,这属于镜像仓库访问问题,不属于 GPU runtime 问题。排查时一定要把这两件事分开。
十四、如果让我重来一次,我会直接这样干
最后给自己留一版“无噪音复现顺序”:
- 验证
nvidia-smi。 - 安装
nvidia-container-toolkit,用 Docker 做一次--gpus all验证。 - 配好 Docker 代理和 K3s 代理。
- 安装 K3s Server。
- 加入 3 台 Worker。
- 不改 containerd 模板,直接用 K3s 原生方式配置
default-runtime: nvidia或工作负载runtimeClassName: nvidia。 - 部署
nvidia-device-plugin。 - 用一个最小 GPU Pod 做最终验证。
- 最后再装
Headlamp这类面板,并单独验证它的鉴权链。
这套顺序的核心价值不是“命令更少”,而是 每一步都能把问题域隔离开。一旦哪一步挂了,基本能立刻知道应该往哪一层查。
注:文中的
nvidia/cuda示例只是在表达“找一个可用的 CUDA 基础镜像来跑nvidia-smi”这个动作。实际操作时,请把<cuda-tag>换成 NVIDIA 当前仍可拉取的镜像标签,不要直接照抄旧值。