回环网络不可能丢包,但它重传了 62 次
回环网络不可能丢包,但它重传了 62 次
站内搜索
直接问 AI

回环网络不可能丢包,但它重传了 62 次

回环网络物理上不可能丢包——没有网线、没有交换机、没有排队丢弃。可 ss 在 lo 上报了 62 次重传。这条”不可能”的观测是整条故障链上唯一的入口,顺着它查下去,根因是一条本该保护上游节点的防火墙规则,保护错了地址。

一、症状:高峰期卡死,重启就好,重启后查不到

一台跑透明代理的家用网关,症状是典型的”说不清楚”:晚上高峰期新连接大面积失败,客户端疯狂重试,网关的 CPU 打满,重启之后一切正常。

最难受的是重启把证据全销毁了。这台机器上:

logread        128KB 内存环形缓冲,重启即空
/var/log       tmpfs
collectd/RRD   没装
pstore/ramoops 没有
wtmp           空的

更糟的是 kernel.panic_on_oops=1 配 kernel.panic=3——任何内核 oops 会在 3 秒后静默重启,连痕迹都不留。查一台机器的历史故障之前,先确认它有没有能活过重启的日志。没有的话,第一件事不是查故障,是先把日志落到磁盘上,然后等它复现。

二、第一个线索:一个物理上不该存在的数字

先看 CPU。四个核里只有两个在忙,load average 只有 2 左右——看起来”负载正常”。这是个陷阱:这台机器的网卡是单队列且中断只落在特定的核上,网络处理只用得上一半的核。等 load average 报警,网络早就死透了。所以别用 load average 判断这类机器的网络健康。

然后看连接状态,发现了那个不该存在的数字:

ss -tin | grep -c retrans        # 回环连接上的重传

62。

这个数字的意义在于它不该存在。回环(lo)不经过任何物理介质:没有链路层丢包,没有队列溢出,没有中间设备。一个包进了回环,除非有人主动丢它,否则不可能需要重传。

所以问题从”网络为什么慢”变成了一个精确得多的问题:谁在丢本机自己发给自己的包?

三、丢的是哪一种包:从状态分布反推

62 次重传不是均匀分布的,按 TCP 状态拆开:

LAST_ACK      53
CLOSE_WAIT     9
其它            0

这个分布把答案几乎写在脸上了。这两个状态都出现在连接拆除阶段:

  • CLOSE_WAIT:收到了对方的 FIN,本地还没关
  • LAST_ACK:本地发出了 FIN,在等对方的最后一个 ACK

重传全部堆在拆连阶段,意味着被丢的是拆连信号。而在这套数据面里,拆连信号主要是 RST——上层代理进程之间用 RST 快速拆连,而不是走完整的四次挥手。

四、根因:规则保护错了地址

网关上有一条 nftables 规则,作用是丢弃来自一组上游地址的 RST 包(这类规则在对抗某些中间设备注入的伪造 RST 时是常见做法):

ip saddr @protected_node_v4 tcp flags & rst == rst  drop

那个 @protected_node_v4 集合由一个 cron 脚本每两分钟从配置里同步一次。脚本用 awk 抓的是上层代理配置里的 server: 字段。

问题就在这儿:这套链路是两层的。上层代理把流量交给本机的下层进程,所以它配置里六个节点的 server: 全部是 127.0.0.1;真正的上游地址在另一个配置文件的 address 字段里。

于是同步出来的集合是:

protected_node_v4 = { 127.0.0.1 }

这条规则从此忠实地执行它被告知的任务:丢掉所有源自 127.0.0.1 的 RST。实测 21 分钟丢了 1074 个,折合每天约 73,500 个。而那些 RST 正是两个本地进程之间的拆连信号。

五、完整的故障链

RST 被丢
  → 两端 socket 卡在 LAST_ACK / CLOSE_WAIT,不释放
  → fd 与 socket 缓冲持续累积
  → 进程的 nofile 上限只有 4096
  → 高峰期逼近上限,新连接全部创建失败
  → 客户端疯狂重试
  → 那一两个能处理网络的核被打满
  → 表现为"网络卡死"

fd 的量级值得记一下:实测 252 条连接占用了 1983 个 fd,比值约 7.8。这个比值在多层代理里很常见(每条逻辑连接在链路上要开好几个 socket),但配上 4096 的上限,意味着大约 520 条并发连接就到顶了——对一个家庭网关来说,这个数字低得可笑。

六、一个会让你追错方向的岔路

盯着 fd 数看的时候,很容易得出”上游进程有 fd 泄漏”的结论,然后一头扎进它的 issue 列表。

但实测下来 fd 会回落:1968 → 980。

