上午十点半,正是核心业务的早高峰期。我刚把显示器切到监控大盘,Prometheus的告警就如期而至:几个核心交易链路的接口延迟P99直接飙到了5秒以上,网关层开始大面积报502和504。
登录终端,随便挑了一台承载核心交易Pod的K8s Node进去。系统负载(Load Average)看起来不高,CPU和内存都还有富裕,但网络包的丢弃率在急剧攀升。
条件反射般地敲下第一条命令:
dmesg -T | tail -n 20
屏幕上满屏的红色:
[Tue Oct 24 10:33:12 2023] nf_conntrack: nf_conntrack: table full, dropping packet
这就很有意思了。连接跟踪表被打满,说明这台节点上产生了海量的连接状态。继续往下看连接分布:
ss -s
输出结果极为离谱:
Total: 312045
TCP: 320112 (estab 1024, closed 318050, orphaned 0, timewait 315000)
单台节点上竟然堆积了超过31万个 TIME_WAIT 状态的TCP连接。
我顺着 ss -ntp 的输出,找出了占据这些连接的罪魁祸首——一个早上九点半刚由某位“高级开发”灰度上线的促销微服务。
我直接去拉了那个微服务的代码仓库。在看了一眼他们调用下游库存服务的逻辑后,我大概理解了什么叫做“用最现代的语言,写最复古的Bug”。
这位同事在Golang的HTTP调用逻辑里,写出了这样的代码:
func CheckInventory(ctx context.Context, req Request) error {
// 为了每次请求都能均匀打到下游Pod,这里禁用KeepAlive
tr := &http.Transport{
DisableKeepAlives: true,
}
client := &http.Client{Transport: tr}
resp, err := client.Post(...)
// ...
}
注释写得很“贴心”:“为了每次请求都能均匀打到下游Pod,这里禁用KeepAlive”。
在这一刻,我确实感受到了一种架构认知上的断层。
在K8s环境中,通过ClusterIP调用下游服务,流量会经过 kube-proxy 维护的 iptables 或 IPVS 规则进行负载均衡。一旦涉及到 NAT(网络地址转换),内核就必须依赖 netfilter 的 conntrack 模块来跟踪这个连接的状态,以此保证请求包和响应包能正确映射。
这位同事为了解决所谓的“长连接负载不均”问题(这本该通过客户端侧的软负载均衡、Service Mesh或者gRPC的LB策略来解决),硬核地选择了最愚蠢的方式:在高达数千QPS的早高峰并发下,对每一个请求发起完整的TCP三次握手,并在请求结束后由客户端主动关闭连接。
TCP协议的常识是什么?主动关闭连接的一方,其端口状态必然会进入 TIME_WAIT,并且会在内核中保留 2 * MSL(Linux下默认通常是60秒)。
这就意味着,如果这个接口的QPS是5000,那么一分钟内就会产生 300,000 个 TIME_WAIT 连接。Linux默认的本地临时端口范围(net.ipv4.ip_local_port_range)通常只有 32768 到 60999,不到三万个端口。即使端口能复用,节点的 nf_conntrack_max(此集群配置为262144)也会瞬间被击穿。
一旦 conntrack 表满,内核的网络栈就会毫不留情地丢弃所有新建连接的SYN包。不仅是这个促销微服务,这台Node上调度的其他无辜Pod(包括 CoreDNS 的本地缓存请求)也全部因为无法建立网络连接而跟着陪葬。
这就叫一颗老鼠屎,炸掉整个宿主机的网络栈。
为了先让业务恢复,我在几台受影响的Node上执行了紧急止血操作,动态拉高 conntrack 上限,并允许 TIME_WAIT 快速回收:
# 紧急扩大连接跟踪表
sysctl -w net.netfilter.nf_conntrack_max=1048576
sysctl -w net.netfilter.nf_conntrack_buckets=262144
# 开启 TIME_WAIT 状态复用 (仅对出站连接有效,刚好符合此场景)
sysctl -w net.ipv4.tcp_tw_reuse=1
随后,我直接在运维群里通知回滚了早上九点半的所有发布。五分钟后,监控上的错误率悬崖式下跌,P99延迟回落到20毫秒的正常水位。
很多写业务代码的人,对底层基础设施有一种莫名其妙的傲慢,认为有框架和容器兜底,自己只需要关注业务逻辑。
但在高并发的分布式系统里,所有的抽象都是有漏水的。你不懂TCP状态机,不懂网络栈的连接跟踪机制,不懂四层负载均衡的原理,盲目地在代码里挥舞“优化”的大棒,最终的结果就是把基础组件变成业务的殉葬品。
记住:不要用破坏内核协议栈行为预期的方式,去弥补你在架构设计上的无知。 连接池(Connection Pool)被发明出来,就是为了不让你干这种蠢事。