1. ClusterIP 的两张脸:“固定的虚拟地址”和“数据面的魔术规则”
1.1 ping 不通 ClusterIP,不代表集群故障
先说一个我踩过的坑。早年间有同事半夜发消息过来,说业务方反馈访问不通,他第一反应是 ping <ClusterIP>,发现确实不通,于是判断 Service 挂了。等我爬起来一看,Pod 全是 Running,Endpoints 也齐,业务五分钟后就自己恢复了——问题其实出在对方客户端短暂抖动,跟集群一点关系都没有。
这个经历说明一个很核心的认知:ClusterIP 不是一个可以被随便 ping 通的 IP 地址,它更像是一系列数据面规则的入口。在绝大多数 K8s 集群里,你从节点上 ip addr 是看不到这个 IP 的,它不在任何网卡上、不属于任何节点、也不参与路由协议。ClusterIP 的意义不是“能被 ping 通”,而是“在约定的协议和端口上,能通过 NAT 规则把流量导到真实后端 Pod”。
所以后续我带人排障时,会先纠正一个直觉:ping ClusterIP 失败是正常现象,不代表服务异常。真正要验证的是 ClusterIP 对应的 TCP/UDP 端口是否能通,比如用 nc -vz 10.96.0.5 80 或者 curl 去测。如果协议不匹配,甚至可能表现出“这个 IP 完全不存在”的样子,因为 kube-proxy 生成规则时只关心 Service 里声明的协议。
1.2 地址从哪来:分配机制和生命周期
ClusterIP 的分配由 API Server 负责。API Server 启动时带 --service-cluster-ip-range 参数,kubeadm 部署的话对应 networking.serviceSubnet,默认一般是 10.96.0.0/12。你在创建 Service 时,如果不显式指定 spec.clusterIP,API Server 就会从这个地址池里动态挑一个;如果你显式指定,则必须落在该地址池范围内,否则会被拒绝。
这里有两个容易让新手困惑的点。第一个是“分配和交流”:动态分配时,API Server 内部维护着一张已分配 IP 的分配表,确保同一地址池里不会出现两个 Service 共用同一个 ClusterIP。第二个是“生命周期”:只要 Service 对象没被删除,ClusterIP 就不会变化;哪怕后端 Pod 全部滚了一遍、重建了无数次,这个 IP 依然是同一个。可一旦 Service 被删掉,这个 IP 就会回到地址池,下次再建同名 Service,分配的 IP 大概率不是原来的那个。
DNS name 也是随着 Service 的存活而存在,所以我们在生产环境里一直强调:对外访问请用 Service 的 DNS 名,而不是把 ClusterIP 写死在配置里。不然运维一删一建,IP 变了,配置不跟着改,故障就出来了。
你可能还会遇到一种看起来“不太常见”的场景:Service 的 type 从 ClusterIP 改成 NodePort,ClusterIP 不会变;但如果把 Service 删了重建,IP 就会变。业务方的配置里如果写死了 IP,等他们发现时往往已经是事故现场了。
1.3 路由表里的尴尬位置:不可路由,但可以被规则“接住”
ClusterIP 为什么不参与路由?因为它不属于任何节点接口,也没有物理设备在监听这个网段。假设一个数据包的目的地址是 10.96.0.5,在没有 NAT 规则介入的情况下,节点查路由表会把它当作外部网段,往前走默认路由,最后在网络黑洞里消失。
真实能通,靠的是 kube-proxy 写入的 iptables/ipvs 规则,在数据包进入本机网络栈的早期阶段就把目标地址改写掉。所以从链路视角来看,ClusterIP 这一层更像一个“中转标签”:它存在的价值是给客户端一个非常稳定的目的地。Pod 会死、会重建、IP 会变,但 Service 的 ClusterIP 不变,客户端只需要记着这个标签。
这就引出了整篇文章真正想讲透的东西:ClusterIP 本身不承担流量,承担流量的是数据面规则;ClusterIP 只是规则的索引键。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一次请求的完整旅程:走进数据面后看到的 ClusterIP 真相
2.1 从 Pod 到 Service:多个网络组件依次登场
假设现在集群里有两个 Pod,Pod A 要访问 Service S 的 ClusterIP 10.96.0.5:80,后端是两个 Pod B1、B2。我们把这个过程拖慢看。
Pod A 发出的数据包先经过自己的 eth0,穿过 veth 对进入节点侧的网络栈。这里第一个关键点是:数据包从 veth 进入节点宿主机时,会先经过 netfilter 的 PREROUTING 钩子。kube-proxy 在 iptables 模式下,会把 KUBE-SERVICES 链同时挂在 PREROUTING 和 OUTPUT 上。这样无论数据包是从别的节点转进来的、从 Pod 转发上来的,还是宿主机自己进程发出的,都能第一时间被规则“接住”。
接着就是 ClusterIP 的“魔法时刻”。数据包进入 KUBE-SERVICES 链后,匹配 Service 对应的 KUBE-SVC-XXX 链。KUBE-SVC 链会根据后端 Pod 数量生成带 --probability 的跳转规则,比如有两个后端时,第一条规则概率 0.5,跳到 KUBE-SEP-XXX;没被选中就落到第二条,跳到另一个 KUBE-SEP-YYY。
真正改写目标地址的动作发生在 KUBE-SEP-XXX 链里,执行的是 DNAT:把 10.96.0.5:80 改成某个 Pod 的 IP 和端口,比如 10.244.1.10:8080。注意看,这一步发生在路由决策之前,所以就绕开了“ClusterIP 不可路由”的尴尬:等路由表看到这个包时,目的地址已经是 Pod IP 了,它自然知道该往哪个节点、哪条 veth 送。
2.2 conntrack:保证回程流量能“变回原样”
只做 DNAT 是不够的,否则 Pod B1 收到的请求源地址还是 Pod A 的 IP,但它的回程包目的地址是 Pod A,根本没地方记录“我把请求伪装过”。Linux 内核对 NAT 的处理依赖 conntrack,也就是连接跟踪表。
在数据包第一次经过 NAT 时,conntrack 会记录这条流的原始五元组和转换后的五元组。Pod B1 回包时,内核看到目的地址是 Pod A,但这和 conntrack 里记录的“原始连接”是反向的,于是执行反向转换:源地址从 Pod B1 的 IP 重新映射成 ClusterIP 10.96.0.5,然后按正常路径送回 Pod A。这样业务层看到的逻辑就是:Pod A 访问了 ClusterIP,ClusterIP 回了包,源和目标都符合直觉。
这里引出一个非常重要的行为特征:ClusterIP 的负载均衡是连接级别的,不是请求级别的。同一连接里的所有数据包,在 conntrack 记录建立之后,都会走同一个后端 Pod,不会中途换人。如果你想让应用层每次请求都落在不同 Pod,得靠客户端自己发起多条连接,或者使用 Session Affinity 反过来钉死同一个后端。
2.3 抓包经验:别用“只看 ClusterIP”的方式去抓
很多人在排障时会写下类似 tcpdump -i eth0 host 10.96.0.5 的命令,然后在节点上等了半天,发现一个包也没抓到,于是得出“流量没到这里”的结论。问题是,NAT 之后包的目的地址已经被改写成 Pod IP,你到底在哪个 hook 点抓包、抓的是 DNAT 前还是 DNAT 后的包,直接决定了你能不能看到 ClusterIP。
我的建议是放弃“只盯 ClusterIP”这种抓法,改成三路并抓:先在 Pod A 内部抓 eth0,确认客户端确实发出了目标为 ClusterIP 的包;再在 Pod B1/B2 所在节点上,抓 veth 一侧的流量;最后在节点上用 conntrack -L -n | grep <ClusterIP> 观察 NAT 表项。三路信息拼起来,才能确认问题出在“发出去了吗”“走了哪条链”“有没有被 DNAT”“conntrack 是否正常”这四个环节中的哪一个。
3. kube-proxy 三种模式横评:iptables 默认,但 ipvs 才是大集群终点
3.1 userspace:只出现在老书里的“代理进程”
K8s 最早期的 kube-proxy 是通过用户态进程来做 TCP/UDP 代理的。kube-proxy 监听 Service 的端口,把数据包从用户态再转发给后端 Pod。这种做法逻辑简单,问题是性能很差,因为每个流量都要经历“内核态到用户态再到内核态”的两趟拷贝。现在的集群里基本没人再用这个模式,但这个模式的历史意义在于:它让 K8s 早期版本能跑通服务发现和代理功能,后来才被性能和可靠性更好的内核态方案取代。
3.2 iptables 模式:“链式随机”如何支撑负载均衡
iptables 模式是大多数默认集群选择的模式,也是我排障时接触最多的一种。它依靠一堆规则链:KUBE-SERVICES 负责入口匹配,KUBE-SVC-<hash> 负责选后端,KUBE-SEP-<hash> 负责具体执行 DNAT。
选后端的逻辑是概率规则。以三个后端为例,链里大概长这样:
bash复制-A KUBE-SVC-XYZ -m statistic --mode random --probability 0.3333333333 -j KUBE-SEP-A
-A KUBE-SVC-XYZ -m statistic --mode random --probability 0.5000000000 -j KUBE-SEP-B
-A KUBE-SVC-XYZ -j KUBE-SEP-C
第一条命中概率约三分之一,第二条约剩余三分之一的二分之一,加起来每条后端被选中的概率大约一样。这个方案成熟稳定,绝大部分情况下够用,但它有一个显著痛点:集群里 Service 数量一多,iptables 规则数量会爆炸式增长,因为每个后端都要产生链和规则。新增或删除一个后端时,kube-proxy 要更新一大片规则,节点 CPU 会瞬时飙高,甚至出现规则更新期间一小段“抖动”。
iptables 模式还有一个细节值得记住:如果 Service 没有任何可用后端,kube-proxy 会在链尾放一条 REJECT 规则,访问时直接拿到 TCP RST,而不是长时间挂死。所以“连接被拒绝”有可能是真的没有后端 Pod,而不是网络不通。
3.3 ipvs 模式:内核态的调度器,高并发下的更优选
ipvs 模式把负载均衡从“规则链遍历”变成了“哈希转发表”。kube-proxy 在节点上创建虚拟服务,虚拟服务的 IP 就是 ClusterIP,后端是真实 Pod IP。数据包到达节点后,内核 IPVS 模块根据调度算法直接挑选后端,不再需要通过一串 Chain 逐条匹配。
对比一下两种模式的效果:
| 对比项 | iptables 模式 | ipvs 模式 |
|---|---|---|
| 规则数量 | 随 Service 和后端数量线性膨胀 | 使用哈希表,规则量可控 |
| 负载均衡算法 | 概率链,实际效果接近随机 | 支持 rr、lc、dh、sh 等多种算法 |
| 更新时效 | 规则链重建,CPU 开销较大 | 直接维护转发表,后端变更更快 |
| 依赖条件 | 内核 netfilter 自带 | 需要加载 ip_vs 相关内核模块 |
| 适合场景 | 中小规模、默认省心 | 大规模集群、高密度 Service |
大规模集群里,后端 Pod 频繁扩缩容时,ipvs 模式的平滑度明显更好。这也是我目前在生产的推荐选择。不过切换前必须确认节点内核加载了 ip_vs、ip_vs_rr、ip_vs_wrr 等模块,否则 kube-proxy 会启动失败,报找不到模块的错误。
3.4 模式切换的实操套路
切换 kube-proxy 模式不需要动业务,只动 kube-proxy 本身的配置。通常是修改 kube-proxy 的 ConfigMap 里的 mode 字段,从空值或 iptables 改成 ipvs,然后滚动重启 kube-proxy DaemonSet。切换期间已经建立的连接会有瞬断,所以建议在低峰期做。
切换完成后,验证方式很简单。先确认进程起来了:
bash复制kubectl logs -n kube-system kube-proxy-xxxxx | grep "Using ipvs Proxier"
再用 ipvsadm 看虚拟服务和后端:
bash复制ipvsadm -L -n
能看到类似 TCP 10.96.0.5:80 rr -> 10.244.1.10:8080 -> 10.244.2.11:8080 的输出,说明虚拟服务已经建立。如果发现 IPVS 的调度算法不太均匀,可以在 kube-proxy 的启动参数里指定 --ipvs-scheduler=lc 之类的算法,不过大多数场景默认的 rr 已经够用了。
4. 服务发现的另一块拼图:环境变量、DNS 和 Headless Service
4.1 环境变量:一个“先有鸡还是先有蛋”的问题
K8s 早期版本里,服务发现非常依赖环境变量。当 Pod 创建时,kubelet 会把集群里已存在的 Service 信息注入到 Pod 的环境变量中,格式类似 MY_SERVICE_SERVICE_HOST=10.96.0.5 和 MY_SERVICE_SERVICE_PORT=80。
陷阱在于:Pod 创建时,如果 Service 还不存在,环境变量就不会注入。只要你调整过 Deployment 的滚动更新顺序,或者 Service 创建晚于 Pod,业务进程里就永远看不到对应的环境变量。这也是我很少建议大家在生产里依赖环境变量做发现的原因——顺序敏感、信息更新不及时、还会污染环境变量表。
4.2 DNS 记录:平时用短名,排障时报完整 FQDN
现在主流方案是 CoreDNS 提供集群内 DNS 解析。每个 Service 都会有一条 A 记录,完整 FQDN 格式是:
text复制<service-name>.<namespace>.svc.<cluster-domain>
默认集群域是 cluster.local,所以一个叫 order-service、命名空间 prod 的 Service,完整域名是 order-service.prod.svc.cluster.local。Pod 的 /etc/resolv.conf 里配置了 search 搜索域,所以你在业务里直接写 order-service 或 order-service.prod 短名也能解析成功。
排障时比较有用的是 SRV 记录。如果你定义了多端口 Service,并且端口有名字,CoreDNS 会生成类似 _http._tcp.order-service.prod.svc.cluster.local 的 SRV 记录:
bash复制dig SRV _http._tcp.order-service.prod.svc.cluster.local
这个命令能直接告诉你服务的端口和后端地址,比翻配置文件快得多。而且有个容易忽略的点:只有带命名的端口才生成 SRV 记录,如果你写 Service 时图省事只写了 port: 8080,没有 name: http,那 SRV 记录是查不到东西的。
4.3 Headless Service:不加 VIP,却给有状态服务指明道路
Service 的 clusterIP 字段显式设为 None 时,创建的就是 Headless Service。它不分配 ClusterIP,kube-proxy 也不会为它创建任何虚拟服务。它存在的意义是让 DNS 直接返回后端的 Pod IP 列表。
这对有状态服务特别友好。以 StatefulSet 为例,后端 Pod 的主机名是稳定的,如果再配上 Headless Service,DNS 里会出现:
text复制pod-0.statefulset-svc.prod.svc.cluster.local. IN A 10.244.1.10
pod-1.statefulset-svc.prod.svc.cluster.local. IN A 10.244.2.11
业务方可以通过这个稳定的域名找到指定物理副本。这在数据库集群、依赖角色定位的场景里非常常用:主节点通过 pod-0 的域名被找到,从节点通过 pod-1、pod-2 的域名被找到。
另一个用法是把 Headless Service 当作自定义负载均衡的入口。因为 DNS 一次查询会返回多个 A 记录,客户端如果自己实现了连接池和重试机制,就能直接在客户端侧做调度,不再依赖 kube-proxy 的转发逻辑。不过要注意,这要求客户端理解并处理多条 IP 的返回结果,不是所有应用都能做到。
5. ClusterIP 不通的排查链路:从 endpoints 一路追到 conntrack
5.1 第一步:先看 endpoints 和 selector,而不是先看 iptables
一遇到 ClusterIP“不通”,很多人的第一反应是上节点查 iptables。我觉得这是错误顺序。最快的一步永远是:
bash复制kubectl get svc -n <namespace>
kubectl get endpoints -n <namespace>
如果 Endpoints 里没有 IP,那就是 Service 的 selector 和 Pod 标签没对齐,或者 Pod 不健康没进入 Ready 状态。这时候去掏 iptables 是浪费时间,因为 kube-proxy 根本没拿到可用后端,自然不会生成正常的 KUBE-SEP 链。先看这两个对象,90% 的“不通”都能直接定位。
检查完 Endpoints,再直接验证后端的 Pod 本身通不通:
bash复制kubectl exec -it <client-pod> -- curl <pod-ip>:<port>
连 Pod IP 都不通,说明问题根本不在 ClusterIP,而在 Pod 网络或应用监听。后端 Pod 没问题,再往数据面走。
5.2 第二步:用 conntrack 检查 NAT 是否真的发生
很多情况下,Service 和 Pod 都正常,但访问就是超时。到这一步,我会进节点用 conntrack 观察实际连接。
先模拟一次访问,让 client Pod 去请求 ClusterIP,然后在任一节点上执行:
bash复制conntrack -L -n | grep <cluster-ip>
如果输出里有类似:
text复制tcp 6 431999 ESTABLISHED src=10.244.0.8 dst=10.96.0.5 sport=12345 dport=80 src=10.244.1.10 dst=10.244.0.8 sport=8080 dport=12345 [ASSURED]
说明包确实到达了节点、DNAT 也执行了,后面那段 IP 就是转换后的后端 Pod 地址。如果客户端访问后 conntrack 里只有一行 [UNREPLIED],说明请求发了过去但后端没回包;如果连记录都没有,那数据包根本没进这个节点的网络栈,可能是路由没发到这台节点。
这里有个细节:conntrack 条目里看到的目标地址仍然是 ClusterIP,是正常的,因为 conntrack 记录的是 NAT 前后的映射关系。不要误以为“规则没生效”。
5.3 第三步:查 kube-proxy 和内核 conntrack 表的隐雷
数据面规则随机消失的经典原因是 kube-proxy 自己出了问题。查看 kube-proxy 日志通常会看到 Failed to ensure that the node:XXX has kernel module 或规则同步失败之类的内容。如果 kube-proxy 的同步循环卡住,iptables 链里可能残留旧规则甚至空规则,表现为“一部分流量走新 Pod,一部分还指向旧 Pod”。
另一个很容易踩到的是 conntrack 表满。内核默认的 nf_conntrack_max 有时不够用,尤其在高并发短连接场景下。一旦 conntrack 表满了,新连接无法创建,数据包直接被丢,业务表现就是“时通时不通”“重启 Pod 后短暂恢复”。判断方法:
bash复制conntrack -S
看 insert_failed 和 drop 计数,如果持续增长,基本可以实锤。再配合:
bash复制dmesg -T | grep conntrack
能直接看到类似 nf_conntrack: table full, dropping packet 的日志。解决方案是调大 net.netfilter.nf_conntrack_max,同时检查业务侧是不是短连接太多、TIME_WAIT 堆积严重。
5.4 跨节点流量下的“源 IP 之谜”
当客户端 Pod 和后端 Pod 不在同一个节点时,流量会经过节点间转发。这时会涉及另一个容易误解的问题:源 IP 到底是谁?
在大多数默认 CNI 方案里,跨节点流量会用隧道封装或者路由直连。你从后端 Pod 里看来源,有时是客户端 Pod 的 IP,有时是客户端节点的 IP,取决于 CNI 是否做了 SNAT/MASQUERADE。如果你在网络策略里限制了来源 IP,就很容易出现“从某台节点来的流量可以,从另一些节点来的却被拒”这种奇怪现象。
排这类问题,我的建议是别纠结于“标准答案”,而是用实验确认实际路径。在后端 Pod 里起一个临时 HTTP 服务,记录请求的源 IP,再从不同节点的客户端分别访问,把结果表对照起来看。源 IP 不一致不是配置错误,它只是不同数据面策略的正常结果,关键是你是否清楚当前集群属于哪一种。
6. 生产环境持续运行后,我更想叮嘱的三件事
6.1 别忽略 IPVS 依赖的内核模块
默认的 iptables 模式对内核没有任何特殊要求,但切到 ipvs 后就不是这样了。建议在节点上提前确认并加载模块:
bash复制modprobe ip_vs
modprobe ip_vs_rr
modprobe ip_vs_wrr
modprobe ip_vs_sh
并让它们开机自动加载。否则节点重启后,kube-proxy 会陷入“起不来-重试-失败”的循环,表象就是某台节点上的 ClusterIP 访问全部异常,而其他节点一切正常。这种“单节点网络故障”最容易误导人,最后发现原因就是内核模块没加载。
6.2 滚动更新和大规模伸缩时注意连接残留
后端 Pod 被替换或者缩容时,conntrack 表里可能还残留着指向旧 Pod IP 的 NAT 条目。表现在业务上就是:Deployment 滚动更新完成的瞬间,部分连接出现超时或 RST。这不是规则错误,而是连接状态还没过期。
减小影响的常规做法是把滚动更新的 maxSurge 和 maxUnavailable 设置得缓和一点,同时给 Pod 预留足够长的优雅终止时间,让存量连接有时间自然老化。如果业务对中断特别敏感,再加一层客户端重试机制,基本就能把影响降到可接受范围。
6.3 别亲手改 kube-proxy 生成的链
最后一条经验非常朴素,但值得反复强调:不要手动去改 kube-proxy 生成的 iptables 链。kube-proxy 在检测到 Service 或 Endpoints 变化时会全量重建规则,你手工加的那条规则要么被覆盖,要么导致规则顺序错乱,反而制造更难排查的故障。
如果你的需求是自定义一些转发逻辑,正确做法是在 KUBE-SERVICES 之前插入自己的独立链,保证 kube-proxy 重建时不会影响你那条链。不过我会更推荐用真正的网络策略、负载均衡器或 Ingress 来做这类控制,而不是在节点的 iptables 上做文章。
在 K8s 里待得越久,我越觉得 ClusterIP 就是整个服务模型的地基。它看着不起眼,配置也就一行,可一旦深入数据面,你会发现后面连着的是 NAT、conntrack、内核调度模块和 DNS 解析这一整条链路。先把这条链路的每个环节都想明白,以后再遇到“网络不通”,至少你能准确说出问题到底出在哪一段,而不是靠玄学和重启碰运气。
