侧边栏壁纸
  • 累计撰写 238 篇文章
  • 累计创建 245 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

为 K8S 补齐入口与存储:MetalLB、Gateway API、NFS 动态供给

zhanjie.me
2025-09-21 / 0 评论 / 0 点赞 / 1 阅读 / 0 字

上一篇里,我们已经把一套基于 Ubuntu 24.04 的原生 K8S 集群搭起来了。
节点 Ready、Calico 正常、最小 Nginx 工作负载也已经跑通。

但如果这时候你直接去部署 vLLM、SGLang 或 Open WebUI,很快就会遇到三个非常现实的问题:

  • Service 虽然创建出来了,但集群外根本访问不到
  • 即使暴露了端口,也缺少统一的入口和路由规则
  • 模型文件、缓存目录、UI 数据这些内容,没法方便地动态申请持久存储

这三个问题,恰好对应 K8S 在裸机或私有云场景下最容易缺失的三层基础能力:

  • MetalLB:给 LoadBalancer 类型 Service 分配外部 IP
  • Gateway API:给 HTTP 流量提供更现代的入口和路由方式
  • NFS 动态供给:让 PVC 可以自动创建后端存储

换句话说,前一篇解决的是“集群能不能跑”,这一篇解决的是:

集群怎么从“能跑工作负载”,升级到“能承载真正的 AI 服务”。

为了让这篇文章更适合直接照着操作,下面仍然会延续前文的写法:

  • 先解释为什么需要
  • 再给命令
  • 关键地方补预期输出
  • 最后说明什么样才算真的装好了

文中的 IP、Service 名称、Pod 名称和时间戳基于课程环境整理,实际与你环境不一致是正常现象。
最重要的是状态变化和验证思路,而不是逐字逐行完全一样。

摘要

如果你只想先拿结论,可以先记住这三件事:

  1. MetalLB 解决的是裸机 K8S 没有云厂商 LoadBalancer 的问题
  2. Gateway API + Envoy 解决的是服务入口和 HTTP 路由治理问题
  3. NFS 动态供给 解决的是 PVC 自动创建存储的问题

这三层补齐后,后续的 Ollama、Open WebUI、vLLM、SGLang 才更容易以“平台化工作负载”的方式部署进来。

这篇文章最关键的验收点也很明确:

  • LoadBalancer 类型 Service 能拿到 192.168.10.240-250 这类外部 IP
  • GatewayHTTPRoute 生效后,能通过域名或 Host 头访问后端服务
  • PVC 能从 Pending 变成 Bound

如果这三步都成立,你的 K8S 集群才真正具备继续承载 AI 服务的基础能力。

系列导航

这是“大模型私有化部署实践”系列的第四篇。当前系列顺序如下:

  1. 本地、Docker、K8S:大模型私有化部署路线怎么选
  2. 大模型推理环境准备实战:GPU、驱动、CUDA、容器运行时
  3. 基于 Ubuntu 24.04 搭建 AI 推理用原生 K8S 集群
  4. 为 K8S 补齐入口与存储:MetalLB、Gateway API、NFS 动态供给
  5. 用 Ollama + Open WebUI 快速搭建本地 AI 体验环境
  6. vLLM 私有化部署实战:本地部署、Docker 部署、接口验证
  7. vLLM 上 K8S:服务部署、对外暴露、监控与验证
  8. SGLang 私有化部署实战:本地部署、Docker 部署、能力体验
  9. SGLang 上 K8S:接入 Open WebUI、服务发布与 GPU 运维
  10. 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 还是 PendingCrashLoopBackOff,先不要继续配地址池。

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 做最终验证:从 PendingBound

动态供给器装好之后,最重要的不是“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_SERVERNFS_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 体验环境跑起来。

0

评论区