K8s网络下半场:Service转发链路与分层排障
很多人把 Pod 之间的网络打通之后,就以为 K8s 网络原理已经学完了,结果一上生产就卡在 Service 上:curl一个 ClusterIP 超时,ping又不通,翻文档翻半天只看到一句"这是虚拟 IP"。这次接着上篇的 Pod 网络往下讲,把 Service、kube-proxy、CoreDNS、跨节点隧道和 NetworkPolicy 这几块串成一条完整的转发链路。如果你已经能说清楚 veth pair 和网桥是怎么把同一个节点上的两个 Pod 连起来的,那这篇就是补全后半程;如果你还没碰过 Service 排障,也能从这里拿到一套可以直接照着走的分层排查方法。全文围绕 K8s 网络原理的下半场展开,重点是每个环节"为什么这么设计"以及"出问题时先看哪里"。
1. 从一次 ping 不通说起:Service 那个虚拟 IP 到底存不存在
1.1 ClusterIP 没有被任何网卡持有,它只是一组规则的"代号"
先破除一个最常见的误解。你在节点上执行ip addr,翻遍所有网卡都找不到10.96.0.1这个地址,但它确实能被访问。原因很直接:ClusterIP 从来就不是一个真实的网络接口地址,它是数据面组件在内核转发路径上埋下的一组匹配规则。当一个数据包的目的地址命中这组规则时,内核会在包离开协议栈之前把目的地址改写成某个后端 Pod 的 IP,这个过程叫 DNAT。
这就解释了两个现象。第一,ping一个 ClusterIP 通常不通,因为 Service 规则基本都带着协议和端口匹配条件,ICMP 报文根本没被规则捕获,自然也不会被改写,内核找不到目的地就把包丢了。第二,curl能通但traceroute看到的路径很奇怪,因为中间那一跳是内核内部的地址改写,不是真实的三层转发。
还有一个更隐蔽的点:ClusterIP 全程只存在于调用方节点的规则里,服务端节点完全不知道这个地址存在。所以你在后端 Pod 所在的节点上抓包,看到的源地址是调用方 Pod 的 IP(或者节点 IP,取决于有没有做 SNAT),永远看不到 ClusterIP。理解这一点,后面所有排障思路才立得住。
1.2 从 Endpoints 到 EndpointSlice:后端列表的载体换了一代
规则里要改写成什么地址,取决于后端 Pod 列表。早期这个列表存在一个叫 Endpoints 的对象里,一个 Service 对应一个 Endpoints,里面塞着所有就绪 Pod 的 IP 和端口。这个设计在中小规模下没问题,但一旦后端超过一千个,单个对象就会变成几百 KB 甚至上 MB,任何一次 watch 事件都要把整个对象推给所有监听方,控制面和数据面都会被拖垮。
现在主流版本已经换成 EndpointSlice,一个 Service 的后端按每片最多 100 个端点切成多个切片,每个切片独立更新。这个改动的收益在滚动发布时最明显:一个两千副本的服务做一次发布,旧方案下每次有 Pod 就绪都要推一遍完整列表,新方案下只推变化的那一片。数据面组件只需要关注自己收到的那部分变化。
这个演进对排障有个直接影响:查后端不能只kubectl get endpoints了,那个命令背后其实做了聚合,真正要看的是kubectl get endpointslice -l kubernetes.io/service-name=<服务名>。有些时候 Endpoints 显示正常但流量就是不通,问题就出在切片上的conditions.ready字段,这个字段的判定逻辑和 Endpoints 的并不完全一致。
2. kube-proxy 的三种数据面:iptables、IPVS 与它们的真实边界
2.1 iptables 模式下一次访问要穿过四段链
理解 iptables 模式最好的方式是跟着一个包走一遍。假设调用方 Pod 要访问10.96.0.10:80,包从 Pod 的 eth0 出来,经过 veth pair 进入宿主机的网桥,然后进入内核的 PREROUTING 链。因为目的地址不是本机地址,包会走 FORWARD 链,而 nat 表的规则正是在这里生效的。
第一段是KUBE-SERVICES,它按目的 IP 和端口做匹配,命中后跳到该服务专属的KUBE-SVC-xxxx链。第二段在这个服务链里做概率分流,每个后端对应一条-m statistic --mode random --probability的规则,用累进概率保证各后端被选中的机会均等。第三段跳到具体某个后端的KUBE-SEP-xxxx链,这里才真正执行 DNAT,把目的地址改成 Pod IP 和容器端口。第四段在 POSTROUTING 阶段,如果流量跨节点还需要做一次源地址伪装,保证回包能原路返回。
用命令看这套结构最直观:
iptables -t nat -L KUBE-SERVICES -n -v iptables -t nat -L KUBE-SVC-XXXXXXXX -n -v iptables -t nat -L KUBE-SEP-XXXXXXXX -n -v规则里的pkts计数是最有用的排障信号。如果KUBE-SERVICES这条的计数不涨,说明包根本没进到规则匹配阶段,问题在上游的路由或网络策略;如果涨了但KUBE-SVC不涨,说明服务的端口定义和实际访问的端口对不上;如果KUBE-SVC涨了而KUBE-SEP不涨,说明后端列表是空的,要去查就绪探针。
2.2 IPVS 模式为什么快,但在某些场景下反而更慢
iptables 模式的性能瓶颈不在匹配,而在更新。规则数量从几百涨到几千、几万之后,每次有 Pod 变动,数据面都要重新生成并原子替换整张表,这个过程的耗时随规则数线性增长。一个上万 Pod 的集群里,一次发布能让节点上的规则刷新耗时到秒级,这期间新建连接会明显抖动。
IPVS 用哈希表存转发规则,查找复杂度是常数级,增删单条规则也只影响一条。这就是它在超大集群里更受欢迎的原因。但它并不是无脑更快,有几个反直觉的点需要知道。
IPVS 本身只处理四层转发,源地址伪装、无后端时拒绝连接这类逻辑还得靠 iptables 辅助链兜底,所以 IPVS 模式下 iptables 规则并没有消失,只是从几万条压缩到几百条。另外 IPVS 的连接复用行为在某些短连接场景下会出问题,社区为相关参数的默认值反复调整过几轮,如果你在跑大量短连接的服务,升级前务必确认这个参数当前版本的行为。
ipvsadm -Ln --stats ipvsadm -Ln -t 10.96.0.10:80上面第二条命令能看到某个服务的实时连接数和流量计数,比翻 iptables 计数清爽得多。
2.3 切模式之后不会自动清理的残留规则
这是我踩过最坑的一次。集群从 iptables 模式换成 IPVS,改完配置重启数据面组件,服务看着是通的,但偶尔有一些老服务访问异常。折腾了很久才发现,旧的KUBE-SVC-*规则还留在 nat 表里,包在KUBE-SERVICES阶段就先命中了旧规则,被 DNAT 到一个已经被删除的后端地址上。
数据面组件启动时会做一次清理,但清理逻辑依赖注解标记,历史上不同版本打的标记格式变过,跨大版本升级时就会出现清不干净的情况。稳妥的做法是切换前先记下当前规则数,切换后人工比一次:
iptables -t nat -L KUBE-SERVICES -n | wc -l iptables -t nat -S | grep KUBE-SVC | wc -l如果 IPVS 模式下这两个数字还是几千,基本可以断定旧规则没清掉。清理不能直接iptables -F,那会把节点上其他组件的规则一起干掉,只能按KUBE-前缀逐条删除。更省事的办法是重启节点上的容器运行时之前的那个网络组件进程,让它重新走一遍全量同步,但生产环境要挑窗口。
注意:切换数据面模式属于会影响全集群的操作,务必先在非生产环境验证一遍完整流程,包括规则清理、连接中断时长和后端连通性。
3. 跨节点 Pod 通信:VXLAN 封装里丢掉的那部分预算
3.1 从路由表到隧道设备的完整转发路径
同节点的 Pod 通信靠网桥二层转发就够了,跨节点就没这么简单,因为两个节点之间的物理网络只认节点 IP。Overlay 型方案的思路是把原始报文整个塞进一个新的 UDP 包里,外层用节点 IP 做源和目的,这个封装动作由隧道设备完成。
具体路径是这样的:Pod 发出包,目的地址是另一个节点上的 Pod IP,包到达宿主机后被路由表判定为"下一跳是隧道设备",于是进入隧道接口。隧道设备按目标 Pod IP 查 FDB 表,找到对应节点的 IP,然后给原始报文加上 VXLAN 头和 UDP 头,外层目的 IP 设为对端节点 IP,从物理网卡发出去。对端节点收到 UDP 包,剥掉外层封装,把内层原始报文重新送进协议栈,路由到目标 Pod。
整个过程中,FDB 表是动态学习的,节点之间靠控制面同步 Pod IP 和节点 IP 的映射关系。这张表一旦有延迟或者条目老化,就会出现"新起的 Pod 在 A 节点,B 节点上的 Pod 访问不通"的现象,而等几秒又自己好了。这类问题的根因往往不在网络,在控制面同步链路上。
3.2 MTU 与 MSS:大包卡死的经典现场
封装是要占字节的。VXLAN 头 8 字节,外层 UDP 头 8 字节,外层 IP 头 20 字节,加起来 50 字节左右,如果底层还叠了别的隧道,开销还要再加。物理网卡 MTU 是 1500 的话,隧道接口的 MTU 就只能设成 1450。
问题在于,很多组件不会自动帮你把 MTU 算对。容器内的网卡、隧道设备、物理网卡三处的 MTU 必须形成一条递减的链条,任何一处没配对,就会出现一个非常迷惑的症状:curl小接口正常,传大文件卡死,ping大包丢包但小包正常。
判断方法很直接:
ping -M do -s 1472 <对端PodIP> # 1472 + 28 = 1500,通说明走的是 1500 ping -M do -s 1422 <对端PodIP> # 1422 + 28 = 1450,通说明隧道 MTU 是 1450如果 1500 不通而 1450 通,那就说明中间有封装但 MTU 没调。这时候要做的不是去改应用,而是把容器网络的 MTU 显式设成隧道 MTU 一致的值,同时确认 TCP MSS 被正确钳制。MSS 钳制没生效时,即使 MTU 对了,握手阶段协商出的 MSS 还是按 1500 算的,大包照样会被丢在隧道出口。
| 位置 | 典型 MTU | 常见错误 |
|---|---|---|
| 物理网卡 | 1500 | 被云厂商改成 1450 或 9001 但没同步到容器网络 |
| 隧道接口 | 1450 | 未随物理网卡 MTU 变化自动调整 |
| 容器内 eth0 | 1450 | 固定写死 1500,导致大包在隧道口被分片或丢弃 |
| TCP MSS | MTU-40 | 未做钳制,协商出 1460 造成黑洞 |
3.3 什么时候该放弃 Overlay 改用 BGP 直连
Overlay 的好处是不依赖底层网络,随便什么二层三层环境都能跑起来,代价是每次跨节点通信多两次封装解封装,还有那 50 字节的 MTU 开销。当集群规模上去、节点之间的东西向流量变成主要负载时,这两笔开销就很可观了。
另一条路是让每个节点把自己负责的 Pod 网段通过 BGP 通告给物理网络,报文直接按 Pod IP 路由,不做任何封装。好处是 MTU 不用动,路径上少两跳处理,抓包也直观得多。坏处是对底层网络有要求,物理交换机得能接住这些路由,节点数量大了之后路由表规模也会成为问题。
选型上我的经验是:节点数在几十台以内、底层网络可控、有专门的人维护交换机配置,优先考虑 BGP 直连;如果是多租户环境、底层网络不归自己管、节点数量弹性很大,老老实实用 Overlay,把 MTU 配好就行,别为了那点性能去折腾。
4. DNS 是 K8s 网络里最容易出问题的一环
4.1 一次服务名解析要走完哪些组件
应用里写http://order-service:8080能访问,靠的是集群内的 DNS。请求从 Pod 发出,先到 Pod 的/etc/resolv.conf里配置的 nameserver,这个地址通常是一个专门给 DNS 用的 ClusterIP。这个 ClusterIP 同样是被规则 DNAT 到某个 DNS 组件的 Pod 上,所以 DNS 解析本身就是一次完整的 Service 访问。
这意味着 DNS 出问题的原因可能是多层的:Pod 的 resolv.conf 写错了、DNS 服务的后端列表空了、DNS 组件自己挂了、DNS 服务到上游解析器不通、甚至只是 conntrack 表满了导致 UDP 包被丢。我见过最离谱的一次是节点上的 conntrack 表打满,表现是所有服务名解析超时,但curl直接写 IP 访问完全正常,排查方向一度跑偏到 DNS 组件上。
kubectl exec -it <pod> -- cat /etc/resolv.conf kubectl exec -it <pod> -- nslookup order-service.default.svc.cluster.local第一条看配置,第二条看实际解析。如果第二条能出结果,说明 DNS 链路是通的,问题在应用侧的缓存或者连接池。
4.2 ndots:5 带来的解析放大
默认的 resolv.conf 里有一行options ndots:5,它的含义是:如果域名里的点数少于 5 个,就先尝试拼接 search 域里的后缀逐个查询,全部失败后才把原始名字当作绝对域名再查一次。
这个设计对集群内短域名很友好,order-service这种写法能被自动补全成完整域名。但对访问外部服务的场景就是灾难:api.example.com只有 2 个点,小于 5,于是解析器会依次尝试api.example.com.default.svc.cluster.local、api.example.com.svc.cluster.local、api.example.com.cluster.local、api.example.com.<其他search域>,四次失败之后才去查真正的api.example.com。一次外部调用光 DNS 就要多发 4 个查询包。
在 QPS 高的服务里,这个放大效应非常明显,DNS 组件的负载会被这些无效查询顶上去。解决办法有两个:调用外部域名时在末尾加一个点写成api.example.com.,明确表示这是绝对域名;或者在 Pod 的 dnsConfig 里把 ndots 调小。
spec: dnsConfig: options: - name: ndots value: "2"调小 ndots 的副作用是集群内短域名可能解析失败,所以更稳妥的做法是让应用统一使用完整域名,或者分成两类工作负载,内部调用多的保持默认,外部调用多的调小。
4.3 短连接、超时与 conntrack 的连锁反应
UDP 没有连接概念,内核为了做 NAT 回写,会给每个 DNS 查询建一条 conntrack 记录,默认超时是 30 秒左右。如果每秒有几千次 DNS 查询,这个表会迅速膨胀。表满之后新连接直接被丢,表现出来就是间歇性的解析失败,而且失败率随负载波动,非常难复现。
同时 DNS 客户端的默认超时是 5 秒,重试 2 次。如果第一个查询包丢了,应用要等 5 秒才会重试,这时候上游的请求早就超时了。这就是为什么很多"偶发 5 秒延迟"的问题最后都追到 DNS 上。
几个可以立刻做的优化:把 conntrack 表的上限调大并在监控里盯着使用率;在节点上开启 UDP 的 conntrack 早期回收;应用侧对 DNS 结果做本地缓存,别每次都查。这三条我一般会一起上,单做一条效果有限。
5. NetworkPolicy 与 Ingress:流量进出集群的两道闸
5.1 NetworkPolicy 只是一份声明,落地完全看 CNI
很多人以为写个 NetworkPolicy 就自动生效了,实际上它只是一个 API 对象,真正执行拦截的是节点上的网络插件。不同插件对它的支持程度差异很大,有的只支持 Pod 选择器不支持命名空间选择器,有的对 egress 的支持是后来才补的,还有的在小版本里对端口段的处理有 bug。
更需要注意的是"默认拒绝"的语义。一个命名空间里只要有一条策略选中了某个 Pod,这个 Pod 的所有入向流量就默认被拒绝,只放行策略里明确写的东西。这个行为很多人第一次遇到会懵:只加了一条允许 A 访问 B 的策略,结果 C 访问 B 也断了。
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-order-to-db namespace: default spec: podSelector: matchLabels: app: order-db policyTypes: - Ingress ingress: - from: - podSelector: matchLabels: app: order-service ports: - protocol: TCP port: 5432这段策略的意思是:order-db 只接受 order-service 的 5432 入向流量。注意它没有写 egress,所以不影响出向。如果要做到"白名单之外全拒绝",还需要一条单独的默认拒绝策略配合。落地上我建议先在测试环境把策略加上,用真实流量跑一轮,观察一周的丢包日志再上生产。
5.2 Ingress 到 Service 再到 Pod:两跳负载均衡怎么配合
外部流量进来通常是 Ingress 控制器暴露的一个入口,控制器根据 Host 和 Path 规则把请求转发到对应的 Service,然后由 Service 的数据面转发到某个 Pod。这里存在两跳独立的负载均衡,第一跳在控制器内部,第二跳在节点的规则层。
这带来一个常见问题:客户端的真实 IP 在第二跳之后就丢了,后端看到的源地址是控制器的 Pod IP 或者节点 IP。要保留真实 IP,需要在控制器侧开启相关配置,并在 Service 上设置合适的流量策略。设置成节点本地模式时,流量只会转发到本节点的 Pod,能少一跳但会带来负载不均;设置成集群模式时,转发均匀但多一次跨节点跳转。
还有一个细节容易被忽略:Ingress 控制器到后端默认走的是 HTTP 短连接,如果后端服务的 keepalive 配置不当,会出现大量 TIME_WAIT 堆积。这种情况表现出来的现象是高峰期新建连接失败,而服务本身 CPU 和内存都很空闲。
6. 一次 Service 访问不通的完整排查链路
6.1 分层排除:从名字到规则一层层往下剥
遇到访问不通,我的顺序是先确认"是名字的问题还是地址的问题"。在同一个 Pod 里,先直接用 ClusterIP 访问,再用服务名访问。如果 IP 通名字不通,问题在 DNS,去查 resolv.conf 和 DNS 组件;如果两个都不通,继续往下。
第二步确认后端列表。kubectl get endpointslice -l kubernetes.io/service-name=<名字>看有没有就绪端点,一个都没有的话问题在就绪探针或者标签选择器,跟网络没关系。
第三步确认规则。在调用方所在的节点上看KUBE-SERVICES和KUBE-SVC的计数,访问一次看数字涨不涨,不涨说明包没走到规则匹配这一步。
第四步确认回程。很多"不通"其实是单向的,去程正常回程被丢。在后端 Pod 上抓包,能看到请求进来但没有响应发出去,那问题在后端应用或者它的出向策略。
这套顺序的价值在于每一步都能明确排除掉一批可能性,而不是东一榔头西一棒子。
6.2 抓包位置选错了,看到的全是假象
抓包位置是排障里最容易出错的一环。很多人在宿主机物理网卡上抓,看到包进来了又出去了,就认为网络没问题,实际上包可能只是被转发到了错误的网桥上。
正确的抓包点得跟着转发路径走:先在调用方 Pod 的 eth0 抓,确认包发出去了;再在宿主机网桥上抓,确认包进了内核;再在隧道设备上抓,看原始包和封装包;最后在后端 Pod 的 eth0 抓,确认包到了。四段对比下来,哪一段断了就一目了然。
# 在 Pod 内抓(需要容器里有抓包工具) tcpdump -i eth0 -nn host <目标IP> # 在节点上抓隧道流量 tcpdump -i <隧道设备> -nn port 8472 # 抓封装后的物理流量 tcpdump -i eth0 -nn udp and port 8472有个细节要注意:在隧道设备上抓到的包已经是解封装后的,跟物理网卡上看到的完全不是一回事。如果只抓物理网卡,看到的是 UDP 包,不看内层根本判断不出是哪个服务在通信。
6.3 配置全对但还是不通的那些隐形原因
排查到最后,配置都核对过了还是不通的情况,通常栽在几个地方。
第一个是节点的本地规则和集群状态不一致。控制面已经更新了,但某个节点的数据面组件因为 watch 断连没有收到最新事件,规则还停留在旧状态。这时候重启那个节点的数据面进程就能恢复,但根因要去查 watch 链路的稳定性。
第二个是网络策略和服务的规则冲突。策略在更早的链路上把包拦掉了,服务的计数根本不涨,看起来像服务本身的问题。
第三个是连接跟踪表的残留。改了后端端口之后,旧的 conntrack 记录还在,同一个五元组的流量继续被转到旧地址,要等记录超时或者手动清理才恢复。
第四个是节点之间的时钟和会话保持问题。开了会话保持的服务在节点重启后,客户端带着旧的会话标识来,被分到了另一个后端,表现是偶发的 401 或者数据不一致。
这几个原因我基本上每次大规模变更后都会主动查一遍,尤其是 conntrack 残留那条,改完配置一定要做一次主动的连接清理验证,不要指望它自己好。
最后分享一个我个人一直在用的习惯:每次改集群网络相关的配置之前,先把当前的关键状态存一份快照,包括iptables -t nat -S的完整输出、ipvsadm -Ln的结果、各节点的路由表、以及几个核心服务的连通性测试结果。改完之后拿新状态跟快照比,差异一眼就能看出来。这个习惯帮我省过好几次因为规则残留导致的深夜排查,比任何监控告警都直接。