たまに発生する ECONNRESET のデバッグ:未読データが TCP RST を引き起こすとき

たまに発生する ECONNRESET のデバッグ:未読データが TCP RST を引き起こすとき

断続的に発生するネットワークエラーは、デバッグにおいて最も厄介なバグの一つです。最も一般的でありながら誤解されやすいエラーの一つが ECONNRESET (Connection reset by peer) です。これは、クラッシュやネットワークの一時的な瞬断のように見えることが多いですが、実際には、受信バッファにデータが残っている状態でソケットをクローズした際の TCP スタックの動作による決定論的な結果であることが頻繁にあります。

この記事では、サーバーログに明らかなクラッシュがないにもかかわらず、読み取り操作中にサービスが ECONNRESET を受信する特定のシナリオを検証します。制御されたラボ実験と、Nginx および Gunicorn を含む実世界のプロダクション環境での障害を組み合わせることで、TCP Reset (RST) パケットの根本的なメカニズムを明らかにします。

ラボでの再現手順

挙動を分離して特定するために、シンプルなサーバー・クライアントのペアを構築しました。サーバーは localhost 上でリスニング用の TCP ソケットを開き、接続を受け入れると、ソケットをクローズして終了する前に、クライアントに対して即座に 600,000 バイトのデータをダンプします。

通常の状態では、クライアントはデータを読み取り、接続をクリーンにクローズします。しかし、クライアントを「スパム」するように変更した場合(レスポンスを読み取ろうとする 前に サーバーに少量のデータを送信する場合)、挙動が変わります。クライアントは断続的に errno 104 (ECONNRESET) に遭遇します。

トレースの分析

tcpdumpstrace を使用して、以下のことが観察されました:

  1. ネットワーク上: tcpdump により、サーバーからクライアントへ TCP RST パケットが実際に送信されていることが確認されました。
  2. サーバー側: strace は、サーバーが 600,000 バイト全量の sendto() を正常に呼び出し、その直後に close() を呼び出していることを示しています。サーバー側にクラッシュやエラーはありません。
  3. クライアント側: クライアントはデータの一部(例:128,000 バイト)を読み取った後、その後の recvfrom() 呼び出しで突然 ECONNRESET を受信します。

仮説

サーバーの close() 呼び出しの前に sleep(1) を導入すると、ECONNRESET は消失します。これは、close() 呼び出しのタイミングが決定的な要因であることを示唆しています。

仮説は、サーバーが自身の受信バッファに未処理のデータ(クライアントから送信された「スパム」データ)が残っている状態でソケットをクローズしているというものです。TCP において、未読データがある状態でソケットをクローズすると、通常、正常な FIN シーケンスではなく、データが失われたことを相手に知らせるために RST をトリガーします。

実世界のシナリオ:Nginx と Gunicorn

この理論的な挙動は、プロダクション環境、具体的には Nginx が Flask アプリケーションのプロキシとして動作し、Gunicorn がそのアプリケーションをサーブしているスタックにおいて顕在化します。

この構成では、Nginx は 2 つの個別の 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 をログに記録します。

技術的な根本原因

この挙動は特定のソフトウェアのバグではなく、TCP 仕様の根本的な側面です。RFC 1122 および RFC 2525 に記載されているように、受信データが TCP バッファに保留されている状態で CLOSE が呼び出された場合、ホストは RST を送信すべき(SHOULD)です。

"受信データが TCP に保留されている状態でホストが CLOSE コールを出す、あるいは CLOSE コールが出された後に新しいデータを受信した場合、その TCP はデータが失われたことを示すために RST を送信すべき(SHOULD)である。"

このメカニズムニズムは、相手側が、自身が送信したデータがアプリケーションによって完全に消費されていなかったことを認識できるようにするためのものであり、相手側がデータを正常に処理したと誤認することを防ぎます。

緩和策と回避策

Gunicorn/Flask のシナリオにおいてこれを解決するために、、アプリケーションを HTTP ボディに対して「ダミー操作」を行うように変更しました。これにより、プロキシが送信したすべてのデータが、サーバーが接続をクローズする前にソケットから読み取られることが保証されます。

注意: ECONNRESET は解決されますが、これは潜在的な Denial of Service (DoS) ベクターを導入することになります。クライアントが巨大なリクエストボディを送信した場合、サーバーはそれを読み取ろうとしてメモリを使い果たす可能性があります。これを緩和するために、Nginx の client_max_body_size のような制限を設定することが不可欠です。

まとめ

断続的に ECONNRESET が発生する場合、すぐにネットワークの故障やプロセスのクラッシュを想定しないでください。サーバーがクライアントからデータを送信中の状態で接続をクローズしているか、あるいはサーバーがソケットをクローズする前にリクエストボディ全体を「怠慢」に読み取っていないかを確認してください。close() を呼び出す前に保留中のすべてのデータをクリーンに読み取ることが、これらのリセットを回避する最も信頼性の高い方法です。

Sources