
2026/05/18 2:09
稀に見られる ECONNRESET エラー。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
提供された要約は正確かつ包括的であり、曖昧さや不必要な不鮮明さなく主なメッセージを明確に伝えています。そのまま使用できます。
本文
ブログ - Git - デスクトップ - お問い合わせ
2026 年 5 月 5 日
同一のマシン上で動作している二つのサービスについて記述します。片方のサービスは localhost にバインドされた TCP ソケットをリッスンし、もう片方はそのソケットに接続してデータをやり取りしています。ある時ある時、接続を发起したサービスがソケットからデータを読み込む際に「ECONNRESET」エラーを受信することがあります(例外的なエラーもログには表示されず、クラッシュや何事もないままです)。ここにあるのはどのような状況でしょうか?
- 「lab」内での再現ケース
- tcpdump で観測される様子
によるサーバー側の挙動strace ./server
によるクライアント側(スパム対応)の挙動strace ./client --spam- 最初の仮説
- 実際の現場でのシナリオ
- 次のステップ
第 2 部へと続きます。
「lab」内の再現ケース
まずは「サーバー」、つまりリッスンするソケットを開くサービスのコードから始めましょう。
以下のプログラムはまさにその役割を果たします:新しい TCP ソケットを作成し、接続を待機し、各リクエストに対し新しいプロセスでフォークします。ただし、ここでの「リクエスト」の内容は特にありません:サーバーは単に接続成立時にクライアントへ 600,000 バイトのデータをダンプしきることのみを行います。
600,000 という数字にはある意味があります:それは私が示したい振る舞いを誘発するのに十分大きくなければなりません。例えば、600 バイト程度ではおそらく機能しません。
server.c
次にクライアント側です:これはサーバーが待機している localhost のポート 8125 に接続し、その後
recv() を EOF またはエラーが発生するまで呼び出し続けます。--spam フラグについては後で触れます。
client.c
また、ビルドファイルも以下です:
Makefile
これら二つのプログラムを実行してみましょう:
[terminal1]$ ./server [terminal2]$ ./client Read 600000 bytes, final return value was 0, errno was 0
特に目新しい現象はありません。
ただし、
--spam フラグを使用してみます:
$ ./client --spam Read 600000 bytes, final return value was -1, errno was 104 (Connection reset by peer) $ ./client --spam Read 256000 bytes, final return value was -1, errno was 104 (Connection reset by peer) $ ./client --spam Read 351232 bytes, final return value was -1, errno was 104 (Connection reset by peer) $ ./client --spam Read 351232 bytes, final return value was -1, errno was 104 (Connection reset by peer) $ ./client --spam Read 351232 bytes, final return value was -1, errno was 104 (Connection reset by peer) $ ./client --spam Read 256000 bytes, final return value was -1, errno was 104 (Connection reset by peer) $ ./client --spam Read 600000 bytes, final return value was -1, errno was 104 (Connection reset by peer)
--spam フラグは、クライアントがデータを受信する前にまずサーバーへ一部のデータを送出하도록します。そして明らかにこれにより接続の断絶が発生しているようです:クライアント側の recv() が -1 を返し、かつ errno が 104 = Connection reset by peer に設定されます。
tcpdump で観測される様子
まず、「ワイヤー上」には何が見えるでしょうか?
さて、実際には TCP RST パケットが確かに存在します。これはプログラミング上の誤りか、あるいは私の解釈のミスを示している可能性もあります。
strace ./server
によるサーバー側の挙動
strace ./serverこの RST はサーバー側から発せられているので、
strace を付けてその様子を調べましょう:
19:59:03.420432 accept(3, NULL, NULL) = 4 19:59:05.652715 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fe43484fa10) = 239546 [pid 239546] 19:59:05.652831 ... [pid 239546] 19:59:05.652959 mmap(NULL, 602112, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0 <unfinished ...>) = 0x7fe43456d000 [pid 239546] 19:59:05.652980 mmap resumed = 0x7fe43456d000 [pid 239546] 19:59:05.653235 sendto(4, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"..., 600000, 0, NULL, 0) = 600000 [pid 239546] 19:59:05.653474 close(4) = 0 [pid 239546] 19:59:05.653553 exit_group(0)
クラッシュは発生していません。フォークし、
sendto() を用いてすべてのデータをクライアントへダンプした後、終了しています。
また、
sendto() は完全な 600,000 バイトを返しており、このプログラムの観点からは「すべてのデータが送信された」とみなされます(明らかな注釈があり、man ページでも説明されています:「sendto() の呼び出しが正常に完了したからといって、メッセージの配信が保証されるわけではない。-1 が返されたことは、ローカルで検出されたエラーであることを示すだけです」)。
実際には、クライアント側で
--spam を使用するか否かを問わず、ここでの変化はありません。
strace ./client --spam
によるクライアント側(スパム対応)の挙動
strace ./client --spam19:59:05.652518 connect(3, {sa_family=AF_INET, sin_port=htons(8125), sin_addr=inet_addr("127.0.0.1")}, 16) = 0 19:59:05.652649 sendto(3, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 100, 0, NULL, 0) = 100 19:59:05.652805 recvfrom(3, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"..., 4096, 0, NULL, NULL) = 4096 19:59:05.652805 recvfrom(3, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"..., 4096, 0, NULL, NULL) = 4096 ... 19:59:05.654440 recvfrom(3, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"..., 4096, 0, NULL, NULL) = 4096 19:59:05.654473 recvfrom(3, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"..., 4096, 0, NULL, NULL) = 1024 19:59:05.654506 recvfrom(3, 0x55d23c5b5010, 4096, 0, NULL, NULL) = -1 ECONNRESET (Connection reset by peer) 19:59:05.654575 write(1, "Read 128000 bytes, final return "..., 60) = 60 19:59:05.654694 write(4, "Connection reset by peer\n", 25) = 25 19:59:05.654725 close(4) = 0 19:59:05.654750 close(3) = 0 19:59:05.654783 exit_group(0) = ?
ここにも特に特異な現象は見られません。
recvfrom() を呼び出し続け、いずれかの呼び出しで -1 / ECONNRESET が返されるまで続行します。
最初の仮説
ECONNRESET が発生するタイミングを検証してみましょう。なぜなら、前述の上部に戻ることで確認できる通り、一部の呼び出しでは完全な 600,000 バイトを読み込み、他の呼び出しでは異なる値が返されているからです。したがって、ここにはおそらく某种のタイミングの問題があるのでしょう。
indsight で最も明らかなことを実行しましょう:サーバー側の
close() を遅延させます。なぜなら、もし本当に RST を生成する要因があるとすれば、それはたぶん close() 自体だからです。
serve_client() 内で、close() の呼び出しの前に単に sleep(1) を追加してみましょう:
sleep(1); if (close(s) == -1) { perror("close"); return 1; }
すると、明確に 1 秒の遅延が観測できるようになります。tcpdump がこれを最も良く示します:
これ以上の深掘りを行わずに、現状で以下のような推測となります:
- サーバー側はそのソケット上の入ってくるデータを「見える」が、それを読み込みません。
- サーバー側で
を呼び出した際に、ソケットは「汚染された(dirty)」状態になっています(待機データがあるため),したがって RST が発せられ、クライアントに対し「すべてのデータが読み込まれていない」と伝える(希望する)ことになります。close()
この説明は今すぐには理にかなっていますが、まだそれを確認する決定的なソースを見つけるに至りません。(当初の仮説の一つでは、バッファが満杯になるため
spamlen が 100 に設定され、本来は 100,000 だったと推測されていましたが、それは重要ではなく、単一のバイトでも十分です。)
実際の現場でのシナリオ
gunicorn が Flask アプリをサーブし、それを nginx がリバースプロキシとして取りまとめる環境で、稀に nginx から gunicorn 側へ ECONNRESET が飛んできました。クラウドホスティング業者やファイアウォール、多数のルーター、並行リクエストなどといった複雑な要素もあり、IOCTL や非ブロッキングソケットによって脱線してしまいました。したがって、この現象を単純化させるのにしばらく時間を要しました。
本質的に、nginx が行うことは HTTP リクエストを gunicorn に転送することですが、それを
syscalls 二回に分けて行います:
09:11:31.254489 writev(29, [{iov_base="POST /reveal/d48z/iha4A9MOMuLW40"..., iov_len=392}], 1) = 392 09:11:31.255435 writev(29, [{iov_base="compat=lynx+needs+this", iov_len=22}], 1) = 22
HTTP ヘッダーが 392 バイト、HTTP ボディが 22 バイト、合計 414 バイトです。
gunicorn はその後、ソケットからこのデータを以下のように読み込みます:
09:11:31.593968 recvfrom(6, "POST /reveal/gEJh/bIoAUdWrSV47mI"..., 8192, 0, NULL, NULL) = 414
ただし、ある時には最初の
writev() の呼び出しのデータのみが受け取られることがあります:
09:11:31.251229 recvfrom(6, "POST /reveal/d48z/iha4A9MOMuLW40"..., 8192, 0, NULL, NULL) = 392
gunicorn(およびその内部で実行されるアプリケーション)は、ヘッダーだけを受け取っても問題なく動作し、ボディについては気にしません。私はこのソフトウェアスタックの一部分が「怠慢」と考えます:アプリケーション側でボディをアクセスするものがなければ、
recv() を呼ぶさえも省略します。
問題は、gunicorn がそのような方法でトランザクションを終了させることです:
09:11:31.583979 sendto(6, "HTTP/1.1 200 OK\r\nServer: gunicor"..., 212, 0, NULL, 0) = 212 09:11:31.584225 sendto(6, "\312\205]"..., 614400, 0, NULL, 0) = 614400 09:11:31.590869 close(6) = 0
データを送信してソケットをクローズします。まだ読み取るべき待機データがあれば、これは RST を引き起こすと考えられます。
回避策として、Python アプリ側で HTTP ボディに対してダミー操作を行うことで、ソケットから完全に読み込まれたことを保証しました。これにより、現在まで追加の ECONNRESET は見せていません。(アプリケーションによりますが、これは DoS のリスクを開く可能性があります:例えばサーバーに 10GB のデータを POST されても、メモリが 1GB しかなかった場合などは困ります。nginx の
client_max_body_size でこれを防ぐことができるでしょう。)
次のステップ
が TCP RST の真の原因であることを検証し、それを裏付ける信頼できる情報源を見つける。close()- RFC 1122 に関連している可能性もある:
- ホストは「半 duplex」の TCP クローズシーケンスを実装してもよい(CLOSE を呼び出したアプリケーションが接続から引き続きデータを読み込むことはできない)。もしホストが受け取り待機中のデータがある状態で CLOSE を発行した場合、あるいは CLOSE が呼ばれた後に新しいデータが届いた場合、TCP はそのデータが失われたことを示すために RST を送信すべきである。
- Python 側で「責任者」を特定する:gunicorn か flask か、実際の Flask アプリか?アップストリームへレポートする。
- gunicorn の可能性があります(すでに報告済み)。