Skip to content

四机 GPU K3s 集群部署复盘

四机 GPU K3s 集群部署复盘

这篇文章不是按我当时的操作台回放来写的,而是基于 4 台机器的 shell 历史和多段智能体工作记录,反向整理出来的一条“真正把集群跑通”的主线。中间大量重复命令、无关噪音和错误尝试都被我主动删掉了。

适用环境:k3s v1.34.5+k3s1containerd 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
2
3
4
4090-24gx4 Ready nvidia.com/gpu=4
4090-24gx8 Ready nvidia.com/gpu=8
4090-48gx2 Ready nvidia.com/gpu=2
a100-40gx2 Ready nvidia.com/gpu=2

另外,节点侧排查日志显示这套环境实际跑的是:

  • k3s v1.34.5+k3s1
  • containerd v2.1.5-k3s1

这一点很关键,因为后面最大的坑就出在 containerd 2.x 的配置方式和旧版本完全不是一回事。

二、先说结论:真正跑通集群的主线

如果只保留最后真正有效、而且和四机 GPU K3s 集群直接相关的路径,这次部署可以概括成 8 步:

如果你只想复现最终结果,先按下面这 8 步走;后面的章节再展开每一步为什么会踩坑。

  1. 先在每台机器上确认 nvidia-smi 正常,并装好 nvidia-container-toolkit
  2. docker run --gpus all ... nvidia-smi 把 GPU runtime 单独验证通。
  3. 处理代理,把 Docker 和 K3s 的代理分开配置。
  4. 在控制平面机器安装 K3s Server。
  5. 取出 /var/lib/rancher/k3s/server/node-token,让 3 台 worker 加入集群。
  6. 部署 nvidia-device-plugin
  7. 发现 GPU 没有正常注册后,不再手改 config.toml.tmpl,而是回到 K3s 原生配置方式。
  8. 让 K3s 重新生成正确的 containerd 配置,再验证 GPU 调度。

真正难的不是“把 K3s 装上”,而是把 GPU runtime、代理和 containerd 配置一起收敛到一个稳定状态。至于后面把 Kuboard 换成 Headlamp,那是主线完成后的管理面板收尾,我放到文章后半段单独说。

三、先把 GPU 运行时单独打通

虽然 K3s 本身不依赖 Docker,但我这次反而先用 Docker 把 GPU runtime 验证了一遍。这样做的好处很明显:可以先把“驱动问题”和“Kubernetes 问题”切开,避免后面所有问题都堆到 K3s 头上。

几台机器上留下来的主线基本一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sudo apt-get update
sudo apt-get install -y --no-install-recommends curl gnupg2

curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \
sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg

curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list >/dev/null

sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

然后再用容器做一次最小验证:

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
2
3
4
# /etc/systemd/system/docker.service.d/http-proxy.conf
[Service]
Environment="HTTP_PROXY=http://<proxy-host>:<port>"
Environment="HTTPS_PROXY=http://<proxy-host>:<port>"

写完以后必须:

1
2
3
sudo systemctl daemon-reexec
sudo systemctl daemon-reload
sudo systemctl restart docker

这里还有一个实际踩坑:有一台机器上,给 Docker 发 SIGHUP 并不能真正把代理切过去,最后只能完整重启 Docker。这一点是从智能体日志里明确确认的。

K3s 这一层也要单独配,比如:

1
2
3
4
# /etc/systemd/system/k3s.service.d/http-proxy.conf
[Service]
Environment="HTTP_PROXY=http://<proxy-host>:<port>"
Environment="HTTPS_PROXY=http://<proxy-host>:<port>"

Agent 节点则对应:

1
2
3
4
# /etc/systemd/system/k3s-agent.service.d/http-proxy.conf
[Service]
Environment="HTTP_PROXY=http://<proxy-host>:<port>"
Environment="HTTPS_PROXY=http://<proxy-host>:<port>"

这一段看起来繁琐,但如果不把代理层次分开,后面很容易出现下面这种错觉:

  • curl 能下脚本。
  • Docker 拉不到镜像。
  • K3s 内部的 containerd 又拉不到别的镜像。

