记一次 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 都可以理解成一套彼此隔离的小网络世界,里面有自己的:

  • 网卡
  • 路由表
  • iptables
  • 监听端口
  • socket

目前 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

DNATDestination NAT,也就是 目标地址转换

在 Kubernetes 里,ClusterIP 是一个虚拟服务地址,不是真实 Pod 的地址。
所以当应用访问:

1
10.43.236.230:8080

的时候,kube-proxy 需要在包真正发出去之前,把它改写成某个真实后端,比如:

1
10.42.2.18:8080

这个“把目标从 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

也就是把:

1
dst=10.43.236.230:8080

改写成:

1
dst=10.42.2.18:8080

这也是我后面排查时,我会使用 conntrack的原因
因为 conntrack 能同时看到“原始目标 ClusterIP”和“实际 reply tuple 已经变成 PodIP”。

这里先解释一下什么是 conntrack

简单来说,conntrack 是 Linux 内核中的一个模块,负责记录和追踪当前系统上所有的网络连接状态。 它会记住一条流从进入内核到离开内核时,经历过哪些 NAT 改写,以及当前处在什么 TCP/UDP 状态(可以说是网络排查神器了)

在这里,conntrack 能做三件事:

  1. 原始请求是谁发给谁

    • 例如:src=10.66.3.15 dst=10.43.236.230 sport=41021 dport=8080
    • 这说明调用方最初访问的确实是 ClusterIP
  2. NAT 之后,内核实际把它翻译成了谁发给谁

    • 例如 reply tuple 里已经出现:src=10.42.2.18 dst=10.42.0.0 sport=8080 dport=64753
    • 这说明内核已经把目标翻译成了真实 PodIP
  3. 这条流当前停在什么状态

    • 例如 SYN_SENT [UNREPLIED]
    • 这说明请求发出去了,但还没有等到对端回包

这样我们就能用它:

直接证明 DNAT 到底有没有发生,以及发生之后,这条流是成功建立连接了,还是卡在了哪里

有了这个神器我才能后续排查出

  • 问题是不是 Service 规则没命中
  • Service 已经翻译成真实后端之后,后面的路径出了问题

第 4 步:如果需要,再做 MASQ/SNAT

在宿主机访问 Pod 的路径里,kube-proxy 往往还会做 MASQ

再解释下SNATMASQ

SNATSource NAT,也就是 源地址转换
MASQMASQUERADE 的缩写,可以把它看成一种特殊形式的 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 翻译成真实 PodIP
  • SNAT/MASQ 负责确保这个 Pod 在处理完请求后,回包能沿着正确的路径,原路返回给发起请求的宿主机或客户端。

而这里MSAQ的作用是让 Service 流量的“原路返回”。

所以一个请求经过 Service 后,经过了MASQL的翻译,可能不只是目标地址变了,源地址/源端口也可能被改写。

第 5 步:根据 Pod 是否跨节点,决定怎么送达

这里有两种情况:

  • 如果 Pod 在本机:

    • 请求不需要跨节点;
    • 直接在本机 overlay/bridge 里送到 Pod。
  • 如果 Pod 在别的节点:

    • 请求需要跨节点;
    • flannel 会把 inner 包封装成 VXLAN outer UDP 包;
    • 然后通过宿主机物理网卡发到目标节点。

这次故障的关键,就是坏在第二种情况:

宿主机 -> Service -> DNAT/MASQ -> 跨node VXLAN

这里顺手解释一下这次排查里反复出现的 innerouter

  • inner:真正的业务请求包,也就是已经经过 DNAT/MASQ 后,实际要送给 PodIP:Port 的那条 TCP 请求。
  • outer:为了跨节点传输,flannel 再给 inner 套上的 VXLAN 外层 UDP 包。

如果用最容易记的话来说:

  • inner 是内容物
  • outer 是运输外包装

在这次问题里:

  • 我在请求 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

正常情况下,目标节点应该:

  1. eth0 上收到 VXLAN outer UDP 包;
  2. 识别并解封装;
  3. flannel.1 这类 overlay 路径上出现 inner TCP 包;
  4. 把 inner 包送到真实 Pod 10.42.2.18:8080
  5. 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 的逻辑已经跑通。此时问题演变为 “报文如何送达”和“回包如何归还”

  • 排查重心: 重点检查数据平面的封装、解封装及路径可达性。

  • 核心排查点:

    • 地址伪装:SNAT/MASQ 是否执行,确保了回包能找回原宿主机。

    • 隧道封装:VXLAN/Geneve 等叠加网络(Overlay)的封包与解封包是否正常。

    • 链路连通性:目标节点是否收到了包?Pod 内部是否有响应?

