一个 VIP 挂四个 Service:allow-shared-ip 的 key 写错就白忙

四个 Service 共用一个 VIP,只有第一个拿到地址,后面三个一直 pending。同一个注解的 key 写法决定分组,写反了要么只有一个能用,要么地址被悄悄换掉。

开发环境的中间件一直靠一条 SSH 隧道接进来。十个 -L 挤在一条命令里,好处是不动集群,坏处是谁重启了电脑就得重新拼一遍命令,换个人接手还要重新问一遍端口。

这次我决定把地址固定下来:给配置中心、搜索、对象存储、网关各建一个 LoadBalancer,让开发机直接连 VIP。集群跑的是 MetalLB 的 Layer2 模式,地址池是一段内网地址。池子不大,所以第一版方案是让四个 Service 共用一个 VIP、各占不同端口。

第一个 Service 顺利拿到了地址,后面三个一直是 <pending>。同一份 YAML 复制了四遍,只有第一份有效。

一、先把地址池的真实占用摸清楚

动手前先看现场,不要凭记忆分配地址:

kubectl get svc -A -o wide | awk '$5 ~ /^[0-9]/ {print $1, $2, $5, $6}'
kubectl get ipaddresspool -n metallb-system -o yaml | grep -A3 addresses

第一列是命名空间,第五列才是 EXTERNAL-IP,按字段位置过滤比 grep LoadBalancer 稳。

当时的结果是:池子 240–249 共十个地址,已被占用 240(入口)、241(监控)、243(数据库外部访问)、244(消息队列)、245(VPN)。242 被运维同事预留给监控的远端写入,不能动。剩下 246、247、248、249 四个空位,够用但不多 —— 这也是我一开始想共用 VIP 的原因。

二、第二个 Service 卡在 pending 的真实原因

kubectl -n dev-infra describe svc dev-es | sed -n '/Events/,$p'

事件里只有一句 no available IPs,或者干脆没有事件。翻 MetalLB 控制器的日志才看到关键信息:地址池里所有地址都被标成了已分配。

问题出在 metallb.io/allow-shared-ip 这个注解上。它的规则不是"我想共用就可以共用",而是同一时刻共用同一 IP 的所有 Service,必须携带完全相同的 key;反过来,key 相同的 Service 会被强制分到同一个 IP,哪怕你希望它们分开。

第一版我是这么写的:

apiVersion: v1
kind: Service
metadata:
  name: dev-es
  namespace: dev-infra
  annotations:
    metallb.io/allow-shared-ip: dev-es     # 每个服务都写了自己的名字
spec:
  type: LoadBalancer
  loadBalancerIP: 192.0.2.246
  allocateLoadBalancerNodePorts: false
  ports:
    - name: http
      port: 9200
      targetPort: 9200

四个 Service 用了四个不同的 key,等于告诉 MetalLB "这四个占四个地址"。于是第一个拿到 246,后面三个既拿不到 246(因为它已经被 key 不同的 Service 占用),也拿不到新地址(池子被预分配完了),只能 pending。

改成同一个 key 就好了:

    metallb.io/allow-shared-ip: dev-infra-shared

这里有个容易反过来的坑:不同 IP 必须用不同 key。我先踩了前半段,后来又踩了后半段 —— 把两个不同 VIP 的 Service 都写成了同一个 key,MetalLB 会让它们挤在同一个 IP 上,第二个的 loadBalancerIP 直接被忽略,服务听在了意料之外的地址上。

场景 key 怎么写 写错的后果
多个 Service 共用同一个 VIP 全部写成同一个值 只有第一个拿到地址,其余 pending
不同 Service 要不同 VIP 各自写不同的值 被迫共用一个 IP,第二个的地址被忽略
只有一个 Service 不写这个注解 正常,写了反而多一层约束

三、NodePort 会绕过你刚设的防火墙

共用 VIP 的第二个隐藏问题在 allocateLoadBalancerNodePorts 上。默认值是 true,MetalLB 在分地址的同时会给每个端口再分配一个 30000 以上的 NodePort,而这个 NodePort 在每台节点上都监听,从内网任何一台机器都能访问。

我们的节点防火墙是按"只开 22/80/443 加一个 VPN 端口"配的,末尾留了一条兜底放行给集群内部流量。NodePort 正好落在这条兜底规则的缝里:策略看起来只开了四个端口,实际效果是数据库、配置中心的端口全都对内网敞开。

所以这四个 Service 全部显式写上:

  allocateLoadBalancerNodePorts: false

