
如果有一天你发现K8s集群里用Keepalived漂移出来的VIPNodePort死活访问不通而直接用节点IP加NodePort却一切正常别急着怀疑防火墙。我最近就在一套3节点的集群上踩中了这个问题排查链路很长根因却特别简单外部流量永远只进VIP所在的节点而Service的转发策略要求流量必须落在存在Pod的节点上。这两头一错位黑洞就出现了。这篇文章把我当时的完整排障过程、涉及到的K8s转发机制、以及最后怎么从“临时恢复”改到“稳定根治”的过程写下来。如果你也在用Keepalived这类方式给K8s服务做入口或者正准备在集群里搞VIP这篇值得你花十分钟看完。1. 故障复现节点IP能通、VIP不能通的那一刻1.1 先说集群长什么样这套集群一共3个节点代号N1、N2、N3都是kubeadm装的网络插件用的Calicokube-proxy跑的是IPVS模式。为了给外部一个固定的入口节点上装了Keepalived3个节点组成一个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 几个快速实验先把故障边界画出来我当时没有一上来就翻文档而是先在集群外部的客户端机器上做了四个探测。# 实验1ping 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. 查到一个关键配置externalTrafficPolicyLocal2.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策略会把没有本地后端的节点变成黑洞最终结果就是VIPNodePort不通。最直接的应急动作有两个方向要么让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入口行为externalTrafficPolicyLocal最终效果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_filter0生产环境不建议长期全局关闭建议只对入口网卡或者专门路由表做精确控制不然容易引入其他安全隐患。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 21注意这里有个细节如果某个服务暂时不可用脚本会把当前节点的VRRP优先级降下来触发VIP漂移到别的节点。但如果你在这个节点上同时跑很多个服务某一个挂了就把VIP切走可能反而会影响其他正常服务。实际生产里我一般会按服务分组或者把健康检查放到更上层的入口组件去做Keepalived只负责“节点本身还活着”这个层面。5.3 实在不想用Keepalived了还有什么选择说句实话经历过这次排查后我对Keepalived的态度有了一些变化。它本身没有错错的是很多人包括我自己把它当成云负载均衡器的平替却没有补齐健康检查和调度能力。如果你内网场景不需要固定出口IP直接用NodePort加DNS轮询都够了。如果需要固定IP可以看看MetalLB这类为K8s设计的负载均衡方案它知道怎么跟Service打交道节点挂了也会自动摘除。如果集群已经上了云环境直接用云厂商的LoadBalancer或Nginx Ingress Controller都比手动维护Keepalived省心。自建方案不是不行而是要清楚地知道自己为它补了多少维护成本。我的习惯是内网小规模服务能上externalIPs就上externalIPs对外暴露的服务老老实实用Ingress入口只有那些对固定IP有硬性依赖、又不想引入额外组件的场景才保留KeepalivedVIP的组合但会同时把Pod亲和、Service策略、健康检查这几件事一次性配到位。