看起来像一个问题,其实是三套进程的代理没统一。

五、控制平面和 3 个 Worker 的加入过程

1. 控制平面

控制平面机器的主线很简单:

1
2
3
curl -sfL https://get.k3s.io | INSTALL_K3S_MIRROR=cn sh -
sudo cat /var/lib/rancher/k3s/server/node-token
kubectl get nodes -o wide

2. Worker 加入

三台 worker 的入群方式一致,本质都是:

1
2
3
4
5
curl -sfL https://get.k3s.io | \
K3S_URL=https://<control-plane-ip>:6443 \
K3S_TOKEN=<REDACTED> \
INSTALL_K3S_MIRROR=cn \
sh -

<control-plane-ip><REDACTED> 换成控制平面节点地址与实际 node-token 即可。

到这一步为止,K3s 集群本身已经成形,真正的麻烦从 GPU 接入才开始。

六、部署 NVIDIA Device Plugin 后,真正的大坑出现了

部署 device plugin 的动作本身并不复杂:

1
2
curl -O https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.14.5/nvidia-device-plugin.yml
kubectl apply -f nvidia-device-plugin.yml

但 apply 之后,历史里马上出现了一串典型排查动作:

1
2
3
4
5
kubectl describe node | grep nvidia.com/gpu
kubectl get pods -n kube-system -o wide | grep nvidia
kubectl logs -n kube-system -l name=nvidia-device-plugin-daemonset
which nvidia-container-runtime
sudo crictl info | grep nvidia

表面现象有几种:

  • 节点一直没有 nvidia.com/gpu 资源。
  • nvidia-device-plugin Pod 虽然起来了,但日志里是 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 的写法。
  • 旧模板不但 nvidia runtime 配置没生效,还把 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-0935
  • config.toml.tmpl.bak-20260326-1725
  • config.toml.tmpl.bak-20260326-1727

核心动作就是:

  • 备份原来的 config.toml.tmpl
  • 移走这份不兼容的旧模板
  • 重启 k3sk3s-agent
  • 让 K3s 重新生成正确的 config.toml

这一步的关键不是“删文件”本身,而是把 containerd 的配置生成权交还给 K3s。只要继续手搓旧模板,后面不管是 nvidia runtime 还是 CNI 路径,都会反复被带歪。

2. 用 K3s 原生参数设置默认 runtime

在 worker 节点上,稳定做法是直接给 K3s 配默认 runtime,而不是手搓 containerd 模板。历史里实际用了两种等价方式:

方式一:

1
2
# /etc/rancher/k3s/config.yaml
default-runtime: nvidia

方式二:

1
2
3
# /etc/systemd/system/k3s-agent.service.d/10-default-runtime.conf
[Service]
Environment="K3S_AGENT_ARGS=--default-runtime=nvidia"

然后重启:

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
2
k3s kubectl get nodes -o wide
k3s kubectl get node -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.allocatable.nvidia\.com/gpu}{"\t"}{.status.conditions[?(@.type=="Ready")].status}{"\n"}{end}'

最终输出就是文章开头那 4 行:

1
2
3
4
4090-24gx4 Ready nvidia.com/gpu=4
4090-24gx8 Ready nvidia.com/gpu=8
4090-48gx2 Ready nvidia.com/gpu=2
a100-40gx2 Ready nvidia.com/gpu=2

2. crictl info 确认 runtime 和 CNI 都对了

稳定状态下,几台节点的关键信息都变成了:

  • defaultRuntimeName: "nvidia" 或已经存在 runtimeHandlers.name: "nvidia"
  • PluginConfDir: /var/lib/rancher/k3s/agent/etc/cni/net.d
  • lastCNILoadStatus: OK
  • NetworkReady: true

这说明不仅 runtime 对了,连之前被带歪的 CNI 也回来了。

3. nvidia-device-plugin 日志恢复正常

历史里的最终日志信号非常一致:

  • Detected NVML library
  • Detected NVML platform
  • Registered device plugin for 'nvidia.com/gpu' with Kubelet

这才是 GPU 真正被 kubelet 识别的证据。

