4411 字
22 分钟

Harbor 偶发 TLS 握手超时:Calico MTU 与 PMTU 缓存排查

2026-09-24 凌晨,升级 JumpServer前预拉镜像,一个节点又报了 net/http: TLS handshake timeout。重试成功,升级也继续完成了,但类似报错已经出现过几次,不能一直靠重试绕过去。

这次排查找到了两层问题:Pod 网卡 MTU 被 CNI 配置固定为 1500,而 IP-in-IP 隧道只能承载 1480;修正之后,又发现节点间的路径 MTU 缓存被压到了 1480,叠加封装后,隧道有效 MTU 进一步降到了 1460。

前一层有模板、落盘配置和实际网卡相互印证;后一层有路由缓存与绕过缓存的探测对照。修复后的独立 HTTPS 请求跨过缓存过期时间,才完成本窗口验收。

下文时间均为北京时间,最终验收截至 09-24 03:01。节点用 A、B、C 代称,命令中的域名和地址使用示例值。没有原始失败连接的抓包,文中会区分已经观察到的事实与据此作出的判断。

现象:总在拉镜像时出现,重试往往正常#

最近一条错误发生在 00:29:24,当时还处于 JumpServer 停机前的预拉阶段。替换域名后的日志如下:

failed to resolve reference "registry.example.com/dockerhub/jumpserver/core:v4.10.19-ce"
Head "https://registry.example.com/v2/dockerhub/jumpserver/core/manifests/v4.10.19-ce":
net/http: TLS handshake timeout

查三台节点从 09-21 起保留的 containerd journal,同时筛选 Harbor 域名和 TLS handshake timeout,四条匹配全部来自节点 C:

时间镜像
09-21 02:06:51library/python:3.14
09-23 21:59:38martizih/kaniko:v1.28.5-debug
09-23 22:48:24library/mariadb:13
09-24 00:29:24jumpserver/core:v4.10.19-ce

节点 A、B 的同一筛选没有匹配。这是日志范围内的分布,不等于整个集群只发生过四次网络失败。不同镜像都遇到过,也说明调查范围不能只停在 JumpServer。

错误发生在客户端到 Harbor HTTPS 入口的 TLS 握手阶段。该连接还没有完成 HTTP manifest 请求,后面的 Registry 鉴权、代理缓存回源、镜像层下载,都要先越过这一关。

之前也写过一次容器镜像拉取一直卡住,那次主要卡在 containerd 的镜像层下载与锁竞争。这次报错已经指出了不同的阶段,不能照搬上次处理方式。

节点 C 为什么更容易暴露问题#

故障时的关键配置如下:

层次实际配置
集群kubeasz 3.6.7,Kubernetes 1.33.1,containerd 2.1.1
Pod 网络Calico 3.28.4,Bird,IPIP Always,etcd datastore
Service 转发kube-proxy IPVS
HTTPS 入口Traefik 两副本,分别位于节点 A、B
底层网络三节点同网段,物理接口、bond0、br0 的 MTU 均为 1500

Harbor 域名在节点上解析到内部 LoadBalancer VIP。但 ip route get 显示它是本机接管的地址,流量进入本机 IPVS;不能看到 LoadBalancer 就认定请求绕去了公网。

实际 IPVS 表里,A、B 各自指向本机 Traefik,C 没有本机 Traefik,指向另外两个节点的实例。C 的入口路径因此包含跨节点隧道:

节点 C:containerd
→ Harbor VIP:443
→ 本机 IPVS
→ tunl0 / IP-in-IP 跨节点
→ 节点 A 或 B:Traefik Pod:8443
→ Harbor

Service 配有 externalTrafficPolicy: Local,这里对路径的判断来自现场 IPVS 表和路由,不能单凭这个字段推断所有节点内部请求的转发行为。

这条路径差异与日志集中在 C 上吻合:A、B 的入口连接主要走本机 Pod,C 必须经过隧道,MTU 不一致更容易暴露。

健康检查为什么没有直接报出原因#

最初检查时,Harbor health 返回 200、组件 healthy;匿名访问 /v2/ 返回预期的 401,证书校验也正常。三台节点各做几次请求,连接开始到 TLS 完成只有约 42–55ms。

其他线索也没有支持入口普遍过载:

  • 故障时点的 Traefik CPU 很低,当前没有窗口内重启;conntrack 使用量远低于上限,监听队列丢弃计数为 0。
  • 一块物理网卡有历史 CRC 累计值,但故障前一小时及更长采样窗口内没有新增,不能拿历史总数直接解释这次故障。
  • Harbor 确实出现过代理缓存未命中,但回源慢解释的是后续 HTTP 处理,不能直接解释客户端尚未完成的入口 TLS 握手。

