为什么连接池会收到莫名的 RST:AWS NLB/NAT 网关不发 FIN 的坑与 eBPF 排查法
You're Not Crazy, They Never Sent Fin
AWS 文档指出,Network Load Balancer 和 NAT 网关的默认连接空闲超时为 350 秒,超时后不发送 FIN 包,只是丢弃连接状态,导致客户端下次写入时收到 TCP RST。
AWS 文档里有一段话,在 Network Load Balancer 下、"Connection idle timeout" 那一节,是这么写的:
如果客户端或目标在超过空闲超时的时间内都没有通过连接发送任何数据,该连接就不再被跟踪。如果客户端或目标在空闲超时时间过后发送数据,客户端会收到一个 TCP RST 包,表示该连接已不再有效。
默认是三百五十秒。NAT 网关页面说的也是一样,并在括号里补充了关键的那部分:它不会发送 FIN 包。
你会在追查一个 502 的大约第四个月找到这段话——这个 502 每晚发生几次,从不在白天出现,从不在你盯着看的时候出现,来自一个日志干干净净的服务。你会找到它,是因为有人在 2019 年在一个论坛上发现了它,你会读两遍,然后大声说一句"哦"。虽然去 AWS 文档里搜一句你根本不知道自己要找的话也挺有趣、挺容易的,但要是能在事情发生时就抓住它,那就更好了。
所以就有了这篇文章。这段话是什么意思,如何在一台机器上用十五秒而不是四个月复现它,以及那 110 行 eBPF 代码——它让内核告诉你,是谁重置了你,以及事情发生时你已经空闲了多久。
12:27:52 RST from 127.0.0.1:8091 -> python[220588] 127.0.0.1:58330 idle 355.0s before the write that got reset
TCP 快速回顾
你认识 SYN 和 ACK。来认识一下 FIN 和 RST。
大家都见过握手。你的进程调用 connect,内核发送一个 SYN,对端回应 SYN-ACK,你回一个 ACK,于是就有了一个连接:两个套接字,每台主机各一个,就序列号达成一致。在你发送一个字节之前,先要一个往返。上面再套上 TLS,就又是两个往返外加一些数学运算。这就是连接池存在所要跳过的开销,也是每个值得使用的 HTTP 客户端都会保留一个连接池的原因。
另外两个标志讨论得较少,但同样重要。
FIN 是礼貌的关闭。它的意思是"我没有更多要发送的了。"每一方在结束时各自发送自己的 FIN,另一方确认它。当你的内核收到一个 FIN 时,它会标记该套接字,之后对它的下一次读取会返回流结束。
RST 是粗鲁的关闭。就像你高中女友没说再见就挂断电话那样。它的意思是"这里没有这样的连接,停下。"当收到一个发往内核并不拥有的套接字的数据包时,或者当一个程序在还有未读数据的情况下关闭时,内核就会发送它。没有任何协商。你的下一次读取或写入会以 connection reset by peer 失败。你大概不该告诉她你觉得派对上那个女孩很可爱。
现在说关键的部分。一个两者都没收到过的套接字,看起来和一个健康的套接字一模一样。没有任何标志表示"中间那个东西把你忘了。"如果一个负载均衡器在没有发送 FIN 的情况下丢弃了连接,你的内核没有任何东西可标记,你的连接池的存活检查没有任何东西可读,而间隙之后的第一次写入就是所有人第一次听说这件事——来自一台已经不再知道你是谁的主机发来的 RST。
好,那么连接池到底在做什么
连接池的存在是为了跳过握手。对于一个每秒调用支付服务两百次的服务来说,这就是一个快速的服务和一个大部分时间都在发 SYN 的服务之间的区别。所以当一个请求完成时,连接会被放回架子上,下一个请求从架子上取走它,而不是重新拨号。
架子上有个计时器。requests 只要服务器说保持连接就保持连接,urllib3 默认是永远,Go 的传输层是 90 秒,你的 Java 客户端则是 2019 年某人设置的某个值。架子上那个东西,是内核仍然认为处于打开状态的套接字。
没人问的问题是,路径上的其他人是否同意。
路径上的每个人都有自己的计时器
结账服务和支付服务之间的 TCP 连接是一对套接字,两端各一个。但数据包会经过一些东西,而这些东西会保持状态:
| 中间设备 | 在多久后放弃 | 放弃时发送什么 |
|---|---|---|
| nginx、HAProxy、Envoy | keep-alive 超时,nginx 默认 75 秒 | FIN |
| AWS Application Load Balancer | 60 秒 | FIN |
| AWS Network Load Balancer | 350 秒 | 什么都不发,在你下次写入时发 RST |
| AWS NAT gateway | 350 秒 | 什么都不发,在你下次写入时发 RST |
| Azure Load Balancer | 4 分钟 | 什么都不发,除非你开启“空闲时 TCP 重置” |
| 有状态防火墙或 conntrack 表 | 出厂设置是什么就是什么 | 通常什么都不发 |
每个设备都会在自己的超时之后忘记这条连接。有礼貌的会发送一个 FIN,内核会标记该套接字,这样下次连接池要复用它时,库会注意到并重新拨号。每个库处理方式略有不同,urllib3 正是出于这个原因,在每次复用前做一次零超时可读性检查。Go 会保留一个 goroutine 读取每个空闲连接。但一般来说,有礼貌的关闭是被处理了的。
不礼貌的那些什么都不发。网关不会关闭连接,它只是忘掉它。结果就是,在套接字再次被使用之前,所有人只能假设它是健康的。连接池的可读性检查看不到任何可读内容,因为确实没有可读内容。然后你写入,而网关已经不知道你是谁了,于是用一个重置来回应。
所以这个 bug 有特征。它只发生在间隔超过中间设备超时之后的第一个请求上。当流量很低、一切看起来都正常的时候。它看起来像是随机的,重试就能修复,因为重试会拨一个新连接。这就是它能在生产环境里存活多年的原因——外面包一层重试,再加一句注释写着 # payments is flaky。
让我们拿这个小家伙试试
我没有四个月时间,所以我构建了能故意复现这个 bug 的最小东西。一台运行 Fedora 42 的机器,上面有两个 Python 服务,中间用 nginx 扮演负载均衡器:
payments 响应 POST /v1/charges。checkout 使用普通的 requests 会话,发起一次调用,休眠 15 秒,再发起一次。nginx 的 keep-alive 设为 10 秒,这样我就不用滚动 X 等待 bug 发生。nftables 规则和 rstwatch 稍后就来。
首先,让 nginx 保持礼貌:
$ .venv/bin/python checkout.py 15 2
10:56:40 checkout: POST /v1/charges -> 200 {"charge_id": "ch_1791568600", "status": "ok"}
(idle for 15s)
10:56:55 checkout: POST /v1/charges -> 200 {"charge_id": "ch_1791568615", "status": "ok"}
两个 200。nginx 在 10 秒时关闭了空闲连接并发送了 FIN,urllib3 看到了,打开了一个新连接,没人注意到。这就是理想情况。
现在我把 nginx 变成一个 NAT 网关。一条 nftables 规则,丢弃 nginx 在其面向客户端端口上发送的 FIN:
nft add rule inet lbdemo output tcp sport 8091 'tcp flags & fin == fin' drop
nginx 仍然按计划关闭自己这一侧。客户端只是从未听说这件事,这就是我们在 AWS 上看到的文档化行为:
$ .venv/bin/python checkout.py 15 2
11:07:25 checkout: POST /v1/charges -> 200 {"charge_id": "ch_1791569245", "status": "ok"}
(idle for 15s)
11:07:40 checkout: POST /v1/charges -> 502 upstream failure: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))
Payments 的日志里只有一个请求。如果 checkout 前面有代理,它会记录一个真实的 502,带有 upstream connect error or disconnect/reset before headers,这就是你凌晨 3 点 grep 过却什么也没学到的那个字符串。
内核一直都知道的事
空闲间隙期间没有任何数据到达,所以内核无从得知。 然后 checkout 写入,RST 回来了,有那么一刻,checkout 主机上的内核掌握了整个故事: 这个重置是针对哪个 socket 的,哪个进程拥有它,以及它最后一次 承载数据是什么时候。它用第一个事实在 socket 上设置了一个错误,然后把 其余的都扔掉了。
Linux 有一个 tracepoint,tcp:tcp_receive_reset,它
在 socket 每次收到 RST 时触发,带有 socket 的地址对和
指向 socket 本身的指针。对于“空闲了多久”,我挂上 tcp_sendmsg 和 tcp_recvmsg,并按 socket 保留它最后一次
承载数据的两个时刻。保留两个的原因是,引发
重置的那次写入本身就是活动。我想要的间隙是那次写入与
它之前发生的任何事情之间的。
struct owner {
__u64 last_ns;
__u64 prev_ns;
__u32 pid;
char comm[16];
};
static __always_inline void touch(struct sock *sk)
{
__u64 key = (__u64)sk;
__u64 now = bpf_ktime_get_ns();
struct owner *o = bpf_map_lookup_elem(&owners, &key);
if (o) {
o->prev_ns = o->last_ns;
o->last_ns = now;
return;
}
struct owner fresh = {};
fresh.last_ns = now;
fresh.pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(fresh.comm, sizeof(fresh.comm));
bpf_map_update_elem(&owners, &key, &fresh, BPF_ANY);
}
SEC("fentry/tcp_sendmsg")
int BPF_PROG(on_sendmsg, struct sock *sk, struct msghdr *msg, size_t size)
{
touch(sk);
return 0;
}
而重置本身:
SEC("tracepoint/tcp/tcp_receive_reset")
int on_receive_reset(struct trace_event_raw_tcp_event_sk *ctx)
{
__u64 key = (__u64)ctx->skaddr;
struct owner *o = bpf_map_lookup_elem(&owners, &key);
struct rst_event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e)
return 0;
*e = (struct rst_event){};
e->family = ctx->family;
e->lport = ctx->sport;
e->rport = ctx->dport;
bpf_probe_read_kernel(e->laddr, 4, ctx->saddr);
bpf_probe_read_kernel(e->raddr, 4, ctx->daddr);
if (o) {
e->known = 1;
e->pid = o->pid;
__builtin_memcpy(e->comm, o->comm, sizeof(e->comm));
if (o->prev_ns)
e->idle_ns = o->last_ns - o->prev_ns;
}
bpf_ringbuf_submit(e, 0);
return 0;
}
这就是 C 的大部分内容。JavaScript 那边是一个环形缓冲区订阅和一个
console.log,在大多数 eBPF 工具中,这部分要花四十个文件
的 Go 代码。yeet 是一个 JavaScript 运行时,它位于
Linux 内核之上,每台机器一个守护进程,它加载对象、挂上
钩子并把事件交给脚本。把它放在第二个实验旁边运行:
$ yeet run . -- --port 8091
11:07:22 rstwatch: watching for TCP resets on port 8091 (ctrl-c to stop)
11:07:40 RST from 127.0.0.1:8091 -> python[192121] 127.0.0.1:38866 idle 15.0s before the write that got reset
11:07:40 RST from 127.0.0.1:38866 -> 127.0.0.1:8091 owner unknown (no data seen on this socket)
第一行讲述了整个故事。重置来自端口 8091,即
负载均衡器。它落在一个由 python[192121] 拥有的 socket 上,checkout。
在引发重置的那次写入之前,连接已经空闲了 15.0 秒,比 nginx 被告知要等待的 10 秒更长。代码、基础设施
和这个数字,来自事情发生的主机,两个服务中都没有安装任何东西。
第二行是回声。重置之后,checkout 拆除它的 socket, 而 nginx 在 8091 上的半关闭 socket 收到了那个重置回来。它没有所有者, 因为 nginx 已经关闭了它。如果你曾经想要证明“双方互相指责”是一个协议特性, 那就是了。
或者跳过 C,让你的 agent 来构建它。把这段粘贴到 Claude Code、
Codex 或你在 Linux 机器上运行的任何东西里,它就会写出追踪器,在真实的重置上验证它,
把它作为服务留下运行,你可以 curl,并把
重置计数放到一个 /metrics 路由上供 Prometheus 使用,这样 350 处的墙就会出现在
Grafana 中:
结尾
事情是这样的。有上千段像本文开头那样的段落。NLB 在 350 秒时忘记你。Node 静态链接它自己的 OpenSSL。当你关闭一个带有未读数据的 socket 时,Linux 发送 RST,而不是 FIN。HTTP/1.1 keep-alive 和一个降级到 HTTP/1.0 的代理。每个 协议、每个库和中间的每个盒子都有一些这样的东西, 它们都在某处被某人记录在案,写在一句你只有在已经知道答案之后才会认出的句子里。
你不可能全部知道。没人能。你能做的是有一种方法,在事情发生时看到主机上实际发生了什么, 而不是从四个各自看到不同片段的日志中重建它。重置就在那里, 在内核里,包含了所有相关信息。唯一缺少的是 提出这个问题的工具。
这就是 yeet 的用途,也是我们围绕 这个问题构建 API 调试 用例的原因:是代码还是基础设施。你的 agent 在失败的主机上读取内核并说出是哪个,带着 证据。下次凌晨 3 点 payments “不稳定”时,你没有疯。 他们从未发送 FIN。
来源:Hacker News · yeet.cx