一个"偶尔慢一下"的问题

客户反馈线上环境偶尔会出现接口超时。不是大面积的,不是持续性的,就是"偶尔慢一下"。

我们对一个测试地址做了简单的监控:每10秒访问一次,记录响应时间。大部分情况正常,但隔一段时间就会冒出一两个响应时间异常长的请求。

说实话,这种问题是最难排查的。如果是全部超时,那大概率是服务挂了或者网络断了,很容易定位。但"偶发性"意味着问题是概率性的,能复现但不可预测,排查过程注定是一场拉锯战。

我们的集群环境是自建的K8s,网络插件用的Calico,服务通过NodePort和Ingress对外暴露。整个排查过程持续了将近两周,经历了三个阶段,最终发现问题的根因藏在一个很多人不会关注的地方——NAT源端口冲突。

第一阶段:排除应用层

接到反馈的第一反应是去看服务本身有没有问题。

登上Pod检查了应用的响应时间——稳定在10ms左右,没有任何抖动。GC日志正常,线程池没有满,数据库连接池也没有排队。应用层可以排除。

那就往下看,检查宿主机。登上27号机器(我们的master节点,同时承担了镜像仓库、NFS等职责),用ss看端口占用情况,发现了一个异常:5000端口有大量的ESTABLISHED连接。

一查,是image-cri-him这个容器运行时相关组件的问题,它在不断建立连接但没有正确释放。端口耗尽虽然不会直接导致超时,但在高并发时会影响系统整体的网络栈性能。升级了这个组件。

同时检查了内核参数,发现ulimit -n设的是1024——这个值对于跑K8s的节点来说实在太小了。每个Pod的每个连接都会占用文件描述符,1024很容易触顶。调整到1000000。

# 调整最大文件描述符
ulimit -n 1000000

# 持久化到 /etc/security/limits.conf
* soft nofile 1000000
* hard nofile 1000000

调完之后观察了几天,超时的频率确实降低了,但还是偶尔会出现。

问题没有彻底解决,继续挖。

第二阶段:网络层抓包

既然应用没问题,宿主机也做了基本优化,那问题大概率出在网络层。

我们做了几个对比测试:

  1. 在27号机器上单独跑了一个Docker容器(不经过K8s网络),同样做10s间隔监控——结果偶尔也会超时,但频率低很多
  2. 换到38号机器做同样的测试——频率更低
  3. 新增59号机器作为独立的对外流量入口——基本不超时

这就有意思了。说明问题跟27号机器的网络状态有强相关。27号机器作为master节点,本身承载了太多职责:K8s API Server、etcd、镜像仓库、NFS……这些服务会产生短时突发的高流量,对网络栈造成压力。

但这只是"放大器",不是根因。因为在38号机器上问题虽然频率低,但并没有完全消失。

开始抓包。用tcpdump在节点层面抓取流量,重点关注超时时间点附近的报文:

tcpdump -i eth0 -nn -s 0 -w capture.pcap host <目标IP>

分析pcap文件,发现了关键线索:超时时间点存在大量的TCP重传包。

重传意味着包在网络传输过程中丢了。对于内网环境来说,物理链路丢包的概率极低,那丢包大概率发生在软件层——也就是Linux内核的网络栈或者K8s的网络插件层。

检查了内核网络参数:

sysctl net.ipv4.tcp_tw_reuse
# 1
sysctl net.ipv4.tcp_tw_recycle
# 1

这两个参数都设成了1。在普通服务器环境下这么设问题不大,但在K8s + NAT环境下,tcp_tw_recycle=1是一个已知的坑。

为什么tcp_tw_recycle在NAT环境下会出问题?

tcp_tw_recycle开启后,内核会通过TCP时间戳(TCP Timestamps)来加速TIME_WAIT状态的socket回收。它的逻辑是:如果新来的SYN包的时间戳比该连接最后记录的时间戳小,就直接丢弃这个SYN包。

在没有NAT的环境下,这没问题——同一个客户端IP发出的包,时间戳一定是单调递增的。

但在K8s环境中,Pod的流量经过SNAT出去时,多个Pod的连接会共享同一个节点IP。不同Pod的TCP时间戳是独立的,完全没有单调递增的保证。这就导致内核误判:“这个SYN的时间戳比我记录的小,一定是个旧包”——然后静默丢弃。

对应到我们观察到的现象:偶尔有请求超时,服务端没有任何异常日志(因为SYN包压根没到应用层),客户端等到超时才重试。

调整参数:

sysctl -w net.ipv4.tcp_tw_reuse=0
sysctl -w net.ipv4.tcp_tw_recycle=0

改完后超时频率继续降低,但还是没有完全消失。

第三阶段:SNAT端口冲突——真正的元凶

前两个阶段解决了"放大器"和"帮凶",但核心问题还在。

继续逐台检查集群所有节点的内核参数,发现node3这台机器的tcp_tw_recycle修改没有生效——可能是重启后sysctl配置没有正确持久化。修复后问题频率进一步降低。

但是,问题还没有归零。