4. 容器内看到真实 GPU

这一步最硬。

有的节点是直接起了 CUDA 测试容器,有的节点是通过 K3s 的 CRI 拉起容器执行 nvidia-smi。例如 A100 节点最终已经能在容器里看到两张卡:

1
2
GPU 0: NVIDIA A100-PCIE-40GB
GPU 1: NVIDIA A100-PCIE-40GB

4090 节点也有对应的容器级验证,说明这次不是“节点上报了一个数字”,而是 runtime 真能把 GPU 暴露给容器。

十、一个最小 GPU 测试 Pod

如果要做一个干净的集群级验证,我会保留下面这个 YAML。

如果你的节点已经设置了 default-runtime: nvidia,可以去掉 runtimeClassName;如果没有,就保留它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: Pod
metadata:
name: gpu-test
spec:
restartPolicy: Never
runtimeClassName: nvidia
containers:
- name: cuda
image: nvidia/cuda:<cuda-tag>
command: ["nvidia-smi"]
resources:
limits:
nvidia.com/gpu: 1

同样,<cuda-tag> 需要替换成当时仍然存在的 CUDA 基础镜像标签。

验证命令:

1
2
3
kubectl apply -f gpu-test.yaml
kubectl logs gpu-test
kubectl describe pod gpu-test

如果日志里能看到显卡信息,同时 describe pod 里能看到:

  • Runtime Class Name: nvidia
  • Limits: nvidia.com/gpu: 1

那这条 GPU 调度链就算闭环了。

写到这里,其实四机 GPU K3s 集群的主线已经结束了。后面还有一段真实发生过的后续动作:把不健康的 Kuboard 替换成 Headlamp,并把外部访问链路调通。

十一、把 Kuboard 换成 Headlamp

GPU 调度链跑通之后,我又做了一条后续动作:把刚装上的 Kuboard 换成 Headlamp

这一步不是集群成形的必要条件,但它是当天真实发生过的一段完整过程,而且在操作层面也踩了一个挺有代表性的坑。

当时的现场是:

  • kuboardkuboard 命名空间里。
  • kuboard-etcd 已经 CrashLoopBackOff
  • kuboard-v3 也不健康。
  • 集群里已经有 K3s 自带的 Traefik,并且 80/443 通过 LoadBalancer 绑到了各节点 IP 上。

Traefik 当时的对外端口是:

1
2
80  -> 31944
443 -> 30760

所以最终没有继续给 Headlamp 再单开一套 NodePort,而是决定直接挂在现有 Traefik 后面。

实际安装时,落的是一份定制过的清单,不是直接照抄官方示例。核心资源包括:

  • headlamp 命名空间
  • headlamp ServiceAccount
  • headlamp-admin ClusterRoleBinding
  • headlamp Deployment
  • headlamp ClusterIP Service
  • Traefik 的 Basic Auth Secret 和 Middleware
  • headlamp Ingress

部署思路大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
apiVersion: v1
kind: Namespace
metadata:
name: headlamp
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: headlamp
namespace: headlamp
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: headlamp-admin
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- kind: ServiceAccount
name: headlamp
namespace: headlamp
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: headlamp
namespace: headlamp
spec:
replicas: 1
template:
spec:
serviceAccountName: headlamp
containers:
- name: headlamp
image: ghcr.io/headlamp-k8s/headlamp:v0.41.0
args:
- -in-cluster
- -in-cluster-context-name=main
- -plugins-dir=/headlamp/plugins
- -session-ttl=86400
---
apiVersion: v1
kind: Service
metadata:
name: headlamp
namespace: headlamp
spec:
type: ClusterIP

入口则通过 Traefik Ingress 暴露,并先加了一层 Basic Auth:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: headlamp-auth
namespace: headlamp
spec:
basicAuth:
secret: headlamp-basic-auth-users
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: headlamp
namespace: headlamp
annotations:
traefik.ingress.kubernetes.io/router.middlewares: headlamp-headlamp-auth@kubernetescrd
spec:
ingressClassName: traefik
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: headlamp
port:
number: 80

Headlamp 部署完成以后,Kuboard 这边就被清理掉了,主动作是:

