Kubernetes 生成式 AI 系列写到第五篇,前面我们聊了不少概念性的东西,集群架构、控制器、服务发现这些,很多朋友反馈说“看懂了,但没感觉”。这次换个节奏,直接上手:用 kubeadm v1.26.0 把集群拉起来,装好 GPU 支持,再往里面部署一个能跑的生成式 AI 推理服务。整条链路走完,你对 Kubernetes 里的“调度”“资源限制”“探针”“服务暴露”这些词才算真正建立手感。这篇内容适合已经跑过 kubectl 基础命令、但对整套生产链路还比较模糊的同学,照着一步步做,大概率能复现出一套可以继续折腾的 AI 推理环境。
1. kubeadm v1.26.0 初始化:从 preflight 到控制面就绪
先交代一下我这边的环境:三台 Ubuntu 22.04 虚拟机,配置是 8 核 16G 内存,其中一台做 master 节点,另外两台是 worker。生成式 AI 模型推理对 GPU 有要求,所以其中一台 worker 节点额外挂载了 NVIDIA T4 显卡。操作系统层面没有用桌面版,服务器版更干净,少了很多干扰项。
1.1 初始化前的环境准备:关闭 swap、配置内核参数、安装 containerd
kubeadm 初始化时有一个不能绕过的坑就是 swap。kubelet 在工作时会根据节点资源做调度决策,如果系统交换内存还在启用状态,kubelet 对内存的判断就容易失真,所以 Kubernetes 从 1.8 开始就强制要求关闭 swap。用swapoff -a关掉当前的交换分区之后,还得改/etc/fstab文件,把里面关于 swap 的那一行注释掉,否则重启之后又会挂回来。
然后是内核参数。容器网络需要 ip 转发,这跟传统虚拟机里的配置是一个道理。修改/etc/sysctl.d/k8s.conf,写入:
net.bridge.bridge-nf-call-iptables = 1 net.ipv4.ip_forward = 1 vm.swappiness = 0这里有个细节要提醒一下,net.bridge.bridge-nf-call-iptables这个参数依赖br_netfilter内核模块,如果模块没加载会直接报错。可以用modprobe br_netfilter手动加载,同时写进/etc/modules-load.d/k8s.conf,确保开机自动加载。
接下来是容器运行时。Kubernetes 从 1.24 版本之后就不再直接使用 Docker 作为运行时了,而是通过 CRI(Container Runtime Interface)对接 containerd。以前那种“装 Docker 就能跑 K8s”的思维要改过来。装 containerd 的时候直接用 apt 源安装就行,但如果不想跟发行版的包管理产生冲突,我建议直接从官方下载二进制包解压到/usr/local,这样后续版本升级更方便。
containerd 装好后,默认配置里SystemdCgroup是 false,这里一定要改成 true。为什么?Kubernetes 在统计 cgroup 资源时,如果容器运行时和 kubelet 使用的 cgroup 驱动不一致,Node 会一直处于 NotReady 状态,你查半天日志可能都找不到直接原因。用containerd config default生成默认配置,把SystemdCgroup = true勾上,然后重启 containerd 服务。
1.2 kubeadm init 的踩坑记录:preflight 检查失败怎么办
安装 kubeadm、kubelet、kubectl 这三个组件时,版本要锁定一致。我这边用的是当前应用最广泛的 v1.26.0 版本。加 apt 源的时候注意一下,Kubernetes 官方源在国内网络环境下经常拉取超时,解决方案一般是配置加速镜像,这里就不展开说了,总之你自己想办法让 apt 能正常拉到安装包。
初始化命令我用了下面这一条:
kubeadm init \ --apiserver-advertise-address=192.168.1.10 \ --image-repository=registry.cn-hangzhou.aliyuncs.com/google_containers \ --kubernetes-version=v1.26.0 \ --pod-network-cidr=172.16.0.0/16这里--pod-network-cidr必须指定,而且这个网段尽量避开物理网络网段,后面 CNI 插件会基于这个网段分配 Pod IP。如果这个参数不填,后面装网络插件的时候会非常被动,Calico 这类插件的默认网段是 192.168.0.0/16,很可能跟你现有网络冲突,到时候改起来比现在多花三倍时间。
跑preflight检查时我遇到了几个问题。第一个是[ERROR FileContent--proc-sys-net-bridge-bridge-nf-call-ip6tables],这个其实就是前面说的内核参数没配好,把 ip6tables 那一行也补上就行。第二个是[ERROR ImagePull]: failed to pull image registry.k8s.io/kube-apiserver:v1.26.0,国内网络直连官方镜像仓库确实不通,这里用--image-repository指定国内镜像仓库就解决了。
初始化成功之后会输出一串 token 和 join 命令,这个记得保存下来,后面 worker 节点入群要用。如果没有保存,也可以在 master 上用kubeadm token create --print-join-command重新生成。
控制面的几个核心 Pod 是在kube-system命名空间下,可以用kubectl get pods -n kube-system查看状态。刚初始化完,有些 Pod 会一直是 Pending,这属于正常现象,因为 Pod 之间需要网络互通,而网络插件(CNI)还没装。这里顺便说一下,如果碰到kube-apiserver一直 CrashLoopBackOff,多半是 etcd 或者证书的问题,可以先看日志定方向,不要盲目去重启节点。
2. CNI 插件的选择与安装:网络层决定上层体验
控制面起来了但网络层是空的,这个时候集群里的 Pod 互相之间是没有办法通信的。CNI 插件就是来解决这个问题的。市面上的选择很多,Flannel、Calico、Cilium、Weave Net,每个都有自己的侧重点。
2.1 为什么选 Calico 而不是 Flannel 或 Cilium
直接说结论:我选了 Calico。原因有三个:
- 第一个是 BGP 路由模式。Calico 默认用 BGP 直接在节点间交换路由,数据包不走叠加隧道,延迟和吞吐表现都更好,这对 AI 推理这类需要高频网络交互的场景很重要。
- 第二个是网络策略。Calico 原生支持 Kubernetes NetworkPolicy,你可以在上面做精细的 Pod 间访问控制,比如只允许某个命名空间的 Pod 访问 GPU 节点的推理服务,这个在安全巡检时很有用。Flannel 虽然也支持 NetworkPolicy,但实现上要额外套一层,不如 Calico 直接。
- 第三个是生态成熟度。Calico 在云原生基金会里属于毕业项目,文档完善,社区踩坑案例也很多,遇到问题更容易找到解决方案。
当然 Cilium 现在也很火,eBPF 技术带来的性能优势很直观,但引入 eBPF 意味着内核版本和 kube-proxy 模式都要做一些调整,对于这个系列来说步子太大了。先把 Calico 的方案跑通,后面想折腾 Cilium 也不迟。
2.2 Calico 安装与 Pod 网段冲突的修复
Calico 的安装方式很简单,直接应用官方清单:
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.25.0/manifests/calico.yaml但在应用之前,有一个坑必须提前处理。Calico 默认的 Pod 网段是 192.168.0.0/16,如果你在 kubeadm init 时用了--pod-network-cidr=172.16.0.0/16,那两者网段就不一致,Calico 的 CNI 配置会错误地认为 Pod 网段是 192.168.0.0/16,导致 IP 分配失败,Pod 一直处于 ContainerCreating。
解决方法是先把清单下载下来,编辑里面的CALICO_IPV4POOL_CIDR环境变量,改成跟 kubeadm 一致的网段:
- name: CALICO_IPV4POOL_CIDR value: "172.16.0.0/16"再一个容易忽略的坑是网卡自动选择。Calico 在启动时会尝试自动探测主网卡 IP,如果机器上有多个网卡(比如虚拟机的 eth0 和 docker0),它可能选错。这种情况下可以在 calico-node 的环境变量里显式指定:
- name: IP_AUTODETECTION_METHOD value: "interface=eth.*"装完之后验证一下节点是否 Ready:
kubectl get nodes如果节点状态还是 NotReady,用kubectl describe node看条件里的原因,通常是 calico-node Pod 有问题。还有一个方法是看/var/log/calico/cni/目录下的日志,里面会写明到底是什么环节没跑通。
3. 让 Kubernetes“看见”GPU:NVIDIA 设备插件部署实录
生成式 AI 推理服务跑在 CPU 上不是不行,但速度只能作为功能验证使用。真正想体验完整的推理链路,还是得有 GPU。Kubernetes 对 GPU 的支持不是天然的,它需要一层“设备插件”来把宿主机上的 GPU 资源上报给 kubelet,这样调度器才能把 Pod 分配到有 GPU 的节点上。
3.1 从宿主机驱动到容器运行时:GPU 支持的三层准备
第一步,宿主机驱动。NVIDIA 驱动版本和 CUDA 版本之间存在兼容关系,你只需要记住一个原则:显卡驱动的安装版本必须以你要跑的 CUDA 镜像为准。比如要用 CUDA 12.0 的镜像,那就至少得装 525 版本以上的驱动。可以用nvidia-smi命令确认驱动是否正常加载。驱动装完后重启一下机器,让内核模块顺利加载。
第二步,配置容器运行时。这步最繁琐,却最容易被忽略。containerd 默认是不带 NVIDIA 容器运行时插件的,需要通过配置指定nvidia-container-runtime作为后端的 OCI runtime。修改/etc/containerd/config.toml:
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.nvidia] privileged_without_host_devices = false runtime_engine = "" runtime_root = "" runtime_type = "io.containerd.runc.v2" [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.nvidia.options] BinaryName = "/usr/bin/nvidia-container-runtime"然后在每个使用 GPU 的容器配置里,runtimeClassName 指向这个 runtime。等一下,这里有个前置步骤:在 containerd 里注册这个 runtime 之后,kubelet 才能通过 CRI 调用它。如果这一步不生效,即使设备插件上报了 GPU,运行容器时也会报failed to run k8s.io/containerd/runc/v2: runc: unknown runtime: nvidia。
第三步,安装 nvidia-container-toolkit。这是一个工具集,它能让容器访问 GPU 驱动和设备节点。装完后要重新生成 containerd 配置:
nvidia-ctk runtime update --runtime-name=nvidia这个命令会自动帮你把上面的 runtime 配置插入到 containerd 的配置文件里,省了很多手动编辑的功夫。不过我还是建议手动检查一遍配置,毕竟这个工具生成的路径可能跟你手动指定的不完全一样。
3.2 Device Plugin 安装与 GPU 可调度验证
宿主机和运行时层面的准备做好之后,接下来就是让 Kubernetes 感知 GPU 了。NVIDIA 官方提供了对应的设备插件,部署方式同样是一条 kubectl apply:
kubectl apply -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.14.0/nvidia-device-plugin.yaml这里有几个字段值得关注。设备插件默认在启动时自动检测 GPU 类型和数量,并把它们作为扩展资源注册到节点上。你可以用kubectl describe node <node-name>来查看节点的可分配资源,正常情况下会出现:
nvidia.com/gpu: 1如果没有出现这个字段,排查顺序是:设备插件 Pod 是否 Running → 插件日志是否有报错 → 宿主机 nvidia-smi 是否正常。日志是很重要的线索,Device Plugin 跟 kubelet 之间走的是 Unix Socket,如果 socket 目录权限不对,插件会一直连接失败,但你从 Pod 状态可能根本看不出来。
验证 GPU 能不能被正常调度,跑一个简单的测试 Pod:
apiVersion: v1 kind: Pod metadata: name: gpu-test spec: restartPolicy: Never containers: - name: cuda-test image: nvidia/cuda:12.1.0-base-ubuntu22.04 command: ["nvidia-smi"] resources: limits: nvidia.com/gpu: 1Pod 运行后查看日志,能正常输出显卡信息就说明链路通了。这个测试 Pod 建议保留,后续排查 GPU 相关问题会经常用到。
4. 部署生成式 AI 推理服务:一份可以抄作业的 Kubernetes 清单
集群跑起来了,GPU 也能被调度了,接下来就是这篇文章的核心戏——在 Kubernetes 上部署一个生成式 AI 推理服务。这里以文本生成模型为例,部署一套兼容 OpenAI API 的推理服务,后面接任何客户端都很方便。
4.1 推理框架选型与镜像确定
先解决“用什么跑模型”的问题。目前社区里主流的选择是 vLLM,它在吞吐和显存管理上做得非常极致,底层用 PagedAttention,能把显存碎片有效降低。TensorRT-LLM 性能更强,但构建流程复杂,而且只针对 NVIDIA 硬件优化。HuggingFace TGI 也值得关注,它在生产环境部署上有自己的优势。不过为了快速把链路跑通,我最后还是选了 vLLM,理由很简单:暴露出来的 OpenAI 兼容接口可以直接被现有生态调用,不用额外写适配层。
镜像方面可以直接用 vLLM 官方提供的版本,也可以自己构建。这里给一个 Dockerfile 参考:
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04 RUN apt-get update && apt-get install -y python3-pip git RUN pip install vllm==0.2.7 WORKDIR /app COPY entrypoint.sh /app/ ENTRYPOINT ["/app/entrypoint.sh"]entrypoint.sh里只需一行核心命令:
python3 -m vllm.entrypoints.openai.api_server \ --model /models/your-model \ --served-model-name your-model \ --host 0.0.0.0 \ --port 8000这里我把模型放在本地路径/models/下。生产环境里更推荐直接从 HuggingFace 模型仓库拉取,但国内网络环境中模型下载经常中断,所以我在镜像里预先放置了模型文件,省去启动时的网络依赖。
4.2 Deployment 清单详解:资源配额、探针与自愈
接下来是 Deployment 的编排。这一份 YAML 我调整过很多次,每一步都是有原因的:
apiVersion: apps/v1 kind: Deployment metadata: name: llm-inference namespace: ai spec: replicas: 1 selector: matchLabels: app: llm-inference template: metadata: labels: app: llm-inference spec: runtimeClassName: nvidia nodeSelector: gpu: present containers: - name: vllm image: your-registry/llm-inference:latest ports: - containerPort: 8000 resources: limits: nvidia.com/gpu: 1 memory: 24Gi cpu: "8" env: - name: HUGGING_FACE_HUB_TOKEN valueFrom: secretKeyRef: name: hf-token key: token volumeMounts: - name: dshm mountPath: /dev/shm - name: models mountPath: /models readinessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 60 periodSeconds: 10 livenessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 30 periodSeconds: 30 volumes: - name: dshm emptyDir: medium: Memory - name: models hostPath: path: /data/models拆解几个关键字段:
runtimeClassName: nvidia:这个字段必须存在,否则 containerd 会默认调用普通 runc,进不了 GPU 环境。nodeSelector: gpu: present:给 GPU 节点提前打了这个标签,可以确保调度器把 Pod 放到有显卡的机器上。如果没有这个 selector,Pod 可能被调度到 CPU 节点上,虽然有 GPU 资源限制,但调度器会因为资源不匹配而一直 Pending。- 内存和 CPU 的
limits不要设太小,模型加载进显存之后还需要一部分内存做中间缓冲,这里给了 24Gi 是相对保守的数值。 /dev/shm挂载很重要。PyTorch 和 CUDA 在多进程处理数据时,共享内存默认只有 64MB,模型稍微大一点就会抛Bus error或直接段错误。用emptyDir的Memory类型把共享内存扩大成一个合理值,这个坑我见过太多次了。- 探针配置里
initialDelaySeconds特意拉长,因为模型加载到显存这个过程通常会持续 30 秒到一分钟,如果按默认的 10 秒去探测,服务还没起来就被当成崩溃实例反复重启了。
4.3 通过 Ingress 暴露服务并验证
Deployment 创建好之后,还要一个 Service 把 8000 端口暴露出来。集群内部可以通过 ClusterIP 访问,但对外提供服务还是建议走 Ingress。这里我用了 nginx-ingress,安装方式直接helm install ingress-nginx ingress-nginx/ingress-nginx就行。
Ingress 配置:
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: llm-inference namespace: ai annotations: nginx.ingress.kubernetes.io/proxy-read-timeout: "300" nginx.ingress.kubernetes.io/proxy-body-size: "10m" spec: ingressClassName: nginx rules: - host: llm.example.com http: paths: - path: / pathType: Prefix backend: service: name: llm-inference port: number: 8000proxy-read-timeout必须调大。生成式 AI 的响应是流式输出的,如果客户端请求的是超长文本,处理时间可能是几十秒甚至几分钟,nginx 默认的 60 秒超时会把连接掐断。设置成 300 秒相对安全。
验证服务的完整链路:用 curl 模拟一个生成式模型请求。
curl -X POST http://llm.example.com/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "your-model", "messages": [{"role": "user", "content": "解释一下 Kubernetes 里的控制器概念"}], "max_tokens": 512 }'如果返回正常的 JSON 响应,就说明整套链路已经通了。前面所有的工作,从 kubeadm init 到 Calico 到 GPU 设备插件,最后都汇集到这一个请求身上。
5. 推理服务的弹性伸缩与稳定性维护
当前 Deployment 是单副本的。但实际使用中,用户一旦流量上来,单副本的 vLLM 服务很容易被压垮。而 GPU 节点成本高,又不能随便加副本,所以弹性伸缩的策略需要仔细设计。
5.1 水平伸缩的陷阱:指标、冷启动与内存
传统的 HPA 通常按 CPU 和内存使用率做伸缩,但对 GPU 推理服务来说,这两个指标很不准。模型推理时 CPU 往往不高,而显存大的模型又占不满 GPU,按 CPU 会被误杀,按内存上限会导致副本数只增不减。更合理的做法是按“正在处理的请求数”来做伸缩。vLLM 暴露了自己的 Prometheus 指标,其中最有用的是vllm:num_requests_running。可以基于它配置 HPA:
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: llm-inference-hpa namespace: ai spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: llm-inference minReplicas: 1 maxReplicas: 4 metrics: - type: Pods pods: metric: name: vllm_num_requests_running target: type: AverageValue averageValue: 4但这里有个冷启动的问题。GPU 上拉新的副本,意味着要执行模型加载,几十秒甚至几分钟才能就绪。在这期间,如果流量已经打过来,你就只能用已有的副本硬扛。所以生产环境里通常的做法是先用较大的副本数满足高峰,而不是依赖 HPA 快速弹出来消化流量。
5.2 让推理 Pod 优雅处理长连接和 GPU 压力
推理服务的另一个特点是长连接。请求进来之后,模型要一步步生成 token,响应是流式的。这时候 Pod 在关闭(比如滚动更新)时,如果直接把连接切断,客户端体验会非常差。需要给 Pod 加terminationGracePeriodSeconds,而且这个值要大于一次完整推理的最长耗时。同时 vLLM 自身接受 SIGTERM 信号后可以优雅退出,它会完成当前正在处理的请求再关闭。
配置时可以这么写:
spec: terminationGracePeriodSeconds: 120另一个 GPU 压力的点是并发。vLLM 内部有自己的调度队列,如果同时进来的请求太多,它会排队处理,而不是全部堆在显存里。但每个请求都会占用一定的 KV cache 显存,如果显存被打满,后续请求就会排队等待甚至报错。建议在启动参数里加上--max-num-seqs,限制同时处理的序列数,防止显存溢出崩掉整卡。
还有 node 上的显存隔离问题。如果一台 GPU 节点上部署了多个推理服务,每个服务都会申请独立的 GPU 资源,但 Pod 之间不会自动共享显存调度。这里就会暴露一个现实:Kubernetes 的 GPU 调度是“整卡分配”的,它没有能力把一个 GPU 切成多份给多个 Pod 用。如果模型的显存占用不到整卡的一半,那就有点浪费资源了。这也是为什么很多人在这一层会引入 vGPU 或者 MIG 设备插件。不过这在生产调优里属于进阶玩法了,先把单卡跑稳再说。
6. 回看系列第五篇:几个真正值得记住的坑
写到这里,正文内容基本结束了。但按我的习惯,最后一节还是要拿出来一些踩过的坑,这些比教程本身更值得记录。
6.1 kubeadm reset 后残留配置导致的玄学问题
我在实操中因为网络配置反复调整,重置了好几次集群。每次重置前都会跑kubeadm reset,但重置之后重启 containerd 和 kubelet,发现kubeadm init还是会报端口占用。查了半天,发现/etc/cni/net.d/目录下残留了旧版的 CNI 配置文件。这个文件在你下次初始化时不会被自动清理,但 containerd 加载时会把里面的配置跟 Kubernetes 的新网络配置一起处理,导致网络不可用。解决办法就是重置后手动清理:
kubeadm reset -f rm -rf /etc/cni/net.d/* iptables -F && iptables -t nat -F ip link delete cni0这是一个很细节的坑,但遇到一次能浪费你一整天。
6.2 磁盘压力驱逐、镜像源与版本匹配
还有一个我印象很深的问题是 Node 磁盘压力。vLLM 镜像加上模型文件,单是镜像就可能超过 15GB,如果不清理 K8s 节点上旧镜像,节点很容易触发磁盘压力,然后 kubelet 直接把 Pod 驱逐。建议装一个node-cleanup或者定期用crictl rmi --prune清理无用的镜像。
镜像源和版本匹配这件事也值得单独提醒。kubeadm 1.26 对应的 kubelet 和 kubectl 也得是 1.26.0,如果装了 1.27 的 kubectl,API 兼容性通常没问题,但 kubelet 版本如果比控制面高,会引发一个很隐蔽的问题:节点能加入集群,但组件的日志里不断输出版本不匹配警告,有些高版本对控制面组件的配置参数已经做了变更,低版本控制面无法理解,导致 Pod 调度出现奇怪失败。所以这句话我每次都要讲:主版本、次版本一定对齐,补丁版本可以有偏差,但不能跨大版本。
最后再分享一个小技巧:在做这一类实验时,把每个阶段的关键命令和输出单独存一个文本文件。比如 kubeadm init 时生成的 token、join 命令、Calico 清单的改动记录、Device Plugin 版本等。等到后面排错时,翻出这些记录能帮你快速定位是配置问题还是环境问题。这个习惯我在生产环境里救过自己很多次。