除偶發性的 ECONNRESET:當未讀取的數據觸發 TCP RST 時
除偶發性的 ECONNRESET:當未讀取的數據觸發 TCP RST 時
間歇性的網路錯誤是除錯中最令人沮喪的錯誤之一。其中最常見但也最容易被誤解的錯誤之一是 ECONNRESET (Connection reset by peer)。雖然它看起來通常像是當機或網路閃斷,但它往往是 TCP 堆疊在處理當數據留在接收緩衝區時關閉 socket 的確定性結果。
本文探討了一個特定的情境:服務在進行讀取操作時收到 ECONNRESET,儘管伺服器日誌中沒有明顯的當機。透過結合受控的實驗室重現實驗與涉及 Nginx 和 Gunicorn 的真實生產環境故障,我們可以揭示 TCP Reset (RST) 封包的底層機制。
實驗室重現器
為了隔離此行為,我們構建了一對簡單的伺服器-客戶端。伺服器在 localhost 上開啟一個監聽的 TCP socket,接受連線,並在關閉 socket 並退出之前,立即向客戶端傾倒 600,000 位元組的數據。
在正常情況下,客戶端讀取數據並乾淨地關閉連線。然而,當客戶端被修改為向伺服器「發送垃圾數據」(spamming the server)——即在嘗試讀取回應 之前 向伺服器發送少量數據——行為就會改變。客戶端會間歇性地遇到 errno 104 (ECONNRESET)。
分析追蹤
使用 tcpdump 和 strace,觀察到以下現象:
- 網路線路:
tcpdump確認伺服器確實向客戶端發送了一個 TCP RST 封包。 - 伺服器:
strace顯示伺服器成功地為完整的 600,000 位元組調用了sendto(),然後立即調用了close()。伺服器端沒有任何當機或錯誤。 - 客戶端: 客戶端讀取了一部分數據(例如 128,000 位元組),然後在隨後的
recvfrom()調用中突然收到ECONNRESET。
假設
透過在伺服器的 close() 調用之前引入 sleep(1),ECONNRESET 就消失了。這表明 close() 調用的時機是關鍵因素。
假設是:伺服器在關閉 socket 時,其自身的接收緩衝區中仍有待處理的數據(由客戶端發送的「垃圾數據」)。在 TCP 中,關閉一個仍有未讀數據的 socket 通常會觸發 RST 而非優雅的 FIN 序列,以向對端發送信號,表示數據已丟失。
真實世界情境:Nginx 與 Gunicorn
這種理論行為會在生產環境中顯現,特別是在由 Nginx 作為 Flask 應用程式的逆向代理,並由 Gunicorn 提供服務的技術棧中。
在此設置中,Nginx 使用兩個獨立的 writev() 調用向 Gunicorn 發送 HTTP 請求:一個用於標頭 (headers),另一個用於請求/主體的第二部分。
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 app)僅讀取數據的第一個區塊(標頭),並忽略主體的其餘部分,因為應用程式邏輯沒有明確要求讀取它。Gunicorn 隨後發送 HTTP 回應並關閉 socket:
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。
技術根本原因
這種行為並非特定軟體的錯誤,而是 TCP 規範的根本面向。如 RFC 1122 和 RFC 2525 所述,如果呼叫 CLOSE 時 TCP 緩衝區中仍有待處理的接收數據,主機 SHOULD 發送一個 RST。
"如果此類主機在 TCP 中仍有待處理接收數據時發出 CLOSE 調用,或者在 CLOSE 調用後收到新數據,其 TCP SHOULD 發送一個 RST 以顯示數據已丟失。"
這種機制確保了對端能夠意識到它所發送的數據未被應用程式完全消耗,從而防止對端假設數據已成功處理。
緩解措施與替代方案
為了在 Gunicorn/Flask 情境中解決此問題,我們修改了應用程式以對 HTTP 主體執行「虛擬操作」(dummy operation)。這確保了在伺服器關閉連線之前,代理伺服器發送的所有數據都已從 socket 讀取完畢。
注意: 雖然這解決了 ECONNRESET,但它引入了潛在的阻斷服務 (DoS) 風險。如果客戶端發送一個巨大的請求主體,伺服器可能會因嘗試讀取它而耗盡記憶體。為了緩解此點,配置如 Nginx 中的 client_max_body_size 等限制是至關重要的,以確保伺服器僅嘗試讀取可控數量的數據。
總結
當你看到間歇性的 ECONNRESET 時,不要立即假設是網路故障或程序當機。檢查你的伺服器是否在客戶端仍在發送數據時關閉了連線,或者你的是否伺服器在關閉 socket 之前對請求主體的讀取是否過於「懶惰」。確保在呼叫 close() 之前完整地讀取所有待處理的數據,是避免這些重置的最可靠方法。