泄漏是单调的,回落说明这些 fd 最终还是被回收了——只是回收得太慢,慢到高峰期撑不住。这是”释放被阻塞”,不是”释放没发生”。这两者的修法完全不同:前者要找是谁在阻塞,后者才是去追上游的 bug。

判据很简单:观察足够长的时间,看曲线单调不单调。只看一个瞬时值,两者长得一模一样。

七、同一个 bug 的另一半:一个一直在空转的实验

同一个错误的集合还被另一条规则引用,那条规则给发往上游节点的流量加固定延迟,用来做对照实验:

oifname "eth0" ip daddr @protected_node_v4  ...

集合里只有 127.0.0.1,而发往回环的包永远不会从 eth0 出去。所以这条规则的计数器恒为 0——那个跑了很久的延迟对照实验,从第一天起就没有生效过,而它不报任何错误,只是安静地什么也不做。

这半边不造成故障,但它的教训更普遍:一条从不匹配的规则和一条工作正常的规则,在没有计数器的情况下看起来完全一样。加规则的时候顺手看一眼 nft list ruleset -a 里的 counter,比事后推理便宜得多。

八、修复与验证

修复本身很小:同步脚本改成读正确的配置文件,并且加一层公网地址白名单——挡掉回环、RFC1918 私网、link-local、组播,以及代理软件常用的 fake-ip 段。

验证要看的不是”感觉好了”,而是那几个具体的数:

指标 修复前 修复后
回环重传 62 0
回环连接状态 11 CLOSE_WAIT + 7 LAST_ACK + 6 FIN_WAIT2 110 ESTABLISHED + 49 TIME_WAIT
集合内容 { 127.0.0.1 } 4 个真实上游地址

TIME_WAIT 的出现是关键信号。它意味着连接是正常走完拆除流程的——主动关闭方在等待可能迟到的重复 FIN。之前一个 TIME_WAIT 都没有,全卡在 LAST_ACK,本身就说明拆连从来没有成功过。

顺手把两个进程的 nofile 从 4096 提到 65535。这不是修复,是把那个荒谬的天花板挪开。

九、第二个坑:nftables 的链优先级决定了你能取到什么状态

修完之后想给那条丢 RST 的规则收窄范围——伪造的 RST 通常注入在握手前后,而正常拆连的 RST 出现在连接末尾。所以理想的条件是”只丢连接早期的 RST”:

ct original packets <= 20

原规则挂在 prerouting priority raw,也就是 -300。直接在原位置加这个条件,是最省事的改法。

而它会完全不生效,且不报任何错。

原因是 conntrack 在 priority -200 才运行。在 -300 的 raw 链里,连接跟踪状态还不存在,ct original packets 取不到值,条件默默地不匹配。

这个结论不是推出来的,是对拍出来的:建两条一模一样的规则,一条挂 raw 链,一条挂 mangle 链,各带计数器,跑一段时间:

raw 链    计数器 = 0
mangle 链 计数器 = 108

所以新链改挂 priority mangle - 10。收窄之后的效果同样有具体数字:卡在 LAST_ACK 的连接从 40–90 条降到 0,到上游的重传归零,而早期 RST 的丢弃计数仍在持续增长——防护还在工作。

这一条值得单独记住:nftables 里”能不能取到某个状态”取决于你的链挂在哪个优先级,取不到时不报错,只是安静地不匹配。写任何带 ct 条件的规则之前,先确认它跑在 conntrack 之后。

十、这次学到的几条

  • 找一个”物理上不可能”的观测当入口。回环重传就是这样一个观测——它把”网络慢”这种没法查的描述,压缩成”谁在丢本机的包”这种能查的问题。
  • 状态分布比总数有信息量。62 次重传本身说明不了什么;62 次全在 LAST_ACK 和 CLOSE_WAIT,直接指向了拆连信号。
  • 先看曲线单调不单调,再决定要不要去追上游的 bug。fd 涨了不等于泄漏。
  • 一条从不匹配的规则不会告诉你它没匹配。计数器是唯一的区分手段,而且要在部署时就看,不是出事后才看。
  • 怀疑一条规则静默失效时,用对拍。建两条只差一个变量的规则同时跑,比读文档快,也比推理可靠。
  • 没有能活过重启的日志,就没有故障排查。这台机器上先补的不是修复,是持久化日志和每分钟一行的资源趋势记录——那是下次崩溃唯一能留下的东西。

References

相关阅读:TCP 的可靠性与拥塞窗口(重传在协议层面的含义)、正向与反向代理的信任边界(多层代理的结构)、SOCKS5 代理与 DNS 的边界。

发表回复

向下探索