일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 |
- nodejs
- OS
- nestjs
- 스프링기초
- 자바
- 스프링 이미지
- 동시성 제어
- 예외 핸들링
- 스프링jpa
- 스프링
- 분산시스템
- 토스팀
- 동시성 문제
- 예외 커스텀
- JavaScript
- 예외필터
- nginx
- 동시성문제
- connection reset by peer
- 유난한 도전
- 트러블슈팅
- Mysql이미지
- 스프링Entity
- 3WayHandshake
- Jenkins
- 스프링오류
- docker
- 대규모 트래픽
- nestjs 예외
- 토스책
- Today
- Total
삽질블로그
Connection reset by peer에 대한 고찰 본문
개인 프로젝트를 진행하면서 발생한 에러에 대해 공부하던 중 발생한 에러에 대해 공부한 내용입니다.
대규모 트래픽을 처리하는 개인 프로젝트를 진행하는 중 클라이언트에서 Connection reset by peer 에러가 계속해서 발생했습니다.
그래서 원인을 찾던 중 서버에서 TCP연결을 재시도 하라는 RST패킷을 보낼 때
TCP 연결이 즉시 끊기면서 발생하는 에러라는 것을 알았습니다.
TCP통신은 데이터의 정확한 전송을 보장하기 위해서 상대방의 컴퓨터와 신뢰성을 수립하는 과정인
3-way handshake과정을 가지게 됩니다.
@Post('issue')
async issueCoupon(@Body() body : IssueCouponDto) : Promise<CouponWallet>{
return await this.couponService.issueCouponRedisLock(body);
}
그래서 위와 같은 Nest.js 코드로 http통신을 할 때 네트워크 단에서 TCP 신뢰성을 보장하기 위해
3-way handshake과정을 거치면서 OS의 시스템 호출을 사용하게 됩니다.
http 통신을 할 때 네트워크에서 진행하는 3-way handshake 과정을 와이어 샤크로 캡쳐한
다음 사진을 보면 다음과 같이 마지막으로 클라이언트에서 ACK패킷을 보낸 후 http 통신이
이루어졌지만 서버에서 정상적으로 통신을 종료하지 못한다면
FIN 패킷이 아닌 RST패킷(빨간색 부분)을 전송하는 것을 확인할 수 있습니다.
다음 이미지를 통해 3-way handshake가 어떤 동작을 통해 이루어지는지 확인해봅시다.
단계는 다음과 같습니다.
1. Client에서 Server와의 연결을 시작하기 위해 SYN 패킷을 보낸 후 SYN-SENT 상태가 된다.
2. Server는 SYN 요청을 받은 후 SYN-RECVED 상태로 변경된다.
3. 이 때 Server는 연결 정보를 SYN큐에 저장하고 Client에 SYN + ACK 패킷을 전송한다.
4. Client는 SYN + ACK 패킷을 받고 ACK를 Server에 응답하면서 ESTABLESHED 상태로 변경된다.
5. Server는 ACK를 받으면 연결 정보를 SYN 큐에서 제거하고 Completed Connection Queue에 추가한다.
그 후 서버는 ESTABLESHED 상태로 변경된다.
6. Accept() 함수는 Completed Connection Queue에서 완료된 연결 요청을 처리한다.
각각 주요 개념을 정리하면 다음과 같습니다.
1. Listen()은 SYN큐와 Completed Connection Queue 총 두 가지 큐를 생성한다.
2. SYN큐는 Client가 SYN 패킷을 전송할 때 저장되며 그 연결 정보를 저장하는 큐이다.
3. Completed Connection Queue는 Client가 ACK 패킷을 전송할 때 생성되는 큐이다.
4. Accept()는 Completed Connection Queue에 저장된 큐를 사용한다.
이와 같이 3-way handshake 과정에서 accept와 listen 두 개의 OS 시스템 콜을 사용하게 됩니다.
그럼 각각의 함수가 어떤 역할을 하는지 알아보기 위해 터미널에 검색해봅시다.
man accept
int accept(int socket, struct sockaddr *restrict address, socklen_t *restrict address_len);
The accept() system call is used with connection-based socket types (SOCK_STREAM, SOCK_SEQPACKET).
It extracts the first con‐nection request on the queue of pending connections for the listening socket, sockfd, creates a new connected socket,
and re‐turns a new file descriptor referring to that socket.
The newly created socket is not in the listening state.
The original socket sockfd is unaffected by this call.
man에 기반하여 요약해보면
1. 리스닝 소켓에서 대기 중인 첫 번째 요청을 꺼내와서 그 연결을 처리하기 위한 새로운 소켓을 생성합니다.
2. 생성된 소켓은 클라이언트와의 통신에 사용됩니다.
3. accept는 그 소켓을 가리키는 파일 디스크립터를 반환합니다.
4. 여기서 리스닝 소켓은 accept 호출에 영향을 받지 않습니다.
man listen
int listen(int socket, int backlog);
Creation of socket-based connections requires several operations.
First, a socket is created with socket(2).
Next, a willingness to accept incoming connections and a queue limit for incoming connections are specified with listen().
Finally, the connections are accepted with accept(2) The listen() call applies only to sockets of type SOCK_STREAM.
man에 기반하여 요약해보면
1. listen은 socket()으로 만들어진 소켓이 필요하며 앞으로 들어올 큐의 제한(개수?)를 listen()을 통해 지정합니다.
2. 마지막으로 연결은 accept()를 통해 수락됩니다.
3. listen()은 오직 SOCK_STREAM 소켓 타입만 적용이 가능합니다.
자 그럼 read tcp 127.0.0.1:52283->127.0.0.1:8000: read: connection reset by peer
이와 같은 서버에서 연결을 일방적으로 끊는 것은 어느 단계에서 발생할까
저는 그 힌트를 와이어 샤크로 패킷을 캡쳐해서 찾았습니다.
이 통신과정을 살펴보면 3-way handshake과정을 거치고 Client에서 POST 요청을 서버로 전송했지만
서버가 그 요청을 처리하지 못하고 RST패킷을 전송하는 것을 확인할 수 있습니다.
근데 이는 accept() 시스템 호출까진 정상적으로 처리되었다고 판단할 수 있습니다.
왜냐하면 accept를 통해 Client와 Server간의 연결 요청을 수락했기 때문에
Client에서 POST요청을 시도했기 때문입니다.
그래서 제 상황을 정리해보자면 accept() 시스템 호출까진 정상적으로 진행됐지만
이를 서버에서 응답하기 위해 읽는 작업인 read()또는 recv() 혹은
쓰기 작업인 write() 또는 send() 시스템 호출을 사용하여 응답값을 보내는 과정에
비정상적인 종료로 인해서 RST 패킷이 보내진 상황으로 판단하였습니다.
제가 사용한 Nest.js도 가장 로우한 부분에선 모두 위와 같은 OS의 시스템 호출을 기반으로 동작하는데요.
이는 Nest.js가 Express.js기반이고 Express.js는 Node.js기반이기 때문입니다.
정확히 말하자면 Express.js는 Node.js의 라이브러리인 libuv를 통해 도움을 받는데,
libuv란 운영체제의 커널을 추상화한 라이브러리입니다.
즉, Node.js의 기반으로 동작하는 프레임 워크, 라이브러리는 운영체제의 커널을 추상화한 libuv의 함수,
예로 uv_write()를 사용하는 것을 알 수 있습니다.
man send를 터미널에 검색해봅시다.
send(int socket, const void *buffer, size_t length, int flags);
send를 보면 buffer를 인자로 받는 것을 확인할 수 있습니다.
그래서 이 버퍼의 최대 크기를 넘어서 오류가 발생했는지 확인하기 위해 다음 명령어로 확인해봤습니다.
netstat -anv | grep 8000
결과의 일부인데 보면 현재 TIME_WAIT 상태에 있는 소켓의 최대 버퍼 크기가
407994이고 현재 버퍼의 크기가 146988임을 볼 수 있습니다.
따라서 최대 버퍼의 크기를 넘어서 발생하는 오류는 아닌 것을 확인할 수 있습니다.
또한 TIME_WAIT 상태는 연결이 정상적으로 종료되고 동일한 소켓을
재사용하지 않기 위해 일정 시간동안 유지하는 상태입니다.
즉, TIME_WAIT은 4-way handshake를 끝마친 상태라고 볼 수 있습니다.
그래서 이 소켓들과 와이어샤크에서 캡쳐한 패킷들을 비교해봤습니다.
패킷들을 캡쳐한 일부이미지와 TIME_WAIT상태에 있는 패킷 이미지입니다.
먼저 첫 번째 이미지를 보면 클라이언트 60431, 60432에서 RST패킷을 던지는 것을 확인할 수 있습니다.
그럼 해당 클라이언트의 소켓이 정상적인지 확인하려면 두 번째 이미지에서 해당 클라이언트의 소켓을 확인하면 되겠죠?
하지만 해당 클라이언트의 소켓이 없는 것을 볼 수 있습니다.
그럼 첫 번째 이미지에서 정상적으로 통신이 된 패킷 중 하나인 클라이언트 60439는 소켓이 있을까요?
두 번째 이미지를 보면 60439가 TIME_WAIT상태에 있는 것을 확인할 수 있습니다.
따라서 3-way handshake과정까지 끝낸 후 클라이언트에서 post요청을 보낸 것을 이전에 확인했기 때문에
accept()를 통해 연결 요청이 수락되었지만 서버에서 요청을 받은 후 바로 RST패킷을 보낸 것으로 보아
알 수 없는 이유로 소켓이 바로 닫혀진 것으로 가정하였습니다.
이런 가정으로 다음과 같이 여러 가지 가정을 세워 원인을 찾으려고 하였지만 결론적으로는 찾지 못하였습니다.
1. 서버의 자원부족인가?
-> 그라파나, 프로메테우스를 이용하여 서버 리소스 모니터링을 해봤지만 충분한 여유가 있었습니다. - 원인 x
2. DB의 과도한 쿼리로 인해 타임아웃 발생하여 문제가 되는가?
-> DB를 사용하지 않은 코드도 똑같이 RST패킷을 보내는 상황이 발생 - 원인 x
3. TCP 수신 버퍼가 가득차서 발생한 오버플로우로 인해 데이터를 수신하지 못해 발생한 문제인가?
-> 서버에서 사용하고 있는 8000번 포트의 TCP 연결 상태를 모니터링하여 수신 버퍼 상태를 확인했지만 문제가 없음을 확인했고, 혹시나 하는 마음에 수신 버퍼의 크기를 최대로 늘려 다시 부하테스트를 진행했지만 여전히 똑같은 상황 발생 - 원인 x
4. 리버스 프록시 혹은 로드 밸런서 설정 이슈
-> 따로 사용한게 없어 배제하였습니다.
5. 포트 재사용으로 인해 발생했는가?
-> TCP 연결 상태를 모니터링하여 RST패킷을 보낸 포트와 비교를 하였지만 포트 재사용이 없음을 확인했습니다. - 원인 x
6. 과도한 커넥션 처리
-> 이건 accept()이전에 발생하는 문제 같아서 배제했는데 설마 하는 마음에 테스트를 진행해봤습니다.
OS 시스템 호출인 listen()으로 만들어진 두 개의 큐 1. SYN, 2. Completed Connection Queue 중에서
1번 큐에 2번 큐의 크기를 초과하는 요청이 몰릴 경우
1번 큐에서 2번 큐로 이동하는 과정에서 오버플로가 발생하면
그 오버 플로된 요청에 한해서 Connection reset by peer가 발생한다고 합니다.
그래서 혹시나 하는 마음에 2번 큐의 크기를 조정하는 서버의 커널 파라미터인 somaxconn를
기본 값인 256에서 1000으로 올려봤지만 똑같은 에러가 발생했습니다.
혹시 256에서 5로 줄이면 100개의 요청에서도 오류가 발생할까? 하는 마음에 줄이고 실행해봤지만 오류가 발생하지 않았습니다.
이 조정을 통해 accept() 함수 이후에 서버에서 RST패킷을 보내는 것을 확실히 하게 되었습니다. - 원인 x
위와 같이 다양한 가정을 통해 원인을 찾으려고 했지만 아직 제 능력으론 정확한 원인을 찾을 수 없었습니다.
하지만 Connection reset by peer의 원인으로 서버에서 보내는 RST패킷은 클라이언트에서 요청을 재시도 하라고 보내는 패킷이라
자동적으로 재요청을 진행한다고 합니다.
대규모 트래픽 등에서 자주 발생하는 네트워크 단 에러라 신경쓰지 않아도 된다고 하는데,
다만 매번 요청에서 이와 같은 에러가 발생한다면 클라이언트 개발자와 얘기하여 해결해야 한다고 합니다.
-> 아니면 댓글로 알려주시면 감사하겠습니다..!
아쉽게도 이번 프로젝트를 진행하면서 발생한 Connection reset by peer가 왜 발생했는지 그 정확한 원인을 찾아서 해결한
트러블 슈팅 과정을 정리하고 싶었지만 그러지 못하였습니다.
하지만 이 과정에서 실제 코드의 가장 로우한 부분이 어떻게 동작하고 돌아가는지 확인하고,
그 과정에서 운영체제의 시스템 호출이 어떻게 사용하게 되는지,
와이어 샤크를 통해 패킷을 분석하고 소켓이 어떻게 생성되고 닫히는지,
또한 3-way handshake에 대해 좀 더 깊이 이해할 수 있었습니다.
'고찰 & 트러블슈팅' 카테고리의 다른 글
대규모 트래픽에서의 connection reset by peer 트러블 슈팅 (0) | 2024.08.29 |
---|---|
Typescript Cannot find module에러 (0) | 2024.04.16 |