1
2
kubectl delete namespace kuboard
kubectl delete clusterrolebinding kuboard-boostrap-crb

所以真实顺序不是“同时装两个面板”,而是:

  1. 先确认 Kuboard 已经不健康。
  2. 再切到 Headlamp
  3. 最后删掉 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
2
3
4
curl -u '<basic-auth>' \
-H 'Content-Type: application/json' \
-d '{"spec":{"namespace":"default"}}' \
http://?.?.?.?:31944/clusters/main/apis/authorization.k8s.io/v1/selfsubjectrulesreviews

第一次返回的是:

1
401 Unauthorized

但换一个角度,直接绕过 Traefik,打集群内 Headlamp Service,并显式带上 headlamp ServiceAccount 的 Bearer Token,结果却是:

1
201

这说明:

  • headlamp 这个 ServiceAccount 的 RBAC 没问题。
  • 真正的问题在 Traefik 入口层。
  • 更具体地说,是 BasicBearer 都占用了同一个 Authorization 头,没法同时带给后端。

最后的定版方案是:

  1. 浏览器侧仍然只走 Traefik Basic Auth。
  2. Traefik 再通过第二个 Middleware,给转发到 Headlamp 的请求注入一个集群内 Bearer Token。

对应动作是先创建一个供 Traefik 使用的 Token Secret:

1
2
3
4
5
6
7
8
9
10
kubectl apply -f - <<EOF
apiVersion: v1
kind: Secret
metadata:
name: headlamp-proxy-token
namespace: headlamp
annotations:
kubernetes.io/service-account.name: "headlamp"
type: kubernetes.io/service-account-token
EOF

然后创建新的 Traefik Middleware:

1
2
3
4
5
6
7
8
9
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: headlamp-k8s-authz
namespace: headlamp
spec:
headers:
customRequestHeaders:
Authorization: "Bearer <REDACTED>"

最后把 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
2
nvidia-smi
docker run --rm --gpus all nvidia/cuda:<cuda-tag> nvidia-smi

这样后面才能明确知道是 Kubernetes 配置问题,不是驱动问题。

2. Docker 代理和 K3s 代理是两套东西

不要以为一个地方能 curl,另一个地方就一定能拉镜像。

3. K3s 1.34 + containerd 2.1 下,别再照抄旧版 config.toml.tmpl

这是这次最大的坑,也是最容易把节点直接搞 NotReady 的坑。真正稳妥的做法,是让 K3s 自己重新生成 containerd 配置,再通过 default-runtimeruntimeClassName 去控制运行时。

4. 镜像拉取失败不等于 GPU runtime 失败

历史里最后还出现过 docker.io 匿名 token EOF,这属于镜像仓库访问问题,不属于 GPU runtime 问题。排查时一定要把这两件事分开。

十四、如果让我重来一次,我会直接这样干

最后给自己留一版“无噪音复现顺序”:

  1. 验证 nvidia-smi
  2. 安装 nvidia-container-toolkit,用 Docker 做一次 --gpus all 验证。
  3. 配好 Docker 代理和 K3s 代理。
  4. 安装 K3s Server。
  5. 加入 3 台 Worker。
  6. 不改 containerd 模板,直接用 K3s 原生方式配置 default-runtime: nvidia 或工作负载 runtimeClassName: nvidia
  7. 部署 nvidia-device-plugin
  8. 用一个最小 GPU Pod 做最终验证。
  9. 最后再装 Headlamp 这类面板,并单独验证它的鉴权链。

这套顺序的核心价值不是“命令更少”,而是 每一步都能把问题域隔离开。一旦哪一步挂了,基本能立刻知道应该往哪一层查。

注:文中的 nvidia/cuda 示例只是在表达“找一个可用的 CUDA 基础镜像来跑 nvidia-smi”这个动作。实际操作时,请把 <cuda-tag> 换成 NVIDIA 当前仍可拉取的镜像标签,不要直接照抄旧值。

About this Post

This post is written by KaranocaVe.

#K3s #Kubernetes #GPU #NVIDIA #containerd #Traefik #Headlamp