这些检查缩小了范围,却没有排除间歇性网络问题。请求是否经过同一路径、TLS 客户端如何分段、当时是否已有 PMTU 缓存,都会影响结果。几次成功的 curl 只能证明那几次请求成功。

第一层:自动探测到了 1480,CNI 却写死了 1500#

IPv4 IP-in-IP 要在原 IP 包外再加一层 20 字节的 IPv4 头。底层能承载 1500 字节时,内层应给封装留出空间:

底层 MTU 1500
减去 IP-in-IP 外层头 20
Pod / 隧道 MTU 1480

这是本次 IPv4 IPIP 场景的计算,换成 VXLAN、WireGuard 或不同云网络,要按实际开销重新算。Calico 官方 MTU 文档列出了对应关系。

现场逐层比较,出现了明确的不一致:

检查对象修复前的值
节点物理接口、bond0、br01500
tunl01480
/var/lib/calico/mtu1480
ConfigMap 的 veth_mtu"0",自动探测
ConfigMap 内的 CNI JSON"mtu": 1500
节点 10-calico.conflist 的 Calico 插件"mtu": 1500
Traefik Pod 的 eth01500

也就是说,Calico 已经算对了隧道 MTU,但 CNI 创建 Pod 网卡时用了另一个固定值。只看 veth_mtu: "0",很容易误以为所有接口都在自动适配。

继续往发布源追,活动的 kubeasz 模板 roles/calico/templates/calico-v3.28.yaml.j2 同样硬编码了这行。模板、已渲染清单、线上 ConfigMap、节点 CNI 文件和实际 Pod 网卡,是一条连续的配置链。

上游 kubeasz issue #1444报告过同类问题,3.6.8 发布说明也列出了 Calico MTU 修复。因此,升级过 kubeasz 工具本身,不能作为现网网络参数已经同步修正的证据;还得查当前模板、生成结果与正在运行的接口。

抓包:握手数据在开头停了约 6.4 秒#

00:40 的包头采样捕获到一条经节点 C 访问远端 Traefik 的连接:

时间观察到的行为
00:40:00.902TCP 建连,对端 SYN/ACK 通告 MSS 1460
00:40:00.903出现 ICMP need to frag (mtu 1480)
00:40:00.907后面一小段数据到达;对端仍 ACK 1,并 SACK 1449:1479
00:40:01–07多次出现相同的分片需求,前面的数据迟迟没有补齐
00:40:07.315重新分段后,先补发 1428 字节,再补齐剩余部分
00:40:07.321服务端开始响应,交换恢复

SACK 表示接收端已经收到了后面的某段数据,但连续数据流前面仍有缺口。首条分片需求到缺口补齐,间隔约 6.412 秒。TCP 三次握手能成功,不代表后面更大的握手数据也能顺利通过。

MSS 与这里的 MTU 也对得上:按 IPv4 和 TCP 各 20 字节的基本头计算,1500 对应 MSS 1460,1480 对应 MSS 1440。实际数据段还可能带 TCP 选项,因此抓到 1428 字节载荷,与额外 12 字节的时间戳选项并不矛盾。

现场 containerd 2.1.1 的默认 Registry transport,TLS 握手预算是 10 秒。数秒的重传与重新分段已经足以占掉大半预算。对应版本源码

这个样本最终恢复了,而且不是 00:29 那条失败连接,也没有单独确认 socket 所属进程。它证明这条入口路径存在 MTU 相关的长停顿,结合配置缺陷和故障分布,使 MTU 成为握手超时的高置信度首要原因;不能写成逐包复现了原始失败。

第一轮修复:配置源与现有接口一起处理#

三台节点同时承担控制面、etcd、业务和 Ceph,另外还有 KubeVirt 虚机及 Multus 附加网络。变更前重新检查 API、etcd、Ceph、业务入口,保存原配置、接口与 Pod/PVC/VMI 身份,并建立两份经过校验的新 etcd 快照。快照保护集群状态,不等于业务卷备份。

让新建 Pod 使用正确的 MTU#

这次保留现有 Calico 版本,按 kubeasz 3.6.8 模板修正活动模板、渲染清单和 ConfigMap 的 CNI 片段:

"mtu": 1500,
"mtu": __CNI_MTU__,

veth_mtu: "0" 继续保留。注意 __CNI_MTU__ 是安装阶段替换的占位符,不能原样写进节点最终的 JSON 配置。本次三台节点落盘 CNI 的对应值改为 0。

