调试偶发性的 ECONNRESET:当未读取的数据触发 TCP RST 时

调试偶发性的 ECONNRESET:当未读取的数据触发 TCP RST 时

间歇性网络错误是调试中最令人沮丧的 bug 之一。最常见但最常被误解的错误之一是 ECONNRESET(Connection reset by peer)。虽然它看起来通常像是崩溃或网络闪烁,但它往往是 TCP 栈在接收缓冲区中留有数据时处理套接字关闭时的确定性结果。

本文探讨了一个特定场景:尽管服务器日志中没有明显的崩溃,但服务在读取操作期间收到了 ECONNRESET。通过结合受控的实验室实验和涉及 Nginx 与 Gunicorn 的真实生产故障,我们可以揭示 TCP 重置 (RST) 数据包的底层机制。

实验室复现器

为了隔离该行为,我们构建了一个简单的服务器-客户端对。服务器在 localhost 上打开一个监听 TCP 套接字,接受连接,并在关闭套接字并退出之前立即向客户端转储 600,000 字节的数据。

在正常条件下,客户端读取数据并干净地关闭连接。然而,当客户端被修改为向服务器“轰炸”——在尝试读取响应之前向服务器发送少量数据——时,行为发生了变化。客户端会间歇性地遇到 errno 104 (ECONNRESET)。

分析追踪信息

使用 tcpdumpstrace 观察到了以下情况:

  1. 网络传输: tcpdump 确认确实有 TCP RST 数据包从服务器发送到客户端。
  2. 服务器: strace 显示服务器成功调用了 sendto() 发送完整的 600,000 字节,然后立即调用了 close()。服务器端没有崩溃或错误。
  3. 客户端: 客户端读取了一部分数据(例如 128,000 字节),然后随后在 recvfrom() 调用中突然收到 ECONNRESET

假设

通过在服务器的 close() 调用之前引入 sleep(1)ECONNRESET 消失了。这表明 close() 调用的时机是关键因素。

假设是:服务器在关闭套接字时,其自身的接收缓冲区中仍有待处理的数据(由客户端发送的“轰炸”数据)。在 TCP 中,关闭一个留有未读取数据的套接字通常会触发 RST 而不是优雅的 FIN 序列,以向对端发出信号,表明数据已丢失。

真实场景:Nginx 与 Gunicorn

这种理论行为在生产环境中也会显现,特别是在由 Nginx 作为 Flask 应用的反向代理、由 Gunicorn 提供服务的技术栈中。

在这种设置下,Nginx 使用两个独立的 writev() 调用向 Gunicorn 发送 HTTP 请求:一个用于请求头,一个用于请求/主体的第二部分。

09:11:31.254489 writev(29, [{iov_base="POST /reveal/...", iov_len=392}], 1) = 392
09:11:31.255435 writev(29, [{iov_base="compat=lynx...", iov_len=22}], 1) = 22

偶尔,Gunicorn(或底层的 Flask 应用)仅读取了数据的第一块(请求头),并因为应用程序逻辑没有明确请求剩余的主体部分而忽略了它。随后 Gunicorn 发送 HTTP 响应并关闭了套接字:

09:11:31.583979 sendto(6, "HTTP/1.1 200 OK...", 212, 0, NULL, 0) = 212
09:11:31.584225 sendto(6, "...", 614400, 0, NULL, 0) = 614400
09:11:31.590869 close(6) = 0

由于请求主体的剩余 22 字节仍停留在内核的接收缓冲区中,close() 调用触发了 TCP RST。Nginx 在收到此 RST 时(此时它可能仍在期望管理该连接),会记录一个 ECONNRESET

技术根本原因

这种行为并非特定软件的 bug,而是 TCP 规范的一个基本方面。正如 RFC 1122 和 RFC 2525 中指出的,如果调用 CLOSE 时 TCP 缓冲区中仍有待处理的接收数据,主机 SHOULD 发送一个 RST。

"如果此类主机在 TCP 中仍有待处理接收数据时发出 CLOSE 调用,或者在调用 CLOSE 之后收到新数据,其 TCP SHOULD 发送一个 RST 以表明数据已丢失。"

这种机制确保了对端能够意识到它发送的数据未被应用程序完全消耗,从而防止对端误以为数据已成功处理。

缓解措施与规避方法

为了解决 Gunicorn/Flask 场景下的这个问题,应用程序被修改为对 HTTP 主体执行“虚拟操作”。这确保了在服务器关闭连接之前,代理服务器发送的所有数据都已从套接字中读取完毕。

注意: 虽然这解决了 ECONNRESET,但它引入了潜在的拒绝服务 (DoS) 向量。如果客户端发送一个巨大的请求主体,服务器可能会因为尝试读取它而耗尽内存。为了缓解这一点,必须配置 Nginx 中的 client_max_body_size 等限制,以确保服务器只尝试读取可控数量的数据。

总结

当你看到间歇性的 ECONNRESET 时,不要立即假设是网络故障或进程崩溃。检查你的服务器是否在客户端仍在发送数据时关闭了连接,或者你的服务器在关闭套接字之前是否对读取整个请求主体表现得“懒惰”。确保在调用 close() 之前完整地读取所有待处理的数据,是避免这些重置的最可靠方法。

Sources