如果有一天你发现,K8s集群里用Keepalived漂移出来的VIP+NodePort死活访问不通,而直接用节点IP加NodePort却一切正常,别急着怀疑防火墙。我最近就在一套3节点的集群上踩中了这个问题,排查链路很长,根因却特别简单:外部流量永远只进VIP所在的节点,而Service的转发策略要求流量必须落在存在Pod的节点上。这两头一错位,黑洞就出现了。
这篇文章把我当时的完整排障过程、涉及到的K8s转发机制、以及最后怎么从“临时恢复”改到“稳定根治”的过程写下来。如果你也在用Keepalived这类方式给K8s服务做入口,或者正准备在集群里搞VIP,这篇值得你花十分钟看完。
1. 故障复现:节点IP能通、VIP不能通的那一刻
1.1 先说集群长什么样
这套集群一共3个节点,代号N1、N2、N3,都是kubeadm装的,网络插件用的Calico,kube-proxy跑的是IPVS模式。为了给外部一个固定的入口,节点上装了Keepalived,3个节点组成一个VRRP实例,漂移出一个VIP,假设是192.168.31.100。正常情况下VIP会落在VRRP的Master节点上。
服务那边很简单,一个Deployment,两个副本,Pod分布在N2和N3上。对应的Service类型是NodePort,指定了nodePort: 30080。
$ kubectl get svc NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE web-demo NodePort 10.97.185.32 <none> 8080:30080/TCP 14d这个架构很常见,Keepalived负责提供一个稳定的虚拟IP,外面访问192.168.31.100:30080,流量先进VIP所在节点,再经过NodePort规则转给后端Pod。看起来没什么问题,但实际访问的时候直接卡住。
1.2 几个快速实验,先把故障边界画出来
我当时没有一上来就翻文档,而是先在集群外部的客户端机器上做了四个探测。
# 实验1:ping VIP,看看虚拟IP本身活着没有 ping 192.168.31.100 # 实验2:依次访问三个节点的NodePort curl http://192.168.31.101:30080 curl http://192.168.31.102:30080 curl http://192.168.31.103:30080 # 实验3:访问VIP:NodePort curl http://192.168.31.100:30080 # 实验4:用nc测试VIP的80端口,排除NodePort问题 nc -vz 192.168.31.100 80结果如下:
| 探测目标 | 结果 |
|---|---|
| ping 192.168.31.100 | 通 |
| N1的NodePort 30080 | 不通 |
| N2的NodePort 30080 | 通 |
| N3的NodePort 30080 | 通 |
| 192.168.31.100:30080 | 不通 |
| 192.168.31.100:80 | 不通(本来就没服务,预期失败) |
到这里已经能划掉很多可能性了。VIP本身活着,两个后端Pod也正常,N2和N3的NodePort都能通。问题集中在:流量一旦落到VIP所在节点,就出不去了。我当时还确认了一下VIP当前落在哪台节点上。
# 在N1上看 $ ip addr show | grep 192.168.31.100 inet 192.168.31.100/32 scope global eth0VIP确实在N1上。于是把注意力从“网络不通”转到了“流量到了N1之后发生了什么”。那台节点IP加30080都不通,本身就很有信息量:说明不是VIP虚拟地址的问题,而是N1这节点在处理外部来源的NodePort流量时出了问题。
2. 查到一个关键配置:externalTrafficPolicy=Local
2.1 看到Service定义里这行配置时,我就预感不好
看Service的YAML,发现里面有一行很不起眼的配置:
$ kubectl get svc web-demo -o yaml | grep -A 2 externalTraffic externalTrafficPolicy: Local internalTrafficPolicy: Cluster这就是第一层根因。
externalTrafficPolicy,外部流量策略,有两个可选值:Cluster和Local。默认值是Cluster,表示外部流量进到任意节点时,都可以被转发给集群里任何一个后端Pod,哪怕目标Pod不在当前节点上,中间会做一次SNAT。而Local的含义是:外部流量只允许转发给当前节点上存在的Pod,如果当前节点没有后端Pod,这个流量就被直接丢弃。
换句话说,Local策略牺牲了一部分转发灵活性,换来的是客户端源IP可以透传,后端可以直接看到真实来源IP。很多内部服务为了拿客户端IP做权限校验,会开这个策略。
2.2 Local模式在IPVS和iptables里分别是怎么丢包的
明白了Local的含义之后,N1上的表现就很好解释了。但为了确认不是别的干扰,我还看了一眼kube-proxy在不同模式下的具体表现。
如果你的kube-proxy是iptables模式,Local策略体现在filter表和nat表上。kube-proxy只为那些本地有Pod的节点生成KUBE-XLB-*链,目标NodePort且来自外部的流量会走这条链;如果链上不存在对应后端,默认规则就是DROP。所以从N1上访问,包会被静默丢弃,表现就是连接超时。
如果是IPVS模式,行为又有点不一样。IPVS模式下的Service会设置一个only-local标志,调度器只会把本地Endpoints当成可用的RS。如果本地RS列表为空,这个连接不会分配给任何后端,同样会被丢掉。
这就是为什么我拿ipvsadm看的时候,发现N1上的这条Service对应的RS里没有10.244.x.x的Pod地址,而N2、N3上的RS列表是正常的。
# 在N1上看这个Service的RS $ ipvsadm -Ln | grep -A 2 30080 TCP 0.0.0.0:30080 rr -> 0.0.0.0:0 Masq 1 0 0 # 在N2上看同样的Service TCP 0.0.0.0:30080 rr -> 10.244.102.5:8080 Masq 1 0 0 -> 10.244.103.8:8080 Masq 1 0 0注意N1上面那个-> 0.0.0.0:0,这是一个“空后端”的标记。流量进到N1之后,IPVS根本找不到可用的Pod,只能丢。
2.3 确认Pod分布,然后用最小改动应急
再看一眼Pod的分布情况:
$ kubectl get pods -o wide NAME READY STATUS RESTARTS AGE IP NODE web-demo-6b9f8d8b7-4xk2z 1/1 Running 0 5m 10.244.102.5 N2 web-demo-6b9f8d8b7-8n2m8 1/1 Running 0 5m 10.244.103.8 N3Pod全部在N2和N3上,N1上一个副本都没有。而Keepalived的VIP偏偏落在N1上,外部流量全走N1,再加上Local策略会把没有本地后端的节点变成黑洞,最终结果就是VIP+NodePort不通。
最直接的应急动作有两个方向:要么让Pod出现在N1上,要么把Service的外部流量策略改回Cluster。
我当时选了“让Pod出现在N1上”。原因很简单:服务端那边想保留客户端IP,不能直接改Cluster,否则会丢失真实来源IP。我用一个临时补丁给Deployment加上了节点亲和性,把副本固定到N1上。
spec: template: spec: nodeSelector: kubernetes.io/hostname: node-1改完生效之后,再curl 192.168.31.100:30080,立即返回200。问题到此算是“表面解决”了,但我知道这只是把锅从N1挪走,整个架构仍然很脆弱。
3. Keepalived做VIP入口,为什么天生容易踩NodePort的坑
3.1 入口固定在一个节点,和Local策略天然冲突
Keepalived的VIP本质上是active-passive的,任意时刻只有一个节点会对这个VIP响应ARP,外部流量会被交换机全部引到这台节点上。这和Local策略放在一起,会产生一个非常尴尬的局面:VIP在哪台节点,哪台节点就必须有对应服务的Pod,否则所有流量都会堆在那个节点上被丢弃。
有人可能会说,那我给每个服务都把所有节点打满Pod不就行了?实际操作中这是不现实的。几十个服务、几百个副本,不可能每一个都恰好覆盖所有节点。而且Keepalived发生主备切换之后,VIP会从一台节点漂移到另一台节点,Pod不会跟着自动迁移,原来的“恰好覆盖”马上就失效了。
我用一个表格来总结这个矛盾:
| 场景 | Keepalived入口行为 | externalTrafficPolicy=Local | 最终效果 |
|---|---|---|---|
| VIP节点有Pod | 流量全部进VIP节点 | 本地转发,正常 | 通 |
| VIP节点无Pod | 流量全部进VIP节点 | 本地无后端,丢包 | 不通 |
| 直接访问非VIP节点IP | 没有入口绑定 | 有Pod就通,没Pod就不通 | 随机通/不通 |
直接访问节点IP的时候,你可以挑一台有Pod的节点去访问,但VIP是固定的,它没有“挑选”的能力,只会跟着Keepalived走到某一台节点。
3.2 云上的LoadBalancer为什么很少遇到这个问题
同样暴露K8s服务,云厂商的LoadBalancer类型Service很少出现这个问题,核心原因是云LB会主动做后端健康检查。它会把后端节点上对应NodePort的健康状态作为转发依据,某个节点没有Pod或者Pod不健康,云LB就会把这个节点从转发列表里摘掉,流量自然不会再往黑洞节点送。
而Keepalived没有这个机制。它只负责VIP漂移和VRRP状态维护,完全不关心K8s服务是否可用。即使N1上的kube-proxy挂了,只要Keepalived进程还活着,VIP就还留在N1上,流量依然往里灌。
这也是很多人在自建K8s集群里用Keepalived时反复踩坑的原因:你等于把“服务高可用”的负担全部压在了Keepalived和VIP上,但它并没有能力感知服务层的健康状态。
3.3 如果改成Cluster模式,会留下什么尾巴
那如果当时我改成externalTrafficPolicy: Cluster呢?临时应急确实可行,因为Cluster模式下,流量进到N1就可以被转发给N2、N3上的Pod,不存在“本地无后端”的丢包问题。但代价也很明显:跨节点转发时,kube-proxy会把源IP改成N1的IP,后端Pod看到的就都是N1的地址,客户端真实IP彻底丢失。
对于只做内部访问的场景,丢了源IP影响不大。但如果你后面要接日志分析、安全审计,源IP丢失意味着所有请求都长一个样,排查问题会非常痛苦。另外Cluster模式也不是没有性能损耗,跨节点转发多一跳,网络延迟和带宽消耗都会增加。
我当时没有直接改成Cluster还有一个原因:这个服务后续可能要做网关,入口层需要客户端IP,我不能用一个不能拿真实IP的方案来糊弄。
4. 把“能通”变成“稳定通”:三种解法与连带检查
4.1 解法一:让Pod跟着VIP走
把Pod固定在VIP节点上,是我用的应急方案。具体做法前面已经提到,通过nodeSelector或nodeAffinity把副本钉在Keepalived的Master节点上。
这个方案最直接,Pod和VIP同节点,流量不需要跨节点转发,延迟低,还能保留客户端IP。但问题在于:一旦Keepalived发生了主备切换,VIP跑到别的节点,Pod并不会跟着过去。所以你需要额外做一个联动,让Pod调度也跟随VIP漂移。常见的做法是用一个自定义控制器或者脚本去监测VRRP状态,在主备切换时给Deployment打补丁,改成新的节点标签。
# 类似这样,在检测到VIP漂移到node-2后,更新Deployment kubectl patch deployment web-demo -p '{"spec":{"template":{"spec":{"nodeSelector":{"kubernetes.io/hostname":"node-2"}}}}}'这种方式适合那些Pod副本少、状态简单、可以容忍短暂中断的内部服务。副本一多,或者服务对Pod分布有要求,这个方案就会很别扭。
4.2 解法二:给Service挂externalIPs,把VIP交给kube-proxy处理
后来我没有满足于“Pod跟着VIP走”,而是换了更稳的思路:不再用VIP直接映射NodePort端口,而是把VIP作为Service的externalIPs写进去。
spec: externalTrafficPolicy: Cluster externalIPs: - 192.168.31.100加了这个配置之后,kube-proxy会在所有节点上把192.168.31.100当成这个Service的入口IP来处理。外部流量到VIP之后,不再依赖N1上的NodePort,而是直接命中KUBE-SERVICES链里针对externalIP的DNAT规则,再正常转发给后端Pod。就算Keepalived把VIP从N1漂移到N2或者N3,流量也能正常工作,因为每个节点都有针对这个externalIP的iptables/IPVS规则。
这个方案的优点是彻底解耦了“VIP落在哪”和“后端Pod在哪里”。VIP继续由Keepalived负责漂移,而K8s服务那边的转发逻辑全部交给kube-proxy。缺点是要重新规划Service的流量策略,而且externalIPs要求网络管理员保证这个IP可以被路由到集群节点上。
对我来说,这是最后采用的方案。它没有动Pod调度,也没有牺牲源IP,还让VIP的故障切换变得真正对服务透明。
4.3 连带检查:ip_forward与rp_filter
在改externalIPs方案的过程中,我还顺手查了两个系统参数,因为这类流量入口改造很容易被它们坑到。
第一个是net.ipv4.ip_forward。跨节点转发依赖它,如果没开,NodePort跨节点转发会直接失败。正常情况下kubeadm会把各节点都配好,但有些发行版默认是0,需要确认一下。
sysctl net.ipv4.ip_forward # 期望输出为1第二个是rp_filter,反向路径过滤。它在多网卡、多路由的节点上特别容易误伤流量。举个简化例子:某个节点上有VPC内网地址、Pod网段地址,回程包可能从另一张网卡回来,如果严格的rp_filter发现“这个包源IP对应的最佳路由出口不是当前入口”,就会直接丢包。
这个参数本身跟这次故障没有直接关系,但如果你按externalIPs方案改造之后,发现流量“偶尔丢、重传多、延迟抖动”,大概率就是它。可以临时放宽观察:
sysctl -w net.ipv4.conf.all.rp_filter=0生产环境不建议长期全局关闭,建议只对入口网卡或者专门路由表做精确控制,不然容易引入其他安全隐患。
5. 这套架构下,我顺手填平的两个次生坑
5.1 坑一:kube-proxy的IPVS模式静默回退
排查过程中,我在某台节点上执行ipvsadm -Ln,发现里面是空的,一开始以为是kube-proxy没起来。后来仔细查,发现是IPVS需要的内核模块没加载,kube-proxy自动回退到了iptables模式。
lsmod | grep ip_vs如果输出为空,表示节点上没加载IPVS模块。很多新手改了kube-proxy的mode为ipvs,但忘了在所有节点上加载模块,结果kube-proxy只能静默回退到iptables。两种模式本身都能用,但排查手段完全不同。如果你一直用ipvsadm看RS,却发现什么都没有,别怀疑是kube-proxy坏了,先查模块。
cat <<EOF | sudo tee /etc/modules-load.d/k8s-ipvs.conf ip_vs ip_vs_rr ip_vs_wrr ip_vs_sh nf_conntrack EOF systemctl enable --now systemd-modules-load.service5.2 坑二:Keepalived健康检查脚本只管网卡不管服务
Keepalived默认的VRRP检查只关心本机网络是否正常,VIP能否绑定。它根本不知道后端的K8s服务是不是还活着。我在那次故障之后,把Keepalived的检查脚本从“ping网关”换成了“真实探测本机服务”。
vrrp_script chk_k8s { script "/etc/keepalived/check_k8s.sh" interval 2 weight -20 fall 3 rise 2 } vrrp_instance VI_1 { state BACKUP interface eth0 virtual_router_id 51 priority 100 advert_int 1 virtual_ipaddress { 192.168.31.100/32 dev eth0 } track_script { chk_k8s } }对应的检查脚本内容也很简单,关键是它探的是真实链路,而不是只查通不通。
#!/bin/bash curl -sf http://127.0.0.1:30080/healthz >/dev/null 2>&1注意这里有个细节:如果某个服务暂时不可用,脚本会把当前节点的VRRP优先级降下来,触发VIP漂移到别的节点。但如果你在这个节点上同时跑很多个服务,某一个挂了就把VIP切走,可能反而会影响其他正常服务。实际生产里,我一般会按服务分组,或者把健康检查放到更上层的入口组件去做,Keepalived只负责“节点本身还活着”这个层面。
5.3 实在不想用Keepalived了,还有什么选择
说句实话,经历过这次排查后,我对Keepalived的态度有了一些变化。它本身没有错,错的是很多人(包括我自己)把它当成云负载均衡器的平替,却没有补齐健康检查和调度能力。
如果你内网场景不需要固定出口IP,直接用NodePort加DNS轮询都够了。如果需要固定IP,可以看看MetalLB这类为K8s设计的负载均衡方案,它知道怎么跟Service打交道,节点挂了也会自动摘除。
如果集群已经上了云环境,直接用云厂商的LoadBalancer或Nginx Ingress Controller,都比手动维护Keepalived省心。自建方案不是不行,而是要清楚地知道自己为它补了多少维护成本。
我的习惯是:内网小规模服务,能上externalIPs就上externalIPs;对外暴露的服务,老老实实用Ingress入口;只有那些对固定IP有硬性依赖、又不想引入额外组件的场景,才保留Keepalived+VIP的组合,但会同时把Pod亲和、Service策略、健康检查这几件事一次性配到位。