但是有了 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却又没问题

这个时候有这几种可能:

  1. 请求根本没命中 kube-proxy 的 Service 规则

    • 也就是 ClusterIP 被当成普通 IP 走默认路由了;
    • 如果是这样,问题重点就在 ip routeiptables OUTPUTKUBE-SERVICES
  2. 请求命中了 Service,但 DNAT/MASQ 后出了问题

    • 也就是 kube-proxy 已经把 ClusterIP 改写成了 PodIP;
    • 但改写后的流在回程、SNAT、conntrack 或 VXLAN 路径里出问题。
  3. 请求已经到 Pod,但应用层慢

    • 例如 Jetty、PTR、DNS、反向解析、线程池卡顿。(之前也有怀疑过是这个问题)
  4. 目标 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]

按照正常的流程

  • 业务层请求一直是 TCP

    • 因为访问的是 http://…:8080
    • HTTP 跑在 TCP 上
  • 跨节点运输层是 UDP

    • 因为 flannel 的 VXLAN 隧道是用 UDP 封装的
    • 默认目的端口就是 8472
  • inner = TCP = real business TCP packet to Pod

  • outer = UDP

  • VXLAN = 把 inner TCP 包装进 outer UDP 里

知道这些之后,我们继续开始排查

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 基线计数;
  • 关注字段只有两个:InErrorsInCsumErrors

再执行一次之前的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
  • 这是触发一次失败流之后再次采样的结果;
  • 如果 InErrorsInCsumErrors 同步增长,而且增长量和 SYN + 重传 次数一致,就说明目标节点把这些 outer UDP 包当 checksum 错包丢掉了。

如果失败流每次都带来 InCsumErrors +3,而成功流不涨,那么就足以说明:

outer UDP 包到了目标机,但被目标机内核按 checksum error 丢了。

我一看,InErrors2258变成了2261InCsumErrors 也一样变化,这时候就能大概看出是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的问题

但我还想再回答两个更细的问题:

  1. 失败包是在目标节点的哪个内核阶段被丢的?
  2. 失败流和成功流在源侧是不是走了不同的发送路径?

所以我继续用了 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_rcv
    • udp_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 发送路径。

暂时总结下

  1. 成功和失败的流量都顺着 vxlan_xmit 走出去了。也就是说,封包逻辑没死,包确实发出去了。

  2. 诡异的“外包装”变异: 问题出在发出去的那个 Outer UDP(外层封装包) 上。虽然外表看起来都在,但失败流的包在“出厂”时(封装阶段)属性已经带伤。

  3. 案发现场(丢包点): 目标节点收到包后,刚跑完 __skb_checksum_complete(内核拆包前的安检),发现 Checksum(校验和)对不上。内核一看是坏包,反手就是一个丢弃,连 vxlan_rcv(真正的接收逻辑)的门都没摸着。

  4. 最后的定性:

内核封装和网卡硬件的 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:

  1. 源节点发出的 outer 包形态分叉

    • 失败流:[bad udp cksum 0xffff -> ...]
    • 成功流:[no cksum]
  2. 目标节点可收到失败流 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
  1. 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 次重传

  1. 函数 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 组合实验(首优)

  • eth0tx/rx/checksum/tso/gso/gro 组合切换;
  • InCsumErrors 是否停止增长作为硬验收。

修复 2:升级内核/网络栈(脱离 3.10)

  • 在灰度节点升级后复测同一路径,验证是否消除 UDP checksum 异常。

修复 3:升级 k3s/flannel 版本

  • 优先验证 VXLAN 相关已知修复版本;
  • 保持与当前场景一致的回归流量进行对照。

修复 4:调整健康检查入口

  • 生产脚本避免依赖“宿主机 -> 跨节点 ClusterIP”单一路径;
  • Spark 监控改为 kubectl exec 或节点内 Agent 拉取。

修复 5:DNS 优化降级为独立项(非本次主因)

  • ndotssingle-request-reopentimeout/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