再次抓包分析,这次重点关注的是Calico的数据包处理路径。在K8s中,Pod访问外部服务时,数据包会经过以下路径:

Pod → veth pair → 宿主机网络栈 → iptables SNAT → 物理网卡 → 外部网络

SNAT(Source NAT)环节是关键。内核需要为每个出站连接分配一个源端口,用于NAT映射。默认情况下,内核的端口分配策略是顺序递增的——这在高并发场景下有极大的冲突概率。

什么是SNAT端口冲突?

当两个Pod在同一时刻发起到同一目标IP:Port的连接,且内核为它们分配了相同的源端口时,NAT表就会产生冲突。冲突的后果是:SYN包被静默丢弃,直到客户端触发重传。

这正是我们观察到的"偶发性超时"的典型症状——不是慢,而是丢了第一个SYN包,等了重传超时(通常1秒)后才成功建连。

解决方案是升级Calico中的NAT规则,启用NF_NAT_RANGE_PROTO_RANDOM_FULLY标志:

# 在iptables的SNAT规则中添加 --random-fully 参数
iptables -t nat -A POSTROUTING -s 10.244.0.0/16 -j MASQUERADE --random-fully

--random-fully的作用是让内核在分配SNAT源端口时采用完全随机策略,而不是默认的顺序递增。这将端口冲突的概率从"确定性冲突"降低为1/65535级别的随机碰撞——在实际负载下基本可以忽略不计。

升级Calico配置后,超时问题基本消失。

完整的问题图谱

回头看整个排查过程,这不是一个单一原因导致的问题,而是多因素叠加:

因素影响解决方案
image-cri-him端口泄漏占用大量端口资源,挤压可用端口空间升级组件
文件描述符上限过低(1024)高并发时FD耗尽导致连接失败调整到1000000
tcp_tw_recycle=1NAT环境下时间戳判断失误,静默丢弃SYN设为0
master节点职责过重短时高流量冲击网络栈新增独立流量入口节点
SNAT默认顺序分配端口高并发下端口冲突,SYN丢弃启用–random-fully

这五个因素,单独拿出来任何一个,可能都不会造成明显的问题。但它们叠加在一起,就形成了"偶发性超时"这个看起来简单、排查起来极其困难的现象。

几个经验教训

1. K8s环境下必须关闭tcp_tw_recycle

这是一个老生常谈的问题,但仍然有很多人在部署K8s时忘了改。Linux 4.12之后的内核已经彻底移除了这个参数(因为它在NAT场景下就是有bug的),但如果你用的是老内核,务必确认设置为0。

# /etc/sysctl.d/k8s.conf
net.ipv4.tcp_tw_recycle = 0
net.ipv4.tcp_tw_reuse = 0

2. SNAT的–random-fully不是可选项,是必选项

对于任何使用iptables做SNAT的K8s网络方案(Calico、Flannel等),--random-fully应该作为默认配置。没有它,在流量稍大的环境下就会出现端口冲突导致的丢包。较新版本的Calico和kube-proxy已经默认启用了这个选项,但升级老集群时需要手动确认。

3. Master节点不要承载业务流量

我们的27号机器既是master节点,又是镜像仓库,又是NFS服务器,还是对外流量入口。这种"全能型选手"的部署方式,在集群规模小的时候省资源,但任何一个服务的流量尖峰都会影响其他所有服务。最终我们把对外流量入口迁移到了独立的59号机器。

4. "偶发性"问题多半是多因素叠加

如果一个问题能稳定复现,排查起来反而简单——改一个变量观察结果就行。偶发性问题难就难在,它往往不是单一原因,而是多个因素恰好同时满足条件时才触发。这时候需要做的是逐层消除:先解决确定性问题(FD上限、组件bug),再处理概率性问题(tcp_tw_recycle、SNAT端口冲突),每消除一层,观察问题频率的变化。

5. 内核参数修改必须做持久化验证

我们在第三阶段发现node3的参数修改没有生效——修改了运行时的sysctl但没有写入配置文件,或者写了配置文件但重启后没有正确加载。建议的做法是修改后立即验证:

# 修改
echo "net.ipv4.tcp_tw_recycle = 0" >> /etc/sysctl.d/k8s.conf
sysctl -p /etc/sysctl.d/k8s.conf

# 验证
sysctl net.ipv4.tcp_tw_recycle
# 期望输出:net.ipv4.tcp_tw_recycle = 0

写在最后

这次排查最大的收获,不是找到了某个具体的技术参数,而是对K8s网络数据链路有了更深的理解。

很多时候我们用K8s,关注的是Pod调度、Service发现、配置管理这些"上层"的东西。但网络这一层,才是真正的基础设施——CNI插件怎么建虚拟网络、iptables规则怎么做流量转发、SNAT怎么处理端口映射、内核参数怎么影响TCP行为——这些东西平时看不见摸不着,但出了问题就是"偶尔慢一下"这种让人抓狂的症状。

如果你的K8s集群也有类似的偶发性超时,不妨按这个思路排查一遍:先确认应用层无问题,再检查内核参数,最后重点关注SNAT端口分配策略。大概率,答案就藏在NAT背后。

更多推荐