一个 localhost 连接,也能打出 ECONNRESET: Connection reset by peer

更麻烦的是,服务端日志看起来很干净:没有崩溃,没有明显异常,sendto() 还返回了完整字节数。客户端却在读响应时被 RST 砍断。

这类 bug 最讨厌的地方不在“偶发”,而在它太像网络问题。可这次核心复现发生在本机 TCP。没有公网链路,没有云厂商,没有防火墙。锅甩不远,只能往栈里查。

现象:服务端发完了,不代表客户端收完了

作者做了一个最小复现。

服务端接受连接后,向客户端发送 600000 字节,然后 close。客户端如果只是读响应,通常正常;如果加 --spam,先向服务端发一点数据,再开始读,就会偶发 errno 104 Connection reset by peer

关键证据不多,但很硬:

观察点结果该怎么理解
server 发送 600000 字节sendto() 返回 600000只说明本地发送路径没立刻报错,不保证对端完整收到
client 加 --spam可能读到 256000、351232 或 600000 后报错RST 和读取进度存在竞争
tcpdump看到真实 TCP RST不是客户端库误判
strace serversendto()close(),进程正常退出应用层没有崩,但 TCP 层已经翻脸

目前比较合理的推断是:服务端 socket 里还有客户端发来的未读数据,应用直接 close。内核可能按半双工关闭语义发 RST,告诉对端:这里有数据被丢了,这不是一次优雅收尾。

RFC 1122 里有相近表述:CLOSE 时如果还有已收到但未读的数据,TCP SHOULD send a RST to indicate data was lost。

这还不能写成铁案。原作者也把它放在 part 1,后面还要验证 close 是否就是确定根因。但至少可以排除一类错觉:sendto() 成功,不等于“客户端已经安全拿到全部响应”。

这句话对排障很重要。很多日志只站在应用进程一侧说话,TCP 不归它管。

现实链路:nginx 转发 POST,gunicorn 偶尔只读到 header

真实场景落在 nginx 反代 gunicorn/Flask 的 POST 请求上。

一次请求里,nginx 分两次 writev 转发:392 字节 header,22 字节 body。正常时,gunicorn 一次读到 414 字节。偶尔,它只读到前 392 字节,也就是只拿到 header。

如果应用逻辑不碰请求体,框架或服务器栈可能也不会继续读 body。随后 gunicorn 返回 200 OK,发送一个较大的响应,再 close。

那 22 字节 body 还留在 socket 里。

这就和最小复现对上了:客户端先发了少量数据,服务端没读完,服务端又写响应并 close。RST 不是凭空出现的,它有路径。

临时 workaround 也很直接:让 Python 应用主动读取或触碰请求体。作者说这么做后,暂时没再观察到 ECONNRESET。

但这个办法不能裸用。读请求体会引入另一笔账:大 body、慢请求、内存占用、DoS 风险。生产里至少要配合 nginx 的 client_max_body_size 之类限制,不能为了消掉一个 RST,打开另一个入口。

对后端工程师,动作很具体:

  • 如果接口是 POST,却不使用 body,要确认框架是否仍会读取或丢弃请求体。
  • 如果准备提前返回大响应,不要默认 close 会优雅结束。
  • 如果用“读一下 body”规避,必须先设 body 大小上限。

对维护 nginx/gunicorn/Flask 栈的 SRE,排查顺序也该收窄:

  • 抓包确认有没有真实 RST,以及 RST 从哪一侧发出。
  • 用 strace 看服务端是不是 sendto() 后直接 close()
  • 对照 nginx 到 gunicorn 的转发,确认 header/body 是否被拆成多次写入。
  • 观察应用是否完全没有访问请求体。

这比泛泛调超时、调 buffer、怀疑云网络要有效。因为这次的变量很小:未读入站数据 + close + 大响应。

判断:抽象省掉的代码,会在边界条件里收费

我不太买账的是那种“只是偶发网络抖动”的解释。

这次更像应用层偷懒、框架 lazy 行为和 TCP 关闭语义撞在一起。每一层单看都能辩解:应用说我不需要 body;框架说那我就不读;服务端说响应已经写出;内核说 socket 里还有未读数据,不能当没事。

最后客户端只看到一个 ECONNRESET

现代 Web 栈的危险不在抽象本身。抽象是生产力。危险在于抽象让人忘了底层契约还在。

HTTP 看起来是请求和响应。TCP 看的是字节流、缓冲区、关闭语义。你在应用层说“这个 body 我不要”,不等于内核也同意这段数据不存在。

“天下熙熙,皆为利来。”工程里也一样。lazy read 省的是常规路径上的 I/O 和心智成本,付的是边界路径上的排障成本。平时没人感觉到,直到一个 RST 从本机连接里冒出来。

这里还不能把锅钉死给 Flask、gunicorn 或 nginx。原文只提到 gunicorn 已有相关报告,Python 侧具体责任边界还没完全确认。更稳妥的说法是:这条链路暴露了一个接口契约问题,而不是某个组件单点失职。

接下来真正该看的,不是“谁背锅”,而是三件事:

要观察什么为什么重要
服务端 close 前是否仍有未读请求体这是 RST 推断的核心变量
读取/丢弃请求体后问题是否稳定消失能验证 workaround 是否真命中机制
框架或服务器是否提供安全的 drain 策略直接决定修复是应用补丁,还是栈层行为调整

这件事给后端系统一个很小但很硬的提醒:不关心请求体,不等于可以不处理请求体。尤其在反代、WSGI server、框架三层之间,谁来读、读多少、何时 close,必须有人负责。

TCP 不会替应用圆场。它只会按自己的规矩结账。