一个 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 通"当结论。
正在加载评论…