记一次 K3s 网络深坑:为什么宿主机访问 ClusterIP 会超时丢包#
TL;DR#
由于旧版内核(3.10)与网卡硬件校验卸载(Checksum Offload)不兼容,导致跨节点 VXLAN 封包的 UDP Checksum 错误,被目标节点内核拦截。
故障复现环境
- OS: CentOS 7 (Kernel 3.10)
- K8s Distro:
- Client Version:
v1.18.3-k3s1 - Server Version:
v1.18.3-k3s1
- CNI: Flannel (VXLAN)
网络拓扑资源
- 宿主机 Node IP:
10.66.3.15 (Gateway: 10.66.3.125) - Service CIDR:
10.43.0.0/16 - Pod CIDR:
10.42.0.0/16 - 目标服务:
spark-webui (ClusterIP: 10.43.236.230:8080)
1
2
3
4
5
6
| [root@master-node ~]# kubectl get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
worker-1 Ready <none> 4y52d v1.18.3-k3s1 10.66.3.18 <none> CentOS Linux 7 (Core) 3.10.0-862.14.4.el7.x86_64 docker://18.6.3
worker-2 Ready <none> 50d v1.18.3-k3s1 10.66.3.34 <none> CentOS Linux 7 (Core) 3.10.0-862.14.4.el7.x86_64 docker://18.6.3
master-node Ready master 4y242d v1.18.3-k3s1 10.66.3.15 <none> CentOS Linux 7 (Core) 3.10.0-862.14.4.el7.x86_64 docker://18.6.3
worker-3 Ready <none> 50d v1.18.3-k3s1 10.66.27.195 <none> CentOS Linux 7 (Core) 3.10.0-862.14.4.el7.x86_64 docker://18.6.3
|
故障现象记录与排查
在某个客户的环境中
宿主机上执行 spark 资源监控相关脚本时,执行curl -m 10 http://$service_ip:8080/json/(这里的service_ip通过kubectl get svc|awk '{print $3}'获取)
通过 ClusterIP试图网络直连容器服务时,出现了完全不通的问题:
1
2
| [root@master-node ~]# kubectl get svc|awk '{print $3}'
10.43.236.230
|
我的疑问#
为什么在宿主机上使用 curl -m 10 http://10.43.236.230:8080/json/(spark-webui 的 ClusterIP)访问容器服务时,会失败或超时,而直接访问 PodIP 却能瞬间返回?
正常链路是怎样的#
在进入具体排查之前,我先讲讲基本概念,以及宿主机访问 ClusterIP 都是怎么走的。(主要是我自己一开始也是完全不懂后面的内容)
什么是 host network namespace#
Linux 里的每个 network namespace 都可以理解成一套彼此隔离的小网络世界,里面有自己的:
目前 Linux 主要支持以下几种隔离维度:#
- PID Namespace: 进程编号隔离。容器内的进程看到的自己 PID 是 1,但宿主机上看其实是一个普通 PID。
- Net Namespace: 网络隔离。每个容器有独立的虚拟网卡、IP 地址和路由表。
- Mount Namespace: 挂载点隔离。容器拥有独立的文件系统根目录。
- UTS Namespace: 主机名与域名隔离。
- IPC Namespace: 进程间通信隔离。
- User Namespace: 用户和用户组隔离(让容器内的 root 实际上只是宿主机上的普通用户)。
Kubernetes 里大多数 Pod 都在各自独立的 network namespace 里
而在宿主机上执行的:
1
| curl http://10.43.236.230:8080/json/
|
这个 curl 进程所在的,就是宿主机自己的网络空间,也就是 host network namespace。
而我在这里科普这个概念,主要是为了关注:
- 宿主机自己发起的流量,重点看本机
OUTPUT 路径; - Pod 或转发流量,更多会经过
PREROUTING / FORWARD 之类的路径。
也就是:
宿主机进程自己发包时,kube-proxy 是怎么在本机把 ClusterIP 处理成真实后端的。
什么是 DNAT#
DNAT 是 Destination NAT,也就是 目标地址转换。
在 Kubernetes 里,ClusterIP 是一个虚拟服务地址,不是真实 Pod 的地址。
所以当应用访问:
的时候,kube-proxy 需要在包真正发出去之前,把它改写成某个真实后端,比如:
这个“把目标从 ClusterIP 改成 PodIP”的动作,就是 DNAT。
可以把它理解成:
- 调用方以为自己访问的是
Service - 但底层真正转发时,已经把目标改成了某个具体
Pod
所以:
ClusterIP 能正常工作,本身就依赖 DNAT 先发生。
正常情况下,宿主机访问 spark-webui 的链路应该怎么走#
以这次环境为例:
- 源宿主机:
10.66.3.15 - Service:
10.43.236.230:8080 - 后端 Pod:
10.42.2.18:8080 - Pod 所在目标节点:
10.66.27.195
正常有这么 6 步:
第 1 步:宿主机进程先发起一个到 ClusterIP 的请求#
也就是:
1
| curl http://10.43.236.230:8080/json/
|
从应用视角看,它的目标就是 10.43.236.230:8080。
第 2 步:本机 kube-proxy 在 OUTPUT 路径上拦截这条流#
因为这个请求是宿主机自己发起的,所以重点不是看“转发别人流量”,而是看本机输出路径。
kube-proxy 会在这里判断:
- 目标是不是某个 Service 的
ClusterIP - 端口是不是这个 Service 暴露的端口
- 要不要把它转到某个真实 Endpoint
第 3 步:发生 DNAT#
也就是把:
改写成:
这也是我后面排查时,我会使用 conntrack的原因
因为 conntrack 能同时看到“原始目标 ClusterIP”和“实际 reply tuple 已经变成 PodIP”。
这里先解释一下什么是 conntrack。
简单来说,conntrack 是 Linux 内核中的一个模块,负责记录和追踪当前系统上所有的网络连接状态。
它会记住一条流从进入内核到离开内核时,经历过哪些 NAT 改写,以及当前处在什么 TCP/UDP 状态(可以说是网络排查神器了)
在这里,conntrack 能做三件事:
原始请求是谁发给谁
- 例如:
src=10.66.3.15 dst=10.43.236.230 sport=41021 dport=8080 - 这说明调用方最初访问的确实是
ClusterIP
NAT 之后,内核实际把它翻译成了谁发给谁
- 例如 reply tuple 里已经出现:
src=10.42.2.18 dst=10.42.0.0 sport=8080 dport=64753 - 这说明内核已经把目标翻译成了真实
PodIP
这条流当前停在什么状态
- 例如
SYN_SENT [UNREPLIED] - 这说明请求发出去了,但还没有等到对端回包
这样我们就能用它:
直接证明 DNAT 到底有没有发生,以及发生之后,这条流是成功建立连接了,还是卡在了哪里
有了这个神器我才能后续排查出
- 问题是不是 Service 规则没命中
- Service 已经翻译成真实后端之后,后面的路径出了问题
第 4 步:如果需要,再做 MASQ/SNAT#
在宿主机访问 Pod 的路径里,kube-proxy 往往还会做 MASQ。
再解释下SNAT和MASQ
SNAT 是 Source NAT,也就是 源地址转换。
MASQ 是 MASQUERADE 的缩写,可以把它看成一种特殊形式的 SNAT。
它们的共同点都是:
它们和 DNAT 的区别是:
DNAT 改的是 目标地址,解决“请求该送到哪里”SNAT/MASQ 改的是 源地址,解决“对方回包时应该回给谁”
举个最直观的例子。
原始请求可能是:
1
2
| src=10.66.3.15:41021
dst=10.43.236.230:8080
|
经过 DNAT 后(目标地址改变):
1
2
| src=10.66.3.15:41021
dst=10.42.2.18:8080 <-- 目标被翻译成了真实的 Pod IP
|
如果随后又发生了 SNAT/MASQ(源地址和端口也被改写):
1
2
| src=<translated-ip>:64753 <-- 源地址和端口被伪装
dst=10.42.2.18:8080
|
在整个转换的过程中
DNAT 负责把 ClusterIP 翻译成真实 PodIPSNAT/MASQ 负责确保这个 Pod 在处理完请求后,回包能沿着正确的路径,原路返回给发起请求的宿主机或客户端。
而这里MSAQ的作用是让 Service 流量的“原路返回”。
所以一个请求经过 Service 后,经过了MASQL的翻译,可能不只是目标地址变了,源地址/源端口也可能被改写。
第 5 步:根据 Pod 是否跨节点,决定怎么送达#
这里有两种情况:
如果 Pod 在本机:
- 请求不需要跨节点;
- 直接在本机 overlay/bridge 里送到 Pod。
如果 Pod 在别的节点:
- 请求需要跨节点;
- flannel 会把 inner 包封装成 VXLAN outer UDP 包;
- 然后通过宿主机物理网卡发到目标节点。
这次故障的关键,就是坏在第二种情况:
宿主机 -> Service -> DNAT/MASQ -> 跨node VXLAN
这里顺手解释一下这次排查里反复出现的 inner 和 outer。
inner:真正的业务请求包,也就是已经经过 DNAT/MASQ 后,实际要送给 PodIP:Port 的那条 TCP 请求。outer:为了跨节点传输,flannel 再给 inner 套上的 VXLAN 外层 UDP 包。
如果用最容易记的话来说:
在这次问题里:
- 我在请求
ClusterIP - kube-proxy 要先把它翻译成真实的
inner 业务包 - flannel 再把这个
inner 包装成 outer 包,发到目标节点
所以后面抓包时:
- 看到
10.66.3.15 -> 10.66.27.195:8472,这是 outer - 看到
10.42.x.x -> 10.42.x.x:8080,这是 inner
这次故障最关键的现象就是:
目标节点能看到 outer,但看不到对应的 inner,说明包送到了目标机,却没有被正确解封装出来。
第 6 步:目标节点收到 outer 包,解封装成 inner 包,再送给 Pod#
正常情况下,目标节点应该:
- 在
eth0 上收到 VXLAN outer UDP 包; - 识别并解封装;
- 在
flannel.1 这类 overlay 路径上出现 inner TCP 包; - 把 inner 包送到真实 Pod
10.42.2.18:8080; - Pod 回
SYN-ACK 和后续 HTTP 响应。
为什么后面排查时,DNAT 会成为关键分界点#
因为它正好把问题分成两大类:
阶段 A:DNAT 尚未发生(流量未进入 K8s 转发逻辑)#
如果抓包发现目标 IP 依然是 ClusterIP,且没有被转换的迹象,说明流量在“翻译”阶段就断了。
排查重心: 重点检查控制平面的规则下发与拦截。
核心排查点:
规则命中:iptables -t nat 中的 KUBE-SERVICES 链是否匹配到了该流量。
拦截机制:OUTPUT 或 PREROUTING 链是否正确拦截了对 Service IP 的请求。
路由决策:系统是否误将 ClusterIP 当作普通公网/局域网地址进行路由(通过 ip route get 验证)。
阶段 B:DNAT 已经发生(流量已进入后端转发链路)#
如果观测到目标地址已转换为 PodIP,说明 kube-proxy 的逻辑已经跑通。此时问题演变为 “报文如何送达”和“回包如何归还”。
但是有了 conntrack 这个神器,排查DNAT就容易多了
只要 conntrack 能看到去程确实是 Service 的虚拟 IP(ClusterIP),回程已经自动变成了后端真实的容器 IP(PodIP),就证明 DNAT已经跑通了
排查过程#
一开始排查的时候曾经有怀疑过是DNS的问题
现象一:流量走到物理网卡的“路由黑洞”
看到这里,我用 ip route 测试下流量跑哪去了:
1
2
3
| [root@master-node ~]# ip route get 10.43.236.230
10.43.236.230 via 10.66.3.125 dev eth0 src 10.66.3.15
cache
|
现象二:极其漫长的 HTTP 高延迟响应
然后我再看下curl -iv详细分析下过程,发现能建立连接,但在收到返回结果前会经历漫长的卡顿(长达十几秒),而同样的请求直接 curl 到 Pod IP 却能瞬间返回:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| [root@master-node ~]# curl -iv http://10.43.236.230:8080/json/
* About to connect() to 10.43.236.230 port 8080 (#0)
* Trying 10.43.236.230...
* Connected to 10.43.236.230 (10.43.236.230) port 8080 (#0)
> GET /json/ HTTP/1.1
> User-Agent: curl/7.29.0
> Host: 10.43.236.230:8080
> Accept: */*
>
## === 注意:网络环境在这里发生了漫长的卡顿等待行为 ===
< HTTP/1.1 200 OK
HTTP/1.1 200 OK
< Date: Thu, 19 Mar 2026 08:15:05 GMT
< Cache-Control: no-cache, no-store, must-revalidate
< Content-Type: text/json;charset=utf-8
< Transfer-Encoding: chunked
<
{
"url" : "spark://10.42.2.18:7077",
"workers" : [ {
"id" : "worker-20260318190316-10.42.2.19-45083",
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| [root@master-node ~]# curl -iv http://10.42.2.18:8080/json/ |head -n 80
* About to connect() to 10.42.2.18 port 8080 (#0)
* Trying 10.42.2.18...
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Connected to 10.42.2.18 (10.42.2.18) port 8080 (#0)
> GET /json/ HTTP/1.1
> User-Agent: curl/7.29.0
> Host: 10.42.2.18:8080
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Thu, 19 Mar 2026 08:18:42 GMT
< Cache-Control: no-cache, no-store, must-revalidate
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< Content-Type: text/json;charset=utf-8
< Vary: Accept-Encoding, User-Agent
< Transfer-Encoding: chunked
<
{ [data not shown]
HTTP/1.1 200 OK
Date: Thu, 19 Mar 2026 08:18:42 GMT
Cache-Control: no-cache, no-store, must-revalidate
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: text/json;charset=utf-8
Vary: Accept-Encoding, User-Agent
Transfer-Encoding: chunked
{
"url" : "spark://10.42.2.18:7077",
"workers" : [ {
"id" : "worker-20260318190316-10.42.2.19-45083",
"host" : "10.42.2.19",
"port" : 45083,
"webuiaddress" : "http://10.42.2.19:8080",
"cores" : 36,
"coresused" : 36,
"coresfree" : 0,
"memory" : 147456,
# 此处省略 #
* Failed writing body (2410 != 5592)
* Failed writing data
100 13669 0 13669 0 0 2215k 0 --:--:-- --:--:-- --:--:-- 2669k
* Closing connection 0
curl: (23) Failed writing body (2410 != 5592)
|
从上面看到,直接请求Pod IP却又没问题
这个时候有这几种可能:
请求根本没命中 kube-proxy 的 Service 规则
- 也就是 ClusterIP 被当成普通 IP 走默认路由了;
- 如果是这样,问题重点就在
ip route、iptables OUTPUT、KUBE-SERVICES。
请求命中了 Service,但 DNAT/MASQ 后出了问题
- 也就是 kube-proxy 已经把
ClusterIP 改写成了 PodIP; - 但改写后的流在回程、SNAT、conntrack 或 VXLAN 路径里出问题。
请求已经到 Pod,但应用层慢
- 例如 Jetty、PTR、DNS、反向解析、线程池卡顿。(之前也有怀疑过是这个问题)
目标 Pod 或目标节点本身异常
- 例如容器没监听、Pod 挂了、跨节点网络整体不通。(不太可能,因为这样服务早挂了,我们的服务还是正常的)
这 4 个假设里,我会很快把注意力放到 DNAT/Service 路径
原因如下:
- 因为访问的是
ClusterIP,而不是 PodIP - 在 Kubernetes 里,
ClusterIP 能工作,前提就是 kube-proxy 先在本机把目标地址做 DNAT - 对于“宿主机自己发起”的流量,这个 DNAT 发生在本机
OUTPUT 链,不是普通转发流量 - 所以只要问题表现出“
ClusterIP 异常,但 PodIP 正常”,就必须优先验证:
这条流到底有没有命中 DNAT,DNAT 之后又被改写成了什么。
直接conntrack开干#
丢包了吗?#
1
| cat /proc/net/nf_conntrack | grep 'src=10.66.3.15' | grep 'dst=10.43.236.230' | grep 'dport=8080' > /tmp/src_fail_conntrack.txt
|
顺便说下,这里的src的ip是master节点的ip,dst是svc(cluster)的ip
得到了这样的回显
1
2
| [root@master-node ~]# sed -n '1,80p' /tmp/src_fail_conntrack.txt
ipv4 2 tcp 6 118 SYN_SENT src=10.66.3.15 dst=10.43.236.230 sport=41021 dport=8080 [UNREPLIED] src=10.42.2.18 dst=10.42.0.0 sport=8080 dport=64753 mark=0 zone=0 use=2
|
这下真相大白了,也坐实了我之前的推论:
1
2
3
4
| ipv4 2 tcp 6 118 SYN_SENT
src=10.66.3.15 dst=10.43.236.230 sport=41021 dport=8080 <-- (1) 去程:找的是 Service
[UNREPLIED] <-- (2) 状态:没收到回包
src=10.42.2.18 dst=10.42.0.0 sport=8080 dport=64753 <-- (3) 期望的回程:源变成了 PodIP
|
确认了 DNAT 成功:因为后半段的 src 已经变成了 Pod 的 IP (10.42.2.18)。
确认了 MASQ 成功:因为后半段的 dst 变成了宿主机网桥或网卡的地址 (10.42.0.0)。
锁定了故障点:状态是 SYN_SENT 且有 [UNREPLIED]。这说明:“翻译官”干活了,包也发出去了,但对面(Pod 所在的节点或 Pod 本身)像掉进黑洞一样,一个字节都没回。
也就是说:
Service/MASQ 是生效的,问题在更后面的路径上。
包丢在哪了?#
因为我们的flannel用的是VXLAN模式
先在这里科普下什么是inner和outer,及他们和VXLAN之间的关系
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| [on host]
|
| 1) original request
| TCP
| src=10.66.3.15:41021
| dst=10.43.236.230:8080 (ClusterIP)
v
[kube-proxy on source node]
|
| 2) DNAT/MASQ
| inner packet
| TCP
| src=10.42.0.0:64753
| dst=10.42.2.18:8080 (real PodIP)
v
[flannel VXLAN encapsulation]
|
| 3) encapsulate inner into outer
| outer packet
| UDP
| src=10.66.3.15:13751
| dst=10.66.27.195:8472 (target node)
| payload = whole inner TCP packet
v
[target node eth0]
|
| 4) VXLAN decapsulation
| remove outer UDP/VXLAN header
v
[target node flannel / Pod path]
|
| 5) recovered inner packet
| TCP
| src=10.42.0.0:64753
| dst=10.42.2.18:8080
v
[spark-webui Pod]
|
按照正常的流程
知道这些之后,我们继续开始排查
1. 物理网卡(eth0):包裹送到后院了吗?#
1
2
| # 命令:确认目标机是否收到了来自源节点的物理包
timeout 6 tcpdump -ni eth0 -vv 'udp port 8472 and host 10.66.3.15' > /tmp/dst2_fail_eth0.txt 2>&1
|
文件回显解读:
1
2
3
4
| [root@worker-3 ~]# sed -n '1,40p' /tmp/dst2_fail_eth0.txt
# 物理网卡收到了包!地址和源节点发出的一模一样,连 [bad udp cksum] 也原封不动地带过来了
20:00:33.142781 IP (tos 0x0, ttl 63, id 52242, offset 0, flags [DF], proto UDP (17), length 110)
10.66.3.15.13751 > 10.66.27.195.8472: [bad udp cksum 0xffff -> 0x7350!] VXLAN, flags [I] (0x08), vni 1
|
解释: 包裹确实送到了目标节点的物理网卡上。这排除了中间路由器(Underlay)丢包的可能性。这里要记住这个udp cksum,后面要用到
2. 隧道网卡(flannel.1):能拆开包裹取件吗?#
1
2
| # 命令:看解封装后的 inner 包是否成功进入了容器网络
timeout 6 tcpdump -ni flannel.1 -vv 'host 10.42.2.18 and tcp port 8080' > /tmp/dst2_fail_flannel.txt 2>&1
|
文件回显解读:
1
2
3
| [root@worker-3 ~]# sed -n '1,40p' /tmp/dst2_fail_flannel.txt
0 packets captured
0 packets received by filter
|
解释: 这是全场最关键的证据! 物理网卡明明收到了包裹,但隧道网卡里却空空如也。这意味着包裹在试图“解封装”进入系统内部时,被内核拒绝并扔掉了。
3. 兜底检查(any):是不是走错路了?#
1
2
| # 命令:监听所有接口,做一个最后的确认
timeout 6 tcpdump -ni any -vv 'host 10.42.2.18 and tcp port 8080' > /tmp/dst2_fail_any.txt 2>&1
|
文件回显解读:
1
2
| [root@worker-3 ~]# sed -n '1,40p' /tmp/dst2_fail_any.txt
0 packets captured
|
解释: 连“全接口监听”都抓不到,彻底说明这个包在目标节点的协议栈里“消失”了。
- 失败流 outer 包能到目标节点
eth0 - 但目标节点
flannel.1 / any 看不到对应 inner 包
所以排查方向变成了:
目标节点收到 outer 包后,在进入 inner 路径之前就出问题了。
用内核统计确认#
现在,我们用内核统计确认“是不是 UDP checksum error 在目标节点把包丢了
如果目标节点收到了 outer 包,但解不出来,那么最直接的怀疑就是:
会不会是目标节点在 UDP 层就把 VXLAN outer 包当成坏包丢了?
来都来了,看下内核计数器吧
1
| grep '^Udp:' /proc/net/snmp > /tmp/udp_before.txt
|
1
2
3
| [root@worker-3 ~]# sed -n '1,2p' /tmp/udp_before.txt
Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrors InCsumErrors
Udp: 22103176739 1173 2258 859145 0 0 2258
|
- 这是失败流复现前的 UDP 基线计数;
- 关注字段只有两个:
InErrors 和 InCsumErrors。
再执行一次之前的curl看一下有没有变
1
| grep '^Udp:' /proc/net/snmp > /tmp/udp_after_fail.txt
|
1
2
3
| [root@worker-3 ~]# sed -n '1,2p' /tmp/udp_after_fail.txt
Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrors InCsumErrors
Udp: 22103181094 1173 2261 859145 0 0 2261
|
- 这是触发一次失败流之后再次采样的结果;
- 如果
InErrors 和 InCsumErrors 同步增长,而且增长量和 SYN + 重传 次数一致,就说明目标节点把这些 outer UDP 包当 checksum 错包丢掉了。
如果失败流每次都带来 InCsumErrors +3,而成功流不涨,那么就足以说明:
outer UDP 包到了目标机,但被目标机内核按 checksum error 丢了。
我一看,InErrors从2258变成了2261,InCsumErrors 也一样变化,这时候就能大概看出是checksum的问题了
这是个例吗?#
在锁定 spark-webui 之后,我又刻意做了反向交叉验证:
1
| curl -m 4 -sS -o /dev/null -w 'cross code=%{http_code} total=%{time_total}\n' http://10.43.115.122:8080/ || true
|
用意:从 worker-3 反向访问位于其他节点上的 internal-service ClusterIP,判断这是不是单服务特例。
1
| curl -m 4 -sS -o /dev/null -w 'pod code=%{http_code} total=%{time_total}\n' http://10.42.0.39:8080/ || true
|
用意:对照同服务 PodIP 是否正常。
当这个反向用例也失败、而目标节点的 InCsumErrors 也一样增长时,排查结论就不再只是单个svc有问题
而在我的排查中,也确定了是一样增长
印证了 宿主机访问跨节点 ClusterIP 这类路径本身就有问题
进一步#
在之前看到的这些表现基本能回答问题了,就是checksum的问题
但我还想再回答两个更细的问题:
- 失败包是在目标节点的哪个内核阶段被丢的?
- 失败流和成功流在源侧是不是走了不同的发送路径?
所以我继续用了 tracing。
目标节点侧,我执行了:
1
| trace-cmd start -p function -l __udp4_lib_rcv -l udp_queue_rcv_skb -l __skb_checksum_complete -l vxlan_rcv
|
用意:确认失败样本是在 UDP checksum 校验阶段掉了,还是成功进入 vxlan_rcv。
1
| cat /sys/kernel/debug/tracing/trace > /tmp/func_trace_fail.txt
|
1
2
3
4
5
6
7
8
| [root@worker-3 ~]# grep -n '__skb_checksum_complete\|vxlan_rcv\|udp_queue_rcv_skb\|__udp4_lib_rcv' /tmp/func_trace_fail.txt | sed -n '1,20p'
12: ksoftirqd/0-19 [000] .... 8568.558570: __udp4_lib_rcv <-udp_rcv
13: ksoftirqd/0-19 [000] .... 8568.558571: udp_queue_rcv_skb <-__udp4_lib_rcv
14: ksoftirqd/0-19 [000] .... 8568.558572: vxlan_rcv <-udp_queue_rcv_skb
24: ksoftirqd/0-19 [000] .... 8574.224181: __skb_checksum_complete <-nf_ip_checksum
25: ksoftirqd/0-19 [000] .... 8574.224182: __udp4_lib_rcv <-udp_rcv
26: ksoftirqd/0-19 [000] .... 8574.224183: udp_queue_rcv_skb <-__udp4_lib_rcv
27: ksoftirqd/0-19 [000] .... 8574.224184: __skb_checksum_complete <-udp_queue_rcv_skb
|
- 这个文件里最关键的不是整份 trace,而是失败样本附近是否出现:
__udp4_lib_rcvudp_queue_rcv_skb__skb_checksum_complete- 以及是否缺少后续的
vxlan_rcv
- 如果你看到异常样本止步在
__skb_checksum_complete,却没有对应的 vxlan_rcv,就说明包在进入 VXLAN 解封装之前已经被 UDP 校验路径挡住了。
源节点侧,我盯的是:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| trace-cmd start -p function -l vxlan_xmit -l vxlan_xmit_one -l 'vxlan_build_skb.isra.52' -l udp_tunnel_xmit_skb -l udp_set_csum -l iptunnel_xmit -l skb_checksum_help
> 比较失败的 ClusterIP 流和成功的 PodIP 流,在源侧走过的 VXLAN 发包路径是否不同。
trace-cmd stop
trace-cmd extract -o /tmp/fail_src.dat
[root@worker-3 ~]# trace-cmd report -i /tmp/fail_src.dat | grep -n 'curl-'
154: curl-17123 [002] .... 8598.104842: vxlan_xmit <-dev_queue_xmit
155: curl-17123 [002] .... 8598.104843: vxlan_xmit_one <-vxlan_xmit
156: curl-17123 [002] .... 8598.104844: vxlan_build_skb.isra.52 <-vxlan_xmit_one
157: curl-17123 [002] .... 8598.104845: udp_tunnel_xmit_skb <-vxlan_xmit_one
158: curl-17123 [002] .... 8598.104846: udp_set_csum <-udp_tunnel_xmit_skb
159: curl-17123 [002] .... 8598.104847: iptunnel_xmit <-udp_tunnel_xmit_skb
160: curl-17123 [002] .... 8598.104848: skb_checksum_help <-validate_xmit_skb
|
用意:只看下和本次 curl 进程相关的函数调用,避免被系统里其他流量淹没。
1
| trace-cmd extract -o /tmp/ok_src.dat
|
对应文件:/tmp/ok_src.dat
怎么理解:
- 这是“成功的 PodIP 流”对应的 trace 数据文件;
- 它的作用不是单独证明某件事,而是拿来和
/tmp/fail_src.dat 做一一对照。
1
| trace-cmd report -i /tmp/ok_src.dat | grep -n 'curl-'
|
用意:把成功流里和 curl 进程相关的发包函数链打印出来,和失败流逐项比对。
1
2
3
4
5
6
7
8
| [root@worker-3 ~]# trace-cmd report -i /tmp/ok_src.dat | grep -n 'curl-'
171: curl-18008 [001] .... 8634.883512: vxlan_xmit <-dev_queue_xmit
172: curl-18008 [001] .... 8634.883513: vxlan_xmit_one <-vxlan_xmit
173: curl-18008 [001] .... 8634.883514: vxlan_build_skb.isra.52 <-vxlan_xmit_one
174: curl-18008 [001] .... 8634.883515: udp_tunnel_xmit_skb <-vxlan_xmit_one
175: curl-18008 [001] .... 8634.883516: udp_set_csum <-udp_tunnel_xmit_skb
176: curl-18008 [001] .... 8634.883517: iptunnel_xmit <-udp_tunnel_xmit_skb
177: curl-18008 [001] .... 8634.883518: skb_checksum_help <-validate_xmit_skb
|
可以看到,失败流里有:
vxlan_xmit
vxlan_xmit_one
vxlan_build_skb.isra.52
udp_tunnel_xmit_skb
udp_set_csum
iptunnel_xmit
skb_checksum_help
成功流里也有:
vxlan_xmit
vxlan_xmit_one
vxlan_build_skb.isra.52
udp_tunnel_xmit_skb
udp_set_csum
iptunnel_xmit
skb_checksum_help
所以能得到一个结论:
失败流和成功流都走到了同一条主要的 VXLAN 发送路径。
暂时总结下#
成功和失败的流量都顺着 vxlan_xmit 走出去了。也就是说,封包逻辑没死,包确实发出去了。
诡异的“外包装”变异:
问题出在发出去的那个 Outer UDP(外层封装包) 上。虽然外表看起来都在,但失败流的包在“出厂”时(封装阶段)属性已经带伤。
案发现场(丢包点):
目标节点收到包后,刚跑完 __skb_checksum_complete(内核拆包前的安检),发现 Checksum(校验和)对不上。内核一看是坏包,反手就是一个丢弃,连 vxlan_rcv(真正的接收逻辑)的门都没摸着。
最后的定性:
内核封装和网卡硬件的 Checksum Offload(校验卸载) 没商量好。网卡以为内核算好了,内核以为网卡会算,结果发出去的 Outer 包是个“封条作废”的次品,直接被对方物理拦截。
疑问解答#
- 故障不是 DNS/PTR 导致的本次主因;
- 故障也不是
spark-webui 单个 Service/Pod 的问题; - 问题集中在:
- 宿主机发起
- 命中 kube-proxy
Service/MASQ - 走跨节点
flannel vxlan - outer UDP 在目标节点被判定 checksum error 并丢弃(进入
vxlan_rcv 前)。
疑问解答1:为什么 ClusterIP 失败而 PodIP 成功?#
同目标对照(spark-webui Endpoint 10.42.2.18:8080)如下:
1
2
3
4
5
6
| [root@master-node ~]# curl --local-port 41021 -m 4 -sS -o /dev/null -w 'fail code=%{http_code} total=%{time_total}\n' http://10.43.236.230:8080/json/
fail code=000 total=4.001
curl: (28) Connection timed out after 4000 milliseconds
[root@master-node ~]# curl --local-port 41022 -m 4 -sS -o /dev/null -w 'ok code=%{http_code} total=%{time_total}\n' http://10.42.2.18:8080/json/
ok code=200 total=0.008
|
对应 conntrack:
1
2
3
4
5
| [root@master-node ~]# sed -n '1,80p' /tmp/src_fail_conntrack.txt
ipv4 2 tcp 6 118 SYN_SENT src=10.66.3.15 dst=10.43.236.230 sport=41021 dport=8080 [UNREPLIED] src=10.42.2.18 dst=10.42.0.0 sport=8080 dport=64753 mark=0 zone=0 use=2
[root@master-node ~]# sed -n '1,80p' /tmp/src_ok_conntrack.txt
ipv4 2 tcp 6 119 TIME_WAIT src=10.42.0.0 dst=10.42.2.18 sport=41022 dport=8080 src=10.42.2.18 dst=10.42.0.0 sport=8080 dport=41022 [ASSURED] mark=0 zone=0 use=2
|
说明失败流已经进入 Service/MASQ 路径并完成 DNAT,但停在 SYN_SENT [UNREPLIED]。
疑问解答2:为什么会出现“超时像卡顿”?#
主因是 VXLAN outer UDP 校验失败,不是应用层等待 DNS:
源节点发出的 outer 包形态分叉
- 失败流:
[bad udp cksum 0xffff -> ...] - 成功流:
[no cksum]
目标节点可收到失败流 outer 包(eth0),但 flannel.1 无 inner 包
1
2
3
4
5
| [root@worker-3 ~]# sed -n '1,120p' /tmp/dst2_fail_eth0.txt
... 10.66.3.15.7158 > 10.66.27.195.otv: [bad udp cksum 0xffff -> 0x1502!] OTV ...
[root@worker-3 ~]# sed -n '1,120p' /tmp/dst2_fail_flannel.txt
0 packets captured
|
- UDP 错包计数在失败时精确增长
1
2
3
4
5
6
7
| [root@worker-3 ~]# sed -n '1,2p' /tmp/udp_before.txt
Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrors InCsumErrors
Udp: 22103176739 1173 2258 859145 0 0 2258
[root@worker-3 ~]# sed -n '1,2p' /tmp/udp_after_fail.txt
Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrors InCsumErrors
Udp: 22103181094 1173 2261 859145 0 0 2261
|
一次失败复现增长 3,刚好对应 SYN + 2 次重传。
- 函数 trace 可见异常包在进入
vxlan_rcv 前已出问题
1
2
| [root@worker-3 tracing]# grep -n '__skb_checksum_complete\|vxlan_rcv\|udp_queue_rcv_skb\|__udp4_lib_rcv' /tmp/func_trace_fail.txt | sed -n '1,40p'
... __skb_checksum_complete <-udp_queue_rcv_skb
|
综合,超时现象来自网络包被目标节点 UDP 层丢弃,而不是本次主链路中的 DNS/PTR 卡顿。
过渡性解决方案#
先恢复监控脚本可用
1
2
| # 替代 curl http://$service_ip:8080/json/
kubectl exec svc/spark-webui -- curl -s http://localhost:8080/json/
|
属于是没招了。。。
可能的修复方案#
修复 1:做网卡 offload 组合实验(首优)
- 对
eth0 做 tx/rx/checksum/tso/gso/gro 组合切换; - 以
InCsumErrors 是否停止增长作为硬验收。
修复 2:升级内核/网络栈(脱离 3.10)
- 在灰度节点升级后复测同一路径,验证是否消除 UDP checksum 异常。
修复 3:升级 k3s/flannel 版本
- 优先验证 VXLAN 相关已知修复版本;
- 保持与当前场景一致的回归流量进行对照。
修复 4:调整健康检查入口
- 生产脚本避免依赖“宿主机 -> 跨节点 ClusterIP”单一路径;
- Spark 监控改为
kubectl exec 或节点内 Agent 拉取。
修复 5:DNS 优化降级为独立项(非本次主因)
ndots、single-request-reopen、timeout/attempts 可作为性能优化继续做;- 但不再作为本次主故障根因链条。
然而在这个环境上,没有起作用#
之前状态
1
| ethtool -k eth0 | grep -E 'tx-checksum-ip-generic|tcp-segmentation-offload|generic-segmentation-offload|generic-receive-offload'
|
1
2
3
4
| tx-checksum-ip-generic: on
tcp-segmentation-offload: on
generic-segmentation-offload: on
generic-receive-offload: on
|
测试下跨节点 ClusterIP :
1
2
3
4
| curl -m 4 -sS -o /dev/null -w 'cross code=%{http_code} total=%{time_total}\n' http://10.43.115.122:8080/ || true
cross code=000 total=4.002
curl: (28) Connection timed out after 4001 milliseconds
|
直连 PodIP 基线:
1
2
3
| curl -m 4 -sS -o /dev/null -w 'pod code=%{http_code} total=%{time_total}\n' http://10.42.0.39:8080/ || true
pod code=200 total=0.017
|
试图改网卡转发
1
| ethtool -K eth0 tx-checksum-ip-generic off
|
系统实际回显:
1
2
3
4
5
6
7
8
| Actual changes:
tx-checksumming: off
tx-checksum-ip-generic: off
tcp-segmentation-offload: off
tx-tcp-segmentation: off [requested on]
tx-tcp-ecn-segmentation: off [requested on]
tx-tcp6-segmentation: off [requested on]
udp-fragmentation-offload: off [requested on]
|
这说明虽然只显式关闭了 tx-checksum-ip-generic,但内核同时把 tcp-segmentation-offload 也关掉了。
变更后确认:
1
2
3
4
5
6
| ethtool -k eth0 | grep -E 'tx-checksum-ip-generic|tcp-segmentation-offload|generic-segmentation-offload|generic-receive-offload'
tx-checksum-ip-generic: off
tcp-segmentation-offload: off
generic-segmentation-offload: on
generic-receive-offload: on
|
变更后结果
再次访问跨节点 ClusterIP:
1
2
3
4
| curl -m 4 -sS -o /dev/null -w 'cross code=%{http_code} total=%{time_total}\n' http://10.43.115.122:8080/ || true
cross code=000 total=4.001
curl: (28) Connection timed out after 4001 milliseconds
|
再次直连 PodIP:
1
2
3
| curl -m 4 -sS -o /dev/null -w 'pod code=%{http_code} total=%{time_total}\n' http://10.42.0.39:8080/ || true
pod code=200 total=0.005
|
😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅
这下真只能用下面的升级k3s或者是升级内核了,到这里我也完全不想搞了,凑合用吧!下次记得升级下我们的K3s