这里的 0 不是让网卡使用零 MTU。Calico 3.28.4 在配置 MTU 为 0 时,会采用 /var/lib/calico/mtu 中已经探测出的值;本集群这个值是 1480。Calico 3.28.4 源码

更新 ConfigMap 前做了服务端 dry-run,实际 patch 同时校验 resourceVersion 和旧值,避免覆盖中途变化。本次没有执行整个 Calico role:它还包含证书和 Secret 相关任务,超出了这一个字段的修复范围。

现有 Pod 网卡不会自动跟着变#

修改 CNI 配置主要影响后续新建工作负载,官方文档也明确了这一点。已运行的 Pod 仍要单独处理。

这次选择原位调整经过核验的主网络 veth 两端,先处理一个 Traefik 副本,确认入口正常,再逐台节点继续。每一对接口都交叉检查:

  • 当前 Pod UID、Ready sandbox、网络命名空间 inode 和 Pod IP。
  • Pod eth0 与主机 cali* 的双向 peer ifindex,确认确实是一对 veth。
  • 不属于 host 网络,不是桥成员,也不是虚机的附加网卡、tap 或 br0。

每次写入前记录原值,匹配失败就停止;恢复时也重新检查身份,防止旧 ifindex 被复用后改到别的接口。没有仅凭历史 WorkloadEndpoint 或网卡名字批量修改。

第一轮共处理 254 对活动主网络 veth,两端均从 1500 降到 1480。物理接口和虚机附加网络保持原值。这种原位处理是本次经过身份核验后的选择,并不是所有集群都应该照搬的通用迁移方式。

120 次成功以后,仍然出现了失败#

如果到这里就停,文章会变成“找到硬编码,改成 1480,问题解决”。但现场并没有这么顺利。

第一轮调整过程中,120 次 Harbor 健康请求全部成功。接着换独立 Go HTTPS 客户端验证,02:23:26 的第一个样本却在 10.065 秒后失败;恢复一段时间后,又在约一个缓存周期后出现一次约 10 秒失败。

这一阶段共 130 次 Go 请求,失败 2 次。首次记录没有保存 stderr,不能仅凭耗时给这两条记录补上确定的错误字符串。它们已经足以阻止验收,也促使排查继续往下走。

此时 Pod 网卡的 1480 已经核实,但 SYN/ACK 里同时出现 MSS 1440 和 1420。后者对应的有效 IP MTU 是 1460:还有一层限制没有处理。

第二层:节点互联的 PMTU 缓存也被压低了#

接口 MTU 是设备设置,PMTU 是某条目的路径当前可用的最大 IP 包大小。ip link 上看到 1500,不代表内核对每个目标都按 1500 发送。

路由检查的关键结果是:

节点 C → 节点 A/B,走 br0:路径缓存 mtu 1480
节点 C → 远端 Traefik Pod,走 tunl0:有效路径 mtu 1460
Harbor VIP:也出现了 mtu 1460 的缓存

节点 A 到 C 的方向也发现类似缓存。于是,本来应该用来承载 IPIP 外层的 1500 字节节点路径,被按 1480 计算,再扣一次 20 字节封装开销,内层就只剩 1460。

先区分真实链路限制与缓存限制#

普通的 DF 探测,1500 字节报文被本机直接拒绝,提示 message too long, mtu=1480。这是本地发送失败,还不能说交换机丢掉了包。

随后,在修改路由之前,用只对本次探测 socket 绕过 PMTU 缓存检查的方式验证。下面的地址是文档示例,执行时应替换为已确认的同网段对端节点:

Terminal window
peer_ip=192.0.2.11
# IPv4 头 20 + ICMP 头 8 + payload 1472 = 1500 字节
ping -4 -c 3 -W 2 -M do -s 1472 "$peer_ip"
# 保持 DF 探测,绕过内核已缓存的 PMTU 检查
sudo ping -4 -c 3 -W 2 -M probe -s 1472 "$peer_ip"

三个节点的六个方向,第二种探测全部 3/3 成功。真实底层链路能承载 1500,阻止普通探测的是缓存的路径限制。 probe 的作用限于该探测,不是关闭全局 PMTU。ping 手册

这与 Calico IPIP、host-network 和 SNAT 的 PMTU 缓存问题 #1709吻合:节点 IP 既用于隧道端点,也可能用于经过源地址转换的流量,较小的路径 MTU 会影响后续封装路径。这次确认了缓存值和真实链路能力的差异,但没有追到最初是哪一个包改变了缓存,也没有据此认定某个内核版本缺陷。