代价是不能再拿 节点IP:3xxxx 做临时调试,必须走 VIP。对我们来说是好事 —— 少一条不受控的访问路径。改完之后用下面这条命令确认没有残留:

kubectl -n dev-infra get svc -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.ports[*].nodePort}{"\n"}{end}'

输出里端口列全是空的才算干净。已经存在的 Service 改这个字段需要先删后建,直接 apply 会报字段不可变。

四、Layer2 模式下 VIP 由一台节点应答

MetalLB 的 Layer2 模式没有真正的负载均衡:它选一台节点做"发言者",由这台节点用 ARP 宣告 VIP,所有流量都进这台机器,再由 kube-proxy 分发。这带来两个必须知道的约束。

第一,externalTrafficPolicy 必须是 Cluster。改成 Local 之后,如果发言者节点上没有对应的 Pod,流量会被直接丢弃,表现出来是"VIP ping 得通、端口连不上"。我们试过一次,节点日志里是大量 ARP FAILED,一分钟内就改回去了。

第二,节点重启或发言者切换时,ARP 缓存要几秒到几十秒才更新。开发同学遇到"刚才还好好的,现在连不上",多数是这一刻,arping 一下就能确认:

arping -c 3 192.0.2.246

如果回的 MAC 和你预期的那台节点不一致,等半分钟或者清一下本地 ARP 缓存,不要去改集群配置。

五、验证要按"端口 + 协议"逐条来

建完不等于能用。我的验收清单是每个端口一条命令,全部从开发机(不在集群网段)执行:

export no_proxy='192.0.2.0/24'
curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 http://192.0.2.246:8848/nacos/
curl -s --max-time 3 http://192.0.2.246:9200/_cluster/health | head -c 120
curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 http://192.0.2.246:9000/minio/health/live
curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 http://192.0.2.247:29100/actuator/health

no_proxy 这一行不是可有可无。本机配了公司代理,不加这句 curl 会把内网地址交给代理,返回一个 407 或者干脆超时,很容易被误判成"VIP 没通"。

VIP 端口 后端 期望结果
192.0.2.246 8848 配置中心 /nacos/ 返回 200
192.0.2.246 9200 搜索 _cluster/health 里 status 为 green
192.0.2.246 9000 对象存储 /minio/health/live 返回 200
192.0.2.247 29100 网关 /actuator/health 返回 {"status":"UP"}

网关那行如果直接请求根路径 /,返回 404 反而说明它活着 —— 网关没有根路由,用 /actuator/health 判断更准。

六、ExternalName 不能当普通 Service 用

同一批里还有一个坑。有个组件在业务命名空间里只是一个 ExternalName 类型的别名,指向监控命名空间里的真实 Service。我想顺手也给它开个 VIP,结果:

  • 给它加 type: LoadBalancer 不生效,控制器不会为 ExternalName 分配地址,因为 ExternalName 根本没有 selector 和 endpoints;
  • 把它的 selector 抄到新 Service 上,kubectl get endpoints 是空的 —— 真实 Service 的 selector 是 app=<组件名>,不是我猜的 app.kubernetes.io/name;
  • 而且这个别名不能跨命名空间选 Pod,必须在目标命名空间里另建一个 Service。

最后落在监控命名空间里,selector 照抄原 Service 才对上。判断 endpoints 是否正常只需要一条命令:

kubectl -n monitoring get endpoints <svc-name> -o wide

ENDPOINTS 列出现 10.244.x.x:端口 才算绑定成功,显示 <none> 就是 selector 没匹配上,这时候改 VIP、改端口都没意义。

小结

  • allow-shared-ip 是按 key 分组的:共用同一 IP 必须同 key,要不同 IP 必须不同 key,两边写反都会静默出错。
  • 地址池先查真实占用再分配,EXTERNAL-IP 是第五列,别用服务名去猜。
  • allocateLoadBalancerNodePorts: false 要显式写,否则每个端口都会在全部节点上多开一个 NodePort,绕过防火墙策略。
  • Layer2 模式下 VIP 只由一台节点应答,externalTrafficPolicy 保持 Cluster,发言者切换时先查 ARP 再怀疑配置。
  • ExternalName 没有 selector,不能挂 LB,也不能跨命名空间选 Pod;endpoints 为空时先对 selector,别动网络。
  • 验收用端口级命令加 no_proxy,一个 VIP 一条命令,别用"能 ping 通"当结论。
查看「容器与编排」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

一个 VIP 挂四个 Service:allow-shared-ip 的 key 写错就白忙 - 起航社区