k3s 加节点后 TLS 握手成批超时:Cilium devices 写死网卡名导致 Pod MTU 变成 65520

k3s 集群加了两台 N100 之后,有个服务每次发版启动都会刷一批 TLS 握手超时。前后查了快一周,最后是 Cilium 配置里写死了老节点的网卡名,新节点上 Pod 的 MTU 变成了 65520。

背景

家里的 k3s 原来只有一台机器,后来又加了两台 N100 小主机,三台在同一个局域网里。

有个服务需要通过海外的云主机访问外部的 HTTPS API。链路是这样的:

1
2
3
4
5
6
7
8
9
10
业务 Pod
│ HTTP CONNECT
▼
出口网关 Pod(2 副本,自己写的,Go)
│ wss 长连接
▼
海外云主机(两台,随机挑一台)
│ TCP
▼
外部 HTTPS API

网关只做一件事:收到 CONNECT,挑一台云主机开一条 WebSocket 隧道,然后两边字节对拷。TLS 是业务 Pod 自己和外部 API 握的,网关和云主机都只是搬运工。

现象

这个服务每次启动都要做一轮自检,几秒内并发打十几个请求出去。

然后就会收到一批告警:

1
_ssl.c:993: The handshake operation timed out

不是每次全挂,大概一半左右。过十几秒再起一个进程,又全好了。

网关日志里,失败的连接都长这样:

1
结束 node=vm1 target=xxx:443 up=0 down=1547 reason=tcp_eof

down 是客户端发往目标的字节,1547 刚好是一个 ClientHello 的大小。up 是回来的字节,0。

再去云主机上翻同一时刻的日志:

1
tunnel: 结束 target=xxx dur=6.74s up=1547 down=0 reason=ws_closed

ClientHello 1547 字节完整送到了外部 API,6.7 秒一个字节都没回来。

看起来就是对端不理我。

走过的弯路

以为是对端限流

第一反应是对方的边缘节点对”同一个 IP 短时间突发一堆新连接”有什么处理。

于是给启动自检加了重试,2 秒、5 秒各重试一次。告警少了点,但没根治。

以为是网关副本跑久了坏了

有一次很明显:两个网关副本,一个正常,一个约 40% 握手超时,把坏的那个删掉立刻恢复。

当时的结论是”副本运行几天后进入某种坏状态”。于是给网关加了个端到端金丝雀:每 30 秒对每台云主机走一遍真实转发路径,在隧道里对目标做一次 TLS 握手,全部连续失败就让 /healthz 返回 503,交给存活探针重启。

这次又出事了。去看坏副本的金丝雀状态:

1
2
{"vm1":{"canaryConsecutiveFailures":0,"canaryRounds":1557},
"vm2":{"canaryConsecutiveFailures":0,"canaryRounds":1557}}

一千五百多轮,一次没失败。同一时间真实请求七成在超时。

以为是 ClientHello 被拆包了

这时候想到一个区别:金丝雀是网关进程内部用 net.Pipe 喂给隧道的,一次 Write 写完整个 ClientHello,隧道里就是一条 WebSocket 消息。真实客户端是从别的节点通过网络连过来的,1547 字节大于 1500,到网关那边可能是两个 TCP 段,网关读两次,就变成两条消息、云主机分两次写给对端。

会不会是对端对拆开的 ClientHello 有意见?

直接在云主机上验证:从业务容器里导出它真实发的 ClientHello 字节,拿到云主机上原样重放,故意拆成两段、中间停 30ms,拆在各个位置都试一遍。

1
2
3
4
5
len=1532 split=0    ok=5/5
len=1532 split=1448 ok=5/5
len=1532 split=1398 ok=5/5
len=1532 split=1200 ok=5/5
len=1532 split=517 ok=5/5

全过。不是这个。

计数器骗了我

回头重新看隧道的代码,发现之前的推断有个洞。

字节计数是这么写的:

1
2
3
4
5
6
7
8
n, err := tcp.Read(buf)
if n > 0 {
if werr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); werr != nil {
// ...
} else {
down.Add(int64(n)) // 写成功了才计数
}
}

写成功之后才累加。云主机日志里的 down=0,并不能说明对端没回包,也可能是对端回了,但云主机往网关那边写的时候卡住了。网关那边的 up 也一样,是 io.Copy 返回之后才加的。

也就是说卡点可能在四段里的任何一段:云主机→对端、对端→云主机、云主机→网关、网关→客户端。

“对端不回包”这个结论,从一开始就是我自己脑补的。

按节点一统计,答案就出来了

既然现场已经没了,就去日志系统里把过去 7 天所有网关副本的连接都捞出来,按副本统计 up=0 的比例,再从 Cilium 的日志里反查每个副本当时在哪个节点:

副本所在节点 连接数 up=0
老节点(原来那台) 1206 0
N100-A 717 60
N100-B 1250 254

老节点上的网关副本,一千多条连接一次都没出过事。

再看出事那天失败的两个业务 Pod,都在 N100-B 上,而那个坏网关副本在 N100-A。同一时间老节点上的业务 Pod 走同一个网关副本,一点问题没有。

所以根本不是”副本坏了”,是 N100-A 和 N100-B 之间的 Pod 通信有问题。之前删副本能恢复,只是因为新副本被调度到了别的节点。

