上一篇里,我们已经把一套基于 Ubuntu 24.04 的原生 K8S 集群搭起来了。
节点 Ready、Calico 正常、最小 Nginx 工作负载也已经跑通。
但如果这时候你直接去部署 vLLM、SGLang 或 Open WebUI,很快就会遇到三个非常现实的问题:
- Service 虽然创建出来了,但集群外根本访问不到
- 即使暴露了端口,也缺少统一的入口和路由规则
- 模型文件、缓存目录、UI 数据这些内容,没法方便地动态申请持久存储
这三个问题,恰好对应 K8S 在裸机或私有云场景下最容易缺失的三层基础能力:
MetalLB:给LoadBalancer类型 Service 分配外部 IPGateway API:给 HTTP 流量提供更现代的入口和路由方式NFS 动态供给:让 PVC 可以自动创建后端存储
换句话说,前一篇解决的是“集群能不能跑”,这一篇解决的是:
集群怎么从“能跑工作负载”,升级到“能承载真正的 AI 服务”。
为了让这篇文章更适合直接照着操作,下面仍然会延续前文的写法:
- 先解释为什么需要
- 再给命令
- 关键地方补预期输出
- 最后说明什么样才算真的装好了
文中的 IP、Service 名称、Pod 名称和时间戳基于课程环境整理,实际与你环境不一致是正常现象。
最重要的是状态变化和验证思路,而不是逐字逐行完全一样。
摘要
如果你只想先拿结论,可以先记住这三件事:
MetalLB解决的是裸机 K8S 没有云厂商 LoadBalancer 的问题Gateway API + Envoy解决的是服务入口和 HTTP 路由治理问题NFS 动态供给解决的是 PVC 自动创建存储的问题
这三层补齐后,后续的 Ollama、Open WebUI、vLLM、SGLang 才更容易以“平台化工作负载”的方式部署进来。
这篇文章最关键的验收点也很明确:
LoadBalancer类型 Service 能拿到192.168.10.240-250这类外部 IPGateway和HTTPRoute生效后,能通过域名或 Host 头访问后端服务PVC能从Pending变成Bound
如果这三步都成立,你的 K8S 集群才真正具备继续承载 AI 服务的基础能力。
系列导航
这是“大模型私有化部署实践”系列的第四篇。当前系列顺序如下:
- 本地、Docker、K8S:大模型私有化部署路线怎么选
- 大模型推理环境准备实战:GPU、驱动、CUDA、容器运行时
- 基于 Ubuntu 24.04 搭建 AI 推理用原生 K8S 集群
- 为 K8S 补齐入口与存储:MetalLB、Gateway API、NFS 动态供给
- 用 Ollama + Open WebUI 快速搭建本地 AI 体验环境
- vLLM 私有化部署实战:本地部署、Docker 部署、接口验证
- vLLM 上 K8S:服务部署、对外暴露、监控与验证
- SGLang 私有化部署实战:本地部署、Docker 部署、能力体验
- SGLang 上 K8S:接入 Open WebUI、服务发布与 GPU 运维
- vLLM 和 SGLang 到底怎么选
如果说上一篇解决的是“原生集群底座”,这一篇解决的就是:
为什么一个节点全是 Ready 的 K8S 集群,仍然不足以承载真正的 AI 应用,以及我们该怎么把缺的入口和存储补上。
1. 为什么 K8S 集群 Ready 了,还是不够
这是很多人第一次搭裸机 K8S 时都会碰到的困惑。
明明:
- 节点已经
Ready - Pod 能调度
- Service 也能创建
但一旦真正开始上业务,就会发现很多东西“看起来有了”,实际却还差最后一公里。
1.1 缺少 MetalLB:LoadBalancer 类型 Service 没有外部 IP
在云厂商托管 K8S 里,type: LoadBalancer 往往会自动分配一个外部地址。
但在裸机或私有云环境里,K8S 本身并不会自动给你变出一个对外可访问的 IP。
这时候最常见的现象就是:
kubectl get svc
输出里 EXTERNAL-IP 一直是:
<pending>
MetalLB 要解决的,就是这件事。
1.2 缺少 Gateway API:服务能暴露,但没有统一入口治理
即使有了外部 IP,也不代表你的入口层已经可用。
很多 AI 服务后面会同时存在:
- 推理 API
- Web UI
- 不同域名或 Host 规则
- 后续可能的灰度、鉴权、转发和治理需求
这时候只靠 NodePort 或简单的 Service 暴露,很快就会变得粗糙且难维护。
Gateway API 的作用,就是给你一套更现代、更声明式的入口模型。
1.3 缺少动态存储:PVC 能写,但没人真的去创建后端卷
AI 服务几乎绕不开存储:
- 模型文件
- 下载缓存
- Web UI 数据
- 推理过程中的持久目录
如果没有动态供给,很多 PVC 虽然定义了,但会一直卡在:
Pending
因为集群里根本没有一个真正去帮你“创建后端卷”的存储供给器。
NFS 动态供给 解决的,就是这件事。
2. 第一层:安装 MetalLB,让 LoadBalancer 真正有外部 IP
在裸机环境里,MetalLB 几乎是最值得优先补的一层。
没有它,后面的入口层很多时候连外部地址都拿不到。
2.1 MetalLB 到底解决什么问题
最简单的理解就是:
它让你在裸机 K8S 里,也能像云上那样使用
LoadBalancer类型 Service。
对于后面要部署的 Envoy Gateway、Open WebUI、模型推理 API 来说,这一点非常关键。
2.2 安装 MetalLB
在控制平面节点执行:
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.13.10/config/manifests/metallb-native.yaml
先看命名空间和 Pod 是否起来:
kubectl get ns
kubectl get pods -n metallb-system
正常情况下,你应该能看到类似输出:
NAME STATUS AGE
metallb-system Active 1m
以及:
NAME READY STATUS RESTARTS AGE
controller-xxxxxxxxxx-xxxxx 1/1 Running 0 1m
speaker-abcde 1/1 Running 0 1m
speaker-fghij 1/1 Running 0 1m
speaker-klmno 1/1 Running 0 1m
如果这里控制器或 speaker 还是 Pending、CrashLoopBackOff,先不要继续配地址池。
2.3 配置地址池和二层广播
MetalLB 本身安装完成,并不代表它已经能分配 IP。
你还要告诉它:“哪些 IP 可以拿来发放”。
在本例环境中,我们沿用课程里的地址池:
192.168.10.240-192.168.10.250
创建 ippool-l2.yaml:
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: first-pool
namespace: metallb-system
spec:
addresses:
- 192.168.10.240-192.168.10.250
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: example
namespace: metallb-system
应用配置:
kubectl apply -f ippool-l2.yaml
kubectl get ipaddresspool -n metallb-system
kubectl get l2advertisement -n metallb-system
正常输出类似:
NAME AUTO ASSIGN AVOID BUGGY IPS ADDRESSES
first-pool true false ["192.168.10.240-192.168.10.250"]
和:
NAME IPADDRESSPOOLS IPADDRESSPOOL SELECTORS INTERFACES
example
2.4 用一个测试 Service 验证 MetalLB 是否真的生效
这一步非常重要。
不要等后面部署 Envoy 了才发现 MetalLB 根本没工作。
先创建一个最小测试应用:
apiVersion: apps/v1
kind: Deployment
metadata:
name: lb-test
spec:
replicas: 1
selector:
matchLabels:
app: lb-test
template:
metadata:
labels:
app: lb-test
spec:
containers:
- name: nginx
image: nginx:1.27
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: lb-test
spec:
selector:
app: lb-test
ports:
- port: 80
targetPort: 80
type: LoadBalancer
应用并查看:
kubectl apply -f lb-test.yaml
kubectl get svc lb-test
如果 MetalLB 正常,你会看到类似结果:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
lb-test LoadBalancer 10.110.22.181 192.168.10.240 80:31xxx/TCP 20s
关键看点不是 NodePort,而是 EXTERNAL-IP 不再是 <pending>,而是真正拿到了 192.168.10.240 这样的地址。
3. 第二层:安装 Gateway API,让入口从“能访问”升级到“能治理”
MetalLB 解决的是“有外部 IP”,但这还只是入口问题的一半。
后面真正承载 AI 服务时,你通常还需要:
- 按域名区分服务
- 按路径转发请求
- 让后续入口规则声明式管理
这就是 Gateway API 发挥作用的地方。
3.1 为什么这里选 Gateway API,而不是只写一个 Ingress
不是说 Ingress 不行,而是从长期演进角度看,Gateway API 的模型更清晰:
GatewayClass定义实现类型Gateway定义入口实例HTTPRoute定义路由规则
对后面多服务、多域名、统一入口治理的 AI 场景来说,这个分层非常顺手。
3.2 安装 Gateway API CRD 和 Envoy Gateway
在控制平面节点执行:
kubectl create -f https://github.com/envoyproxy/gateway/releases/download/v1.6.0/install.yaml
安装完成后,先检查 CRD:
kubectl get crd | grep networking.k8s.io
常见输出类似:
gatewayclasses.gateway.networking.k8s.io
gateways.gateway.networking.k8s.io
httproutes.gateway.networking.k8s.io
grpcroutes.gateway.networking.k8s.io
referencegrants.gateway.networking.k8s.io
再检查控制器:
kubectl get pods -n envoy-gateway-system
kubectl get svc -n envoy-gateway-system
正常情况下,你应该能看到 envoy-gateway-system 命名空间下的控制器 Pod 为 Running。
3.3 先确认 GatewayClass 是否出现
kubectl get gatewayclass
典型输出:
NAME CONTROLLER NAME ACCEPTED AGE
eg gateway.envoyproxy.io/gatewayclass-controller True 2m
ACCEPTED=True 是这里最重要的信号。
3.4 部署一个最小 Gateway 和 HTTPRoute
为了把 MetalLB 和 Gateway API 串起来,我们先不用 Open WebUI,先用一个简单后端验证入口链路。
先准备一个后端服务,例如 httpbin:
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
spec:
replicas: 1
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: httpbin
image: kennethreitz/httpbin
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: backend
spec:
selector:
app: backend
ports:
- port: 3000
targetPort: 80
然后准备 gateway-http-route.yaml:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: demo-gateway
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
hostname: "www.example.com"
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: demo-route
spec:
parentRefs:
- name: demo-gateway
hostnames:
- "www.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: backend
port: 3000
weight: 1
应用资源:
kubectl apply -f backend.yaml
kubectl apply -f gateway-http-route.yaml
kubectl get gateway
kubectl get httproute
kubectl get pods -n envoy-gateway-system
kubectl get svc -n envoy-gateway-system | grep LoadBalancer
3.5 预期输出应该长什么样
kubectl get gateway:
NAME CLASS ADDRESS PROGRAMMED AGE
demo-gateway eg 192.168.10.240 True 30s
kubectl get httproute:
NAME HOSTNAMES AGE
demo-route ["www.example.com"] 30s
kubectl get svc -n envoy-gateway-system | grep LoadBalancer:
envoy-default-eg-e41e7b31 LoadBalancer 10.111.180.42 192.168.10.240 80:31042/TCP 26s
这条输出非常关键,因为它说明:
- Envoy 入口 Service 已经被创建
- 这个 Service 的
LoadBalancer地址已经由 MetalLB 分到了192.168.10.240
3.6 为什么课件里要改 externalTrafficPolicy
课件里有一步:
kubectl patch service envoy-default-eg-e41e7b31 -n envoy-gateway-system -p '{"spec":{"externalTrafficPolicy":"Cluster"}}'
这一步的目的,是让入口 Service 更容易在整个集群范围内转发流量。
你可以先看一下当前配置:
kubectl get svc -n envoy-gateway-system envoy-default-eg-e41e7b31 -o yaml | grep externalTrafficPolicy
如果需要切到 Cluster,再执行 patch。
3.7 用 curl 做一次真正的入口验证
最简单的验证方式是:
curl -H "Host: www.example.com" http://192.168.10.240/get
如果配置正常,你会看到类似 httpbin 返回的 JSON:
{
"path": "/get",
"host": "www.example.com",
"method": "GET",
"proto": "HTTP/1.1",
"headers": {
"Accept": [
"*/*"
],
"User-Agent": [
"curl/8.x"
]
}
}
只要这一步能通,就说明从集群外部进入 Gateway,再路由到后端服务的链路已经成立了。
4. 第三层:配置 NFS 动态供给,让 PVC 不再一直 Pending
入口问题解决后,下一层通常就是存储。
这一步对 AI 服务尤其重要,因为后续很容易遇到这些需求:
- Open WebUI 的数据目录要持久化
- Ollama 或模型相关目录要持久化
- 推理缓存、结果数据、日志目录要持久化
如果没有动态供给,很多资源定义看起来都没问题,但 PVC 会一直卡住。
4.1 先准备 NFS 服务器
课程环境里单独准备了一台 NFS 服务器,例如:
- NFS 服务器 IP:
192.168.10.143 - 共享目录:
/netshare
先确认磁盘:
lsblk
典型输出类似:
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
sda 8:0 0 100G 0 disk
sdb 8:16 0 100G 0 disk
安装 NFS 服务:
apt-get update
apt-get install -y nfs-server
systemctl status nfs-server --no-pager
正常输出里至少应该看到:
Loaded: loaded
Active: active (exited)
4.2 准备挂载目录和导出配置
mkdir -p /netshare
mkfs.xfs /dev/sdb
mount /dev/sdb /netshare
echo '/dev/sdb /netshare xfs defaults 0 0' >> /etc/fstab
echo '/netshare *(rw,sync,no_subtree_check,no_root_squash)' >> /etc/exports
exportfs -arv
showmount -e
如果导出正常,showmount -e 输出通常类似:
Export list for nfsserver:
/netshare *
4.3 从 K8S 节点验证 NFS 是否可达
在控制平面和至少一个 worker 节点执行:
apt install -y nfs-common
showmount -e 192.168.10.143
预期输出:
Export list for 192.168.10.143:
/netshare *
只要这一步不通,就先不要继续上动态供给器。
5. 部署 NFS 动态供给器
这一步的目标,是让 K8S 能根据 PVC 自动在 NFS 后端创建目录并完成绑定。
5.1 获取资源清单
在控制平面节点执行:
for file in class.yaml deployment.yaml rbac.yaml; do \
wget https://raw.githubusercontent.com/kubernetes-incubator/external-storage/master/nfs-client/deploy/$file; \
done
ls
预期输出中应该至少包含:
class.yaml
deployment.yaml
rbac.yaml
5.2 先应用 RBAC
kubectl apply -f rbac.yaml
5.3 修改 StorageClass 和 provisioner 名称
打开 class.yaml,关键内容通常像这样:
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
name: nfs-client
provisioner: fuseim.pri/ifs
parameters:
archiveOnDelete: "false"
应用:
kubectl apply -f class.yaml
kubectl get sc
成功时通常会看到:
storageclass.storage.k8s.io/nfs-client created
5.4 修改 deployment.yaml 里的 NFS 地址和共享目录
这是最容易忘的一步。
在 deployment.yaml 里,至少要确认下面两项改成你的真实值:
- NFS 服务器地址:
192.168.10.143 - 共享目录:
/netshare
例如需要关注的环境变量通常类似:
env:
- name: PROVISIONER_NAME
value: fuseim.pri/ifs
- name: NFS_SERVER
value: 192.168.10.143
- name: NFS_PATH
value: /netshare
应用:
kubectl apply -f deployment.yaml
kubectl get pods
预期至少应该看到 provisioner Pod 为 Running。
6. 用 PVC 做最终验证:从 Pending 到 Bound
动态供给器装好之后,最重要的不是“Pod 起没起”,而是它到底能不能真实帮你创建卷。
6.1 创建一个测试 PVC
准备 pvc-test.yaml:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc-test
spec:
accessModes:
- ReadWriteOnce
storageClassName: nfs-client
resources:
requests:
storage: 1Gi
应用并查看:
kubectl apply -f pvc-test.yaml
kubectl get pvc
kubectl get pv
6.2 预期输出应该长什么样
如果动态供给生效,kubectl get pvc 应该从一开始的 Pending 很快变成:
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
pvc-test Bound pvc-57bee742-326b-4d41-b241-7f2b5dd22596 1Gi RWO nfs-client 20s
课件里类似的成功输出如下:
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
www-web-0 Bound pvc-57bee742-326b-4d41-b241-7f2b5dd22596 1Gi RWO nfs-client 3m19s
看到 Bound,才说明这套动态供给真正工作了。
6.3 如果还是 Pending,优先看哪里
优先检查这三类问题:
deployment.yaml里的NFS_SERVER和NFS_PATH是否写对- provisioner Pod 是否
Running - 节点上
showmount -e 192.168.10.143是否正常
不要一上来就怀疑 PVC 定义,多数时候问题仍然出在 NFS 连通性或 provisioner 配置。
7. 把三层串起来看:它们在后续 AI 服务里分别负责什么
到这里,MetalLB、Gateway API 和 NFS 动态供给都补上了。
如果只把它们当作三个孤立组件,其实很容易低估它们的价值。
更实用的理解方式,是把它们看成 AI 服务进入 K8S 前必须补齐的三层平台能力:
7.1 MetalLB 负责“让入口拥有可访问地址”
后续不管是 Envoy、Open WebUI 还是模型 API,对外暴露都离不开一个真正可用的外部 IP。
MetalLB 提供的,就是这个基础能力。
7.2 Gateway API 负责“让入口具备路由和治理能力”
后续你会越来越需要:
- 一个域名对应一个服务
- 不同路径转发到不同后端
- UI 和 API 分开管理
Gateway API 给的,是这层治理能力。
7.3 NFS 动态供给负责“让工作负载可以声明式申请存储”
后面部署 Open WebUI、Ollama,甚至部分模型服务时,都会越来越依赖 PVC。
如果没有动态供给,很多配置文件虽然能写,但落不到真正可用的存储上。
8. 结语:集群从“能跑”到“能承载服务”,差的往往就是这三层
很多人在第一次做裸机 K8S 时,容易把注意力都放在集群安装本身。
但对 AI 服务来说,一个真正有用的集群,不是节点都 Ready 就结束了。
真正能支撑后续工作负载的,往往是这些“后补”的平台能力:
- 有可分配的
LoadBalancer外部 IP - 有统一的入口和路由模型
- 有可以自动绑定的持久存储
只有这三层补齐之后,你后面去部署 Ollama、Open WebUI、vLLM、SGLang,才不会每次都被“服务怎么暴露”和“卷为什么绑不上”这类基础问题绊住。
如果用一句话概括这一篇,我会这样总结:
原生 K8S 集群解决的是“工作负载能不能调度”,而 MetalLB、Gateway API、NFS 动态供给解决的是“服务能不能真正交付给别人用”。
下一篇文章,我们就继续沿着这条路往前走,把这些能力真正用起来:
基于已经补齐入口和存储能力的 K8S 集群,快速部署 Ollama 和 Open WebUI,先把一套可交互的本地 AI 体验环境跑起来。
评论区