간헐적인 ECONNRESET 디버깅: 읽지 않은 데이터가 TCP Reset을 유발할 때

간헐적으로 발생하는 네트워크 오류는 디버깅하기 가장 까다로운 버그 중 하나입니다. 가장 흔하면서도 오해하기 쉬운 오류 중 하나는 ECONNRESET ("Connection reset by peer")입니다. 이는 종종 크래시나 네트워크 중단을 의미하는 것으로 생각되지만, 실제 근본 원인은 훨씬 더 미묘한 경우가 많습니다. 바로 두 서비스가 TCP 연결을 닫는 방식을 처리하는 방식의 불일치 때문입니다.

이 글에서는 서버가 응답을 보내고 연결을 닫지만, 클라이언트가 정상적인 종료 대신 ECONNRESET을 받는 특정 시나리오를 살펴봅니다. 제어된 재현 도구와 Nginx 및 Gunicorn이 포함된 실제 운영 환경의 장애 사례를 분석함으로써 TCP Reset (RST) 패킷의 메커니즘을 밝혀낼 수 있습니다.

간헐적 Reset의 미스터리

동일한 머신에서 실행되는 두 개의 서비스를 가정해 봅시다. 서버는 localhost에서 리스닝 TCP 소켓을 열고, 클라이언트가 여기에 연결합니다. 그들은 정상적으로 데이터를 교환합니다. 하지만 가끔씩, 서버 로그에는 크래시나 오류가 전혀 나타나지 않음에도 불구하고 클라이언트가 소켓에서 데이터를 읽는 동안 ECONNRESET을 받게 됩니다.

이를 조사하기 위해 "실험용" 재현 도구를 구축했습니다. 이 서버는 연결 시 클라이언트에게 600,000바이트를 덤프한 다음 즉시 close()를 호출합니다.

정상적인 조건에서는 클라이언트가 데이터를 읽고 연결이 깔끔하게 닫힙니다. 하지만 클라이언트가 서버에 "스팸"을 보내도록(읽기를 시도하기 전에 서버로 일부 데이터를 전송) 수정하면 동작이 달라집니다. 클라이언트의 recv() 호출이 errno104 (ECONNRESET)로 설정된 -1을 반환하기 시작합니다.

와이어(Wire) 및 시스템 호출 분석

tcpdump를 사용하면 서버에서 클라이언트로 실제로 TCP RST 패킷이 전송되고 있음이 명확해집니다. 이는 서버의 strace가 다음과 같이 완벽하게 정상적인 실행 흐름을 보여주기 때문에 놀라운 일입니다:

  1. accept()로 연결 수락.
  2. sendto()로 600,000바이트 전송.
  3. close()로 소켓 닫기.
  4. exit_group(0) (정상 종료).

서버 관점에서는 모든 데이터가 성공적으로 전송되었습니다. 그러나 클라이언트의 strace를 보면 ECONNRESET이 발생하기 전에 데이터의 일부만 읽는 데 성공했음을 알 수 있습니다.

가설: "지저분한(Dirty)" 소켓

서버의 close() 호출 전에 sleep(1)을 도입하면 ECONNRESET이 사라집니다. 이는 소켓을 닫는 것과 관련된 타이밍 문제임을 시사합니다.

가설은 서버가 close()를 호출할 때 서버의 수신 버퍼에 여전히 대기 중인 데이터(클라이언트가 보낸 "스팸")가 남아 있다는 것입니다. TCP에서 프로세스가 수신 큐에 읽지 않은 데이터가 남아 있는 소켓을 닫으면, 스택은 정상적인 종료에 사용되는 표준 FIN 패킷 대신 상대방에게 RST 패킷을 보냅니다.

실제 적용 사례: Nginx와 Gunicorn

이 이론적인 동작은 운영 환경, 특히 Nginx를 리버스 프록시로 사용하고 Gunicorn이 Flask 애플리케이션을 서비스하는 스택에서 나타납니다.

이 시나리오에서 Nginx는 두 개의 writev() 호출(하나는 헤더용, 하나는 바디용)을 사용하여 Gunicorn에 HTTP 요청을 보냅니다. Gunicorn은 헤더를 읽지만, 애플리케이션이 "게으르게" 동작하여 요청 바디에 명시적으로 접근하지 않으면 Gunicorn은 해당 남은 데이터에 대해 recv()를 호출하지 않을 수 있습니다.

Gunicorn이 요청 처리를 마치면 응답을 보내고 close()를 호출합니다. 요청 바디가 소켓 버퍼에 읽히지 않은 채로 남아 있기 때문에 커널은 TCP RST를 트리거합니다. 이 RST를 받은 Nginx는 ECONNRESET을 로그에 남깁니다.

해결 방법

이를 해결하려면 애플리케이션이 연결을 닫기 전에 소켓에서 전체 요청 바디를 읽었는지 확인해야 합니다. Flask/Gunicorn 예시에서는 HTTP 바디에 대해 더미 작업을 수행함으로써 읽기를 강제하여 버퍼를 비우고 정상적인 FIN 종료가 가능하도록 할 수 있습니다.

기술적 심층 분석: 왜 RST인가?

이 동작은 버그가 아니라 TCP 사양의 정의된 부분입니다. RFC 1122 및 RFC 2525에 명시된 바와 같이, 수신된 데이터가 아직 대기 중인 상태에서 CLOSE 호출이 이루어지면 호스트는 RST를 보내야 합니다.

호스트는 "반이중(half-duplex)" TCP 종료 시퀀스를 구현할 수 있다... 만약 해당 호스트가 TCP에 수신된 데이터가 아직 대기 중인 상태에서 CLOSE 호출을 발행하거나, CLOSE 호출 후 새로운 데이터가 수신되면, 해당 TCP는 데이터가 손실되었음을 나타내기 위해 RST를 보내야 한다(SHOULD).

왜 그냥 FIN을 보내지 않는가?

커뮤니티에서 논의된 바와 같이, 데이터가 대기 중일 때 FIN(표준 4-way handshake)을 보내는 것은 오해를 불러일으킬 수 있습니다. 이는 실제로는 일부 데이터가 손실되었음에도 불구하고 모든 데이터가 성공적으로 전달되고 처리되었음을 암시하기 때문입니다. RST는 상대방에게 전송된 데이터가 애플리케이션에 의해 완전히 소비되지 않았음을 알리는 신호입니다.

개발자를 위한 핵심 요약

  • 모든 것을 읽으세요: 양쪽 모두 데이터를 보내는 프로토콜을 구현하는 경우, 수신 측이 소켓을 닫기 전에 사용 가능한 모든 데이터를 읽었는지 확인하십시오.
  • 정상 종료(Graceful Shutdowns): shutdown(SHUT_WR)를 사용하여 데이터 전송이 완료되었음을 알리면서도 EOF까지 소켓을 읽을 수 있도록 하는 방식을 고려하십시오.
  • "게으른" 프레임워크 주의: 고수준 프레임워크는 소켓 읽기를 추상화할 수 있습니다. 리버스 프록시에서 간헐적인 ECONNRESET 오류가 발생하면 애플리케이션이 요청 바디를 무시하고 있는지 확인하십시오.
  • 버퍼 제한: RST를 방지하기 위해 요청 바디 읽기를 강제할 때는, 거대한 요청 바디를 통한 서비스 거부(DoS) 공격을 방지하기 위해 client_max_body_size(Nginx 기준)를 유의하십시오.

Sources