原因

进网关 Pod 的 netns 看连接:

1
nsenter -t <pid> -n ss -tni state established
1
2
→ 老节点上的业务 Pod      mss:1448
→ 同节点的业务 Pod mss:65468

MSS 65468?再看 Pod 的网卡:

1
2
3
4
5
6
# 老节点上的 Pod
$ cat /sys/class/net/eth0/mtu
1500
# N100 上的 Pod
$ cat /sys/class/net/eth0/mtu
65520

N100 上所有 Pod 的 MTU 都是 65520。

翻 Cilium 的 values:

1
2
devices:
- enp3s0

enp3s0 是老节点的网卡名。两台 N100 的网卡叫 enp1s0,上面压根没有 enp3s0。

Cilium 在 N100 上匹配不到设备,MTU 自动探测就退回了 65520,Pod 的 MTU 是从这里派生的。

加节点那天,registries.yaml、firewalld、NFS 都一项项补了,唯独这一行没人想起来。网络是通的,ping 也是通的,一般的请求也正常,所以一个月都没人发现。

为啥小包能过、大包不行

两台 N100 上的 Pod 互相连,两边都按 MTU 65520 报 MSS,协商出来就是 65468。

但物理网卡只有 1500。超过 1500 的包到了宿主机要转发出网卡时,内核只能丢掉,然后回一个 ICMP “需要分片” 给发送方,发送方把这个目的地的 PMTU 调成 1500 再重传。这就是 PMTU 发现。

也就是说,这两台机器之间所有超过 1500 的 Pod 流量,全靠这个 ICMP 兜底。

对上现象:

  • CONNECT 的 200 应答、隧道的 OK 帧都是小包,正常过;
  • TLS 的 ServerHello 带证书,好几 KB,从网关发回给另一台 N100 上的客户端时过不去;
  • 老节点的 Pod MTU 是 1500,对端只肯收 1448,根本不需要 PMTU 发现,所以老节点上的副本永远没事;
  • 同节点的 Pod 走本机,不过物理网卡,也没事;
  • 新起的业务进程是新 Pod IP,网关那边对这个目的地没有 PMTU 缓存,一启动就突发一堆连接,正好撞上。

我在两台 N100 上各找了个普通 Pod 测过,PMTU 发现是能正常工作的(第一次大包之后 ip route get 能看到 cache mtu 1500)。出事那会儿 ICMP 为什么没起作用,网关 Pod 上的 NetworkPolicy、ICMP 限速都有可能,现场没了,没抓到证据,就不瞎猜了。

反正把 MTU 改对之后,集群内部根本不会再产生超过 1500 的包,PMTU 发现爱灵不灵都无所谓了。

为啥金丝雀发现不了

金丝雀是网关进程自己用 net.Pipe 当客户端,压根不经过”网关→另一台节点上的客户端”这段网络。坏的恰恰是这一段。

做探活还是得尽量走和真实流量一模一样的路径,不然测了个寂寞。

解决

values 里把两个网卡名都列上,顺手把 MTU 钉死 1500,不再依赖自动探测:

1
2
3
4
5
devices:
- enp3s0
- enp1s0
# native routing 没有封装开销,等于物理网卡 MTU
MTU: 1500

devices 支持通配符,写 enp+ 也行。我还是列全了,以后加节点看到这一行会想起来改。

1
2
3
4
helm -n kube-system upgrade cilium cilium/cilium --version 1.20.2 -f helm/values/cilium.yaml
kubectl -n kube-system get cm cilium-config -o yaml | grep -E "mtu|devices"
# devices: enp3s0 enp1s0
# mtu: "1500"

Cilium 的 chart 默认改 ConfigMap 不会自动重启 agent,得自己一台一台删 agent Pod。先删的 N100-A(上面没有关键服务),15 秒就绪,确认没问题再删另外两台。

本来以为已经在跑的 Pod 要全部重建才能拿到新 MTU,准备挨个进 netns 里 ip link set eth0 mtu 1500。结果一扫,两台 N100 上 36 个 Pod 已经全是 1500 了:Cilium 1.20 的 agent 里有个 mtu-updater 任务,启动时会把已有 Pod 两侧的 veth MTU 一起改掉。省事。

验证:

1
2
3
4
5
6
7
8
# 宿主机
$ ip -o link | grep lxc | grep -o "mtu [0-9]*" | sort | uniq -c
19 mtu 1500
# 新建的 Pod 到别的节点
$ ip route get <别的节点上的 Pod IP>
cache mtu 1500
$ ss -tni | grep -o "mss:[0-9]*" | sort | uniq -c
69 mss:1448

再从业务 Pod 经两个网关副本各并发打 8 个、16 个 TLS 握手,全过。

最后

这个坑能藏一个月,主要是两个原因:

一是它只在特定组合下出现:网关在一台 N100、客户端在另一台 N100、包又够大。平时多数流量碰不到。

二是我太早相信了 down=0 这个数字。计数器只在写成功后才加,”对端没回包”是我自己脑补出来的,后面加重试、加金丝雀,全是在错误的前提上打补丁。

加节点的检查清单里已经补上了一条:Cilium 的 devices 里有没有这台机器的网卡名。