缓存同时给“重试就好了”提供了解释:内核学到较小的 MTU 后,后续连接可能暂时正常;缓存过期,新连接又可能遇到同样的分段问题。因此验收必须包含缓存自然过期后的连接,不能只连续测几十秒。

第二轮修复:只固定已验证的节点互联路径#

这次没有继续把 Pod MTU 一路往下调,而是在每台节点为另外两台节点增加精确的 /32 路由:沿用原接口和首选源地址,不经过网关,将路径 MTU 固定为已验证的 1500。三个节点合计六条,逐台实施并检查 API、etcd 和 Ceph。

下面仅展示一条路由的形式,示例地址需替换。前提是已证明这条同网段底层路径能承载 1500,并确认不存在需要保留的同目标 /32 路由。 本次先保存了六个目标的原状态,均无已有精确路由。

Terminal window
peer_ip=192.0.2.11
local_ip=192.0.2.13
sudo ip -4 route add "$peer_ip/32" dev br0 \
proto static scope link src "$local_ip" mtu lock 1500

mtu lock 会改变命中该路由的 PMTU 发现与 IPv4 DF 行为。因此它是本次六条已验证节点路径的针对性处理,不是 Calico 必配项,更不能照搬到链路 MTU 未知的跨网段或公网路径。命令含义见 iproute2 的路由手册。

路由通过每节点独立的 k8s-peer-mtu.service 持久化:oneshot,在 network-online 之后、containerd 和 kubelet 之前执行,目标仅为另外两台节点。单元语法、enabled/active 和实际路由都验证通过。

本窗口没有重启节点验证开机过程。以后调整节点地址、底层 MTU、网桥或重新应用网络配置,需要重新核对这些路由;新增节点也不能自动继承这六条静态记录。

恢复方法同样限定在本次对象内:核对目标路由仍与变更记录一致,停止并禁用对应服务,删除本次新增的 /32,回到原连接路由。只停止 oneshot 服务不会自动删除已经写入的路由。 CNI、模板和现有接口的回退另按备份协调处理,普通 MTU 回退不使用 etcd 整库恢复。

最后如何确认修复有效#

第二轮修复后,从 02:40:26 到 02:52:14,进行了 708.4 秒、140 次独立 Go HTTPS 请求:

验证项结果
Go HTTPS 请求140/140 成功,平均 0.095 秒,最大 0.116 秒
缓存自然过期原低 MTU 缓存消失后仍成功,没有清空全局路由缓存
Traefik SYN/ACK两个后端共四条最终样本,MSS 均为 1440
分片需求采样最终 12 秒针对 Harbor VIP 的抓包,0 匹配、0 kernel capture drops
新建 Pod每节点一只,主网络 veth 两端自动使用 1480
跨节点 Pod 大包六方向各三次 DF 1480 字节探测,18/18 成功
超限报文1481 字节在源接口按预期被拒绝
Pod 内 DNS 与 HTTPS三个测试 Pod 均通过
原生 CRI 拉取校验三节点使用同一镜像 digest,均成功并返回相同 imageID

表中的 HTTPS 耗时包含 CLI 启动和 HTTP 响应,不是单独的 TLS 握手耗时;12 秒抓包也只代表该采样窗口。CRI 输出为 up to date,因此不把它写成重新下载过全部镜像层,网络结论来自多项检查共同支持。

临时测试 Pod 使用已缓存镜像,不挂业务卷,验证后连同专用 namespace 一起清理。最终生产接口盘点仍为 254 对主网络 veth,全部 1480/1480。另外复核了:

  • 存续接口的身份、IP/MAC 和附加网络字段,非 Calico 主机接口的 MTU 与身份均保持一致。
  • 10 个 VMI 的 UID、节点、运行状态和网络字段,128 个 PVC 的 UID、PV 绑定及 Bound 状态保持一致。
  • 核心网络、DNS 组件 Ready,API、三节点、etcd 健康;Ceph 18 个 OSD up/in、三个 MON 保持仲裁,没有新增降级。

本任务没有重启节点、虚机,也没有发起业务 Pod 重建。窗口中仍有 HPA、发布和定时任务带来的正常 Pod 变化,所以不能把这些结果扩写成“全体业务零波动、所有 Pod 始终不变”。前一阶段两次失败也保留在记录里,最终 140 次零失败只属于完成两轮修复后的观察窗口。

这次留下的排查顺序很明确:先确认超时发生在哪个协议阶段,再沿真实路径核对 模板 → CNI 配置 → 活动网卡 → 路由 PMTU → TCP 分段。MTU 看起来只是一个数字,实际却有多处配置和缓存参与;修好一处后,仍要让新建 Pod、已有连接路径和缓存过期后的新连接分别给出证据。