쿼카러버의 기술 블로그

[Golang] httpclient connection pool관리하는법 p.s. 소켓과 TCP Handshake란? 본문

[Golang]

[Golang] httpclient connection pool관리하는법 p.s. 소켓과 TCP Handshake란?

quokkalover 2022. 3. 7. 21:53

Go httpclient connection pool

필자는 현재 Go언어로 MSA 아키텍쳐의 API 서비스를 개발하고 있다. 서비스 내부에서는 gRPC, jRPC, REST 등 다양한 HTTP기반 프로토콜을 사용해보고 있지만, 외부에 배포되는 서비스로는 HTTP를 기반으로 XML 또는 JSON을 이용하여 서버-클라이언트가 데이터를 주고받는 통신 방식인 REST API를 개발하고 있다.

 

트래픽이 적은 토이 프로젝트를 개발할 때는 성능 문제를 다룰 필요성을 못느꼈었는데, 회사에 와서 대용량 트래픽을 경험하다보니 connection explosion이 발생하면서 내가 알고있지 못했던 부분들을 많이 배우게 됐는데 그 중 하나가 httpclientconnection pool관리였다.

 

DBCP는 많이 들어봤지만 httpclient connction pool의 개념은 생소해서 찾아보니, 너무나도 잘 정리된 글을 발견해서, 이 글을 토대로 소켓과 HTTP state들의 개념들을 추가하고 재구성하여 공부할겸 정리하고자 한다. 사실 배보다 배꼽이 더 큰 격으로, 다른 부분 설명이 더 많아서, 그냥 httpclient connection pool세팅 권장사항 및 가이드를 보고 싶다면 그냥 아래로 쭉 내려서 Golang HTTP 클라이언트 사용가이드 으로 넘어가는걸 추천한다 ㅋㅋ 필자가 나중에 참고하고자 정리한 글이기 때문에 디테일이 좀 많다..

 

 

시작해보자!

 

 

Go로 서비스 개발을 할 때 별토의 세팅 없이 default httpclient를 사용하게 되면 HTTP Request가 발생할 때마다 새로운 client 객체를 매번 생성하는 방식으로 동작하면 커널단에서 socket을 지속적으로 만들고 Close하면서 TIME_WAITsocket이 쌓여 자원(포트)이 고갈되는 문제가 발생한다. connection pool을 사용하는 이유로 RTT가 1번 더 발생하는 등 네트워크 연결 비용이 조금 더 발생하고 속도가 더 느려지는 것으로 이해했었는데, 장애를 겪어보니 connection pool사용 뿐 아니라 pool 관리의 필요성을 절실히 느꼈다.

 

 

httpclientconnection pool개념을 이해하기 위해서는 다음의 선행지식이 필요하다. 먼저 이 선행지식들을 살펴보고 넘어가자.

  • connection pool
  • socket
  • HTTP TCP Handshake

 

Connection pool

Connection pool이란 Connection을 미리 만들어서 pool속에 저장해 두고 있다가 필요할 때 connection을 pool에서 쓰고 다시 pool에 반환하는 기법을 말한다. 주로 웹 컨테이너를 실행할 때 데이터베이스와의 커넥션을 미리 생성해두고 pool속에 저장해두고 있다가 필요할 때에 가져다 쓰고 반환한다.

 

이렇게 connection을 미리 생성해두게되면 connection을 생성하는 데 드는 연결 시간이 소비되지 않는다. 물론 connection pool의 크기를 접속자 수, 서버 부하 등을 고려해 적절한 크기로 조정해야 한다. 그렇지 않으면 아래와 같은 문제가 발생할 수 있다.

  • Connection pool의 크기가 너무 작으면 대기하는 요청이 증가한다.
  • Connection pool의 크기가 너무 크다면 메모리 낭비가 심해진다.

 

자 이제 connection pool이 무엇인지 알아봤고, connection pool을 사용하면 성능 개선을 얻을 수 있는데 그 이유로 크게 다음의 두 가지를 생각해볼 수 있다. (물론 더 자세한 이유가 있다.)

  • 반복되는 비용과 시간 줄임 : TCP handshake 비용 감소
  • 자원 고갈로 인한 문제 가능성 감소 : TIME_WAIT 문제 해소

 

위 두 가지가 있는데, 이들을 좀 더 구체적으로 이해하기 위해 socketHTTP(TCP)의 TCP handshake에 대해 알아보자.

Socket

앞으로 소켓이라는 단어를 자주 보게될텐데, 소켓의 뜻을 먼저 짚고 넘어가보자. 소켓과 포트가 좀 헷갈릴때가 많아서 포켓도 설명할꺼다 ㅎㅎ.

소켓은 프로세스가 네트워크에서 데이터를 통신할 수 있도록 연결해주는 연결부다. 즉 서버, 클라이언트 모두 데이터를 주고받으려면 각각 소켓을 생성해야 한다. 서버에서는 특정 포트와 연결된 소켓을 가지고 컴퓨터 위에서 동작하는데, 이 소켓을 통해서 소켓의 연결 요청이 있을 때가지 대기한다.(Listen한다.) 또 클라이언트와 연결이되면 연결된 Client 소켓과 통신하기 위해 새로 들어오는 Client 연결 요청을 받을 새로운 소켓을 하나 더 생성한다.

 

포트(Port)

포트(Port)는 네트워크 상에서 통신하기 위해서 호스트 내부적으로 프로세스가 할당받아야 하는 고유한 숫자이다. 포트번호는 데이터를 받을 때와 보낼 때 모두 필요하다. 데이터를 전송하고 목적지 호스트에 데이터가 도착했을 때 호스트가 어떤 소켓으로 데이터를 보낼지 결정하게 해주는 값이 바로 포트다. 따라서 한 호스트 내에서 네트워크 통신을 하고 있는 프로세스를 식별하기 위해 사용되는 값이므로, 기본적으로는 같은 호스트 내에서 서로 다른 프로세스가 같은 포트 넘버를 가질 수 없다.

 

소켓 (Socket)

프로세스가 데이터를 주고받을때 보내는쪽과 받는쪽 모두 소켓을 열어야 한다. 보내는 쪽이 소켓이라는 창구를 열고 소켓을 통해서 데이터를 보내면 네트워크 모델에 따라 호스트에 데이터가 도착하게 되고, 데이터를 담은 봉투에 써진 도착지의 포트 넘버와 같은 포트를 할당 받은 프로세스를 찾아서 그 프로세스의 소켓을 통해 해당 프로세스에 데이터를 전달한다.

소켓을 열기 위해선 호스트에 할당된 IP 주소, 포트 넘버, 프로토콜 등이 필요하고, 이 세가지가 소켓을 정의하고 식별한다.

 

소켓 정의 : 5-tuple

하나의 커넥션을 식별하기 위해서는 아래 5개의 element가 필요하다.

  1. protocol
  2. src IP address
  3. src port
  4. dst IP address
  5. dst port

 

소켓 생성 흐름

클라이언트

클라이언트 소켓(Client Socket)은 처음 소켓(Socket)을 [1]생성(create)한 다음, 서버 측에 [2]연결(connect)을 요청한다. 그리고 서버 소켓에서 연결이 받아들여지면 데이터를 [3]송수신(send/recv)하고, 모든 처리가 완료되면 소켓(Socket)을 [4]닫는다(close).

 

서버

서버 소켓(Server Socket)은 처리 과정이 조금 복잡하다. 일단 클라이언트와 마찬가지로, 첫 번째 단계는 소켓(Socket)을 [1]생성(create)하는 것이다. 그리고 서버 소켓이 해야 할 두 번째 작업은, 서버가 사용할 IP 주소와 포트 번호를 생성한 소켓에 [2]결합(bind)시키는 것이다. 그런 다음 클라이언트로부터 연결 요청이 수신되는지 [3]주시(listen)하고, 요청이 수신되면 요청을 [4]받아들여(accept) 데이터 통신을 위한 소켓을 생성한다. 일단 새로운 소켓을 통해 연결이 수립(ESTABLISHED)되면, 클라이언트와 마찬가지로 데이터를 [5]송수신(send/recv)할 수 있다. 마지막으로 데이터 송수신이 완료되면, 소켓(Socket)을 [6]닫는다(close).

 

 

하나의 포트 = 다수의 소켓 생성 가능

프로세스가 네트워크 통신을 하기 위해서는 포트를 할당받아야 하는데, 서버의 경우는 보통 하나만 할당받는다. 같은 프로세스가 같은 포트를 가지고도 여러 개의 소켓을 열 수 있기 때문이다. 하나의 프로세스는 같은 프로토콜, 같은 IP 주소, 같은 포트 넘버를 가지는 수십 혹은 수만개의 소켓을 가질 수 있다. 이런 이유 때문에 하나의 프로세스는 하나의 포트만으로도 다른 여러 호스트에 있는 프로세스의 요청을 처리할 수 있고, 따라서 게임 서버의 접속자수가 수십 수백만이 될 수 있다.

즉 아래로 정리해볼 수 있겠다. (최대한 간단하게 해봄..)

  • TCP소켓은 하나의 커넥션이 아니다. 특정 커넥션의 endpoint다.
  • TCP의 소켓은 UDP와 달리 5가지의 identifier로 구분되기 때문(5-tuple)이다. 서로 다른 src IP Address, src Port 번호로 서버측 transport Layer에서 서로 다른 소켓으로 demultiplexing이 가능하다.
  • 클라이언트 A, B의 프로세스의 소켓이 8080번 포트의 웹서버와 연결을 맺는다면, HTTP request는 모두 8080번 포트로 향한다.
  • 여기서 헷갈리는건 client가 소켓을 열 때는 하나의 소켓당 하나의 포트가 열리겠지만, 서버입장에서는 하나의 포트에 다수의 소켓이 생길 수 있다는 점이다.
    • 소켓은 src, dst IP, port가 있기 때문에 IP, port만 다르면 다수의 소켓을 생성할 수 있기 때문. (소켓은 파일이기 때문에 file descriptor가 생성될 수 있는 갯수 만큼)
    • 아파치를 예로 들면 요렇게 cli_fd(socket의 filedescriptor를 부여하고, 가져올 수 있음)
      • int cli_fd = accept(sockfd, (struct sockaddr *) &cli_ip_address, &ip_len);
    • 좀더 자세한 설명 필요하면 이 글 읽는 것 추천https://medium.com/@nieldeokar/port-vs-socket-3c2d6ea854cc
    • 클라이언트가 다수의 커넥션을 서버와 맺으면 클라이언트는 랜덤 포트로 소켓하나 새로 생성해서 서버에 연결 요청을 해야 함. (different random src port)

따라서 다시 한번 정리해보면 소켓을 정의하는 것소켓을 식별하는 것은 구분해야 한다. IP주소, 포트 넘버, 프로토콜로 소켓을 정의할 수는 있지만, 이것이 소켓을 유일하게 식별하진 않는다. 소켓을 여러개를 만들면 고유번호가 필요한데 이때 "디스크립터" 라고 말하며 소켓의 번호라고 생각하면 된다. (리눅스에서는 모든게 파일이니까 file descriptor임)

 

 

리눅스에서는 모든게 파일. 즉, 소켓 = 파일

참고로 리눅스에서는 모든게 파일이다. 따라서 소켓조작과 파일 조작을 동일하게 간주한다. 즉 소켓을 파일의 일종으로 구분한다. 따라서

  • 전체 시스템에서 가질 수 있는 최대 파일 개수 제한이 있으면 소켓의 전체 개수에 영향을 미친다. (시스템이 굉장히 많은 파일과 소켓을 사용하는 경우, open() 시스템 콜에서 too many open files와 같은 에러가 발생한다. 사실 커널보다는 프로세스가 가질 수 있는 소켓 갯수 제약을 봐야하고, 따라서 프로세스별 제한 설정인 user limit값을 살펴봐야 한다. (ulimit -a)(open files값이 프로세스가 가질 수 있는 소켓 포함 파일 갯수)
    • NIC(Network Interface)를 통해 컴퓨터 내부로 전송된 데이터를 소켓에 분배하는 작업은 운영체제가 한다. 이 때 운영체제는 Port번호를 활용해서 컴퓨터 내부로 전송된 데이터를 소켓에 분배한다. 즉, Port번호는 하나의 운영체제 안에서 소켓을 구분하는 목적으로 사용된다. 따라서 하나의 운영체제 내에서 동일한 포트 번호를 둘 이상의 소켓에 할당할 수 없다.
    • PORT번호의 범위는 0 ~ 65535 이하 (0~1023번까지는 Well-known Port)
  • 파일 입출력 함수를 소켓 입출력에, 다시 말해 네트워크 상에서의 데이터 송수신에 사용할 수 있다.

 

 

TCP handshake : 반복되는 비용과 시간

TCP는 연결형 프로토콜이다.

HTTP통신에서는 데이터를 전송하기 위해 정확한 전송을 보장하기 위한 3-way handshake 작업이 발생한다.

  1. 클라이언트가 통신 상대인 서버측 OS에게 가상 경로 오픈을 의뢰하며 SYN 패킷 전송
  2. 서버측 소켓은 LISTENING상태이기에 ACK +SYN 패킷 응답.
  3. 클라이언트도 다시 ACK 패킷으로 응답하며 서버의 새로운 소켓이 생성되며 연결(ESTABLISHED)된다

그리고 데이터 전송이 종료되면 리소스를 정리하기 위한 4-way handshake이 발생한다.

  1. 클라이언트가 연결을 종료하겠다는 FIN플래그를 전송한다. 이때 A클라이언트는 FIN-WAIT상태가 된다.
  2. 서버는 FIN 플래그를 받고 일단 확인 메시지 ACK을 보내고 자신의 통신이 끝날 때 까지 기다리는 상태 즉 CLOSE_WAIT상태가 된다.
  3. 연결을 종료할 준비가 되면 연결 해지를 위한 준비가 되었음을 알리기 위해 클라이언트에게 FIN플래그를 전송한다. 이 때 B서버는 LAST-ACK상태가 된다.
  4. 클라이언트는 해지 준비가 되었다는 ACK을 확인했다는 메시지를 보내고 이때 A클라이언트의 상태가 FIN-WAIT → TIME-WAIT으로 변경된다.

즉 TCP는 통신의 신뢰성을 보장하기 위해 데이터의 전달과 흐름 제어, 혼잡 제어 등을 제공하는 전송계층이다. 여기서 커넥션 풀 사용의 힌트를 하나 얻자면, 커넥션을 맺을 때마다 위 3way/4way handshake 작업이 발생할 텐데, 이러한 반복적으로 인해 발생하는 성능 저하 비용을 방지 하기 위해 미리 커넥션을 맺어두고 재사용하는 connection pool이 필요할 것이다.

 

 

CLOSE_WAIT & TIME_WAIT : 자원 고갈 문제

TCP 4way handshake에서, TIME_WAIT상태의 소켓 누적 문제가 connection pool을 사용해야하는 이유가 되는데, 이를 이해하기 위해서는 CLOSE_WAITTIME_WAIT이 무엇인지 알아볼 필요가 있다.

연결 해제 요청 대상은 크게 아래 두 가지로 나눠볼 수 있다.

  • Active Close : TCP 연결 해제를 요청한 대상
    • 상태 : FIN_WAIT1, FIN_WAIT2, TIME_WAIT
      • FIN_WAIT2 : Active Close가 Passive Close로부터 FIN패킷을 기다리는 상태. 일정 시간이 오지 않으면 자동으로 TIME_WAIT으로 넘어감
  • Passive Close : TCP 연결 해제를 수신한 대상
    • 상태 : CLOSE_WAIT상태, LAST_ACK

위에서 보면 알 수 있듯이 클라이언트, 서버 모두 둘 중 하나가 될 수 있다. 서버가 연결을 끊는다면 Active Close가 될 것이고, 클라이언트가 서버 연결을 끊었을 때는 Passive Close가 된다.

 

CLOSE_WAIT

CLOSE_WAIT은 Passive Close가 FIN요청을 수신했을 때 발생한다. Passive Close는 FIN요청을 받았을 때 즉시 Close를 실행하지 않고 TCP포트를 사용중인 프로세스에게 종료 명령을 내리고, Close명령을 실행할 때 까지 기다리는 상태를 CLOSE_WAIT이라고 한다. 즉 FIN Flag가 1인 패킷을 받았을 때 연결을 종료하겟다는 것을 일단 알겠다는 의미로 ACK을 보낸다. 출력 버퍼에 있는 데이터를 모두 보낸 다음에 서로간의 연결을 끊어야 하기 때문애 ACK만 보낸다.

서버 부하테스트를 진행하다 일정 시간이 경과하면 행업 상태에 빠지는 경우가 자주 발생한다. 부하가 높아지게 되면 CPU부하 등의 이유로 느려지는건 당연하지만 행업 상태에서 복구되지 않는 문제가 발생한다. 실제로 부하테스트를 진행하고나서 서비스 중인 포트의 상황을 lsof -i:{포트 번호}netstat -tonp로 조회해보면 CLOSE_WAIT상태의 포트가 매우 많은 경우가 있다. 이렇게 CLOSE_WAIT상태가 사라지지 않고 계속 쌓이게 되면 서버에서는 더 이상 서비스를 처리하지 못하는 상태가 된다.

이렇게 되는 이유는 Passive Close측이 CLOSE_WAIT상태에 빠지게되면 Active Close측은 FIN을 못받는 상태이기 때문에 FIN_WAIT2에서 마찬가지로 대기하게 된다.

커널 옵션으로 타임아웃 조절이 가능한 FIN_WAIT이나 재사용이 가능한 TIME_WAIT과는 달리, CLOSE_WAIT는 포트를 잡고 있는 프로세스의 종료 또는 네트워크 재시작 외에는 제거할 방법이 없다. 즉, 로컬 애플리케이션이 정상적으로 close()요청을 하는 것이 가장 좋은 방법이다.

흔한 에러로 클라이언트로 요청하고 나서 response를 명시적으로 Close해주지 않으면 too many open files에러가 발생한다.

 

TIME_WAIT

TIME_WAIT이 왜필요할까? 이건 클라이언트가 서버로부터 오는 데이터의 유실을 방지 및 sequence ID가 꼬이는 것을 방지하기 위해 있는 장치다. TIME_WAIT 상태 동안에는 해당 소켓의 주소를 다른 소켓에게 할당하는 것을 막는다.

  • 데이터 유실 
  • 클라이언트가 Server에서 FIN패킷을 전송하기 전에 전송한 패킷이 Routing 지연이나 패킷 유실로 인한 재전송 등으로 FIN패킷보다 늦게 도착하는 경우에, FIN패킷이 도착하자마자 연결을 닫아버리면, 그 뒤에 오는 패킷들은 drop되고, 데이터가 유실될 수 있다.
  • Sequence ID 꼬임 
  • 즉시 연결 종료하고 바로 다음 연결을 맺을 때 첫 번째 연결했을 때의 데이터 패킷이 뒤늦게 도착하여 Sequence ID가 꼬이는 것을 방지. 즉 마지막 ACK이 전달되지 않을 경우기존 포트 번호를 다른 소켓에게 할당한다면 FIN flag가 1인 패킷을 받게 되면 제대로된 전송이 이루어지지 않는다.

따라서 Server로부터 FIN을 수신하더라도 일정시간 동안 세션을 남겨놓고 잉여 패킷을 기다리는 과정을 거치는데, 이 과정을 TIME_WAIT이라고 한다. 이 일정시간이 지나고나면 세션을 만료하고 연결을 종료시키며, CLOSE 상태로 변화한다. 문제는 대기 시간이 짧지 않다. 우분투 환경에서는 60초정도를 유지하고 있는다고 한다.

 

TIME_WAIT으로 인한 자원 고갈

클라이언트의 호출을 받은 A서버가 다른 B서버의 서비스를 호출하면 매번 Connection을 만들고 Close하게 되면서 TIME_WAIT상태가 발생하고 60초 지나면 사라지는 것을 반복하게 된다. (물론 TIME_WAIT시간은 커널에 default 값으로 정해져있지만 수정 가능하다.)

이 상태에서 60초 이내에 수 만개의 Request가 오면 어떻게 될까? A서버의 모든 네트워크 포트가 고갈되어 클라이언트는 더 이상 A서버로 요청을 보낼 수 없는 상태가 된다. 즉 모든 클라이언트가 요청을 못보내는 것이 아니라 일부 클라이언트의 요청은 받을 수 있고, 일부 클라이언트의 요청은 받을 수 없는 상태가 된다.

즉 이런 경우는 갑자기 사용자가 증가하는 경우에 발생한다. 서버쪽 네트워크 자원은 다음의 명령으로 모니터링할 수 있다. 2초마다 네트워크 연결 중에 ESTABLISHEDTIME_WAIT상태에 있는 연결 갯수를 볼 수 있다.

watch -n 2 "netstat -an | egrep 'ESTABLISHED|TIME_WAIT' | wc -l

서버가 행걸리는 경우에 위 커맨드를 실행해보고, 서버로 접속할 수 없다는 에러 메시지가 나타날 때, TIME_WAIT 상태에 있는 연결 갯수가 문제인 경우를 진단해볼 수 있다. 이런 경우 역시 connection pool을 사용하지 않고 요청 때마다 client를 새로 만들고 connection을 새로 생성할 때 발생한다.

 

 

Golang HTTP 클라이언트 사용가이드

golang은 기본으로 제공하는 httpclientconnection pool을 내장하고 있다. 요청을 처리 후에 연결을 종료하는게 아니라, idle connection을 생성해서 idle connection pool에 넣어두고, timeout(일반적으로 90초)가 발생하기 전에 요청이 다시 생성되면 이미 있는 connection을 재사용하는 것이다. 이렇게 하면 socket connection의 갯수를 줄일 수 있다.

그리고 http client를 사용할 때는 아래 이슈를 고려해보는게 좋다.

  • connection pool에 담을 수 있는 connection의 제한이 있는가?
  • connection이 오랫동안 사용되지 않으면, connection을 재사용할 필요가 있는가?
  • idle connection이 connection pool에 없어서 새로운 connection을 맺을 수 없을 때, 큐에 담아야 하는가?
  • queue에 담아야 한다면, queue에 담아둘 시간과, queue의 크기는 어떻게 정해야 하는가?

자 그래서 connection pool을 사용하면 모든 세팅이 끝일까? 그렇진 않다, golang에서 제공하는 http client를 사용할 때는 세팅을 조금만 잘못하면 TIME_WAIT지옥을 맛볼 수 있다. 이제 golang의 http 패키지를 통해 connection pool의 개념의 활용방안을 알아보자. 고려해야할건 당연히 무궁무진하지만 본 글에서는 idleConns에 집중할 것이다.

 

 

transport struct(구조체)

golang의 http client는 connection pool을 사용하고 있으며 여러가지 옵션( Transport 구조체)을 이용하여 connection 설정을 할 수 있다.

// Transports should be reused instead of created as needed.
// Transports are safe for concurrent use by multiple goroutines.
type Transport struct {
    //A lock is required to operate an idle connection
    idleMu       sync.Mutex
    //Idle connection pool, key is the combination of Protocol target address, etc
    idleConn     map[connectMethodKey][]*persistConn // most recently used at end
    //Queue waiting for idle connection, based on slice implementation, unlimited queue size
    idleConnWait map[connectMethodKey]wantConnQueue  // waiting getConns

    //A lock needs to be acquired when queuing to establish a connection
    connsPerHostMu   sync.Mutex
    //Number of connections per host
    connsPerHost     map[connectMethodKey]int
    //The queue waiting to establish a connection is also based on slicing, and the size of the queue is unlimited
    connsPerHostWait map[connectMethodKey]wantConnQueue // waiting getConns

    //Maximum number of idle connections
    MaxIdleConns int
    //The maximum number of idle connections per target host; the default is 2 (note the default)
    MaxIdleConnsPerHost int
    //The maximum number of connections that can be established per host
    MaxConnsPerHost int
    //The connection is closed when it is not in use
    IdleConnTimeout time.Duration

    //Disable long connection, use short connection
    DisableKeepAlives bool
}

위 transport구조체를 좀 살펴보면, 특정 호스트에게 허용되는 connection과 idle connection 수를 제한함으로써 Queue를 보호하고 있고, 주요 설정 값으로 MaxIDleConns, MaxIdleConnsPerHost를 고려할 수 있다.

MaxIdleConns / MaxIdleConnsPerHost옵션

var DefaultTransport RoundTripper = &Transport{
        ... 
  MaxIdleConns:          100,
  IdleConnTimeout:       90 * time.Second,
        ... 
}

// DefaultMaxIdleConnsPerHost is the default value of Transport's
// MaxIdleConnsPerHost.
const DefaultMaxIdleConnsPerHost = 2

주석을 읽어보면 MaxIdleConns의 기본값은 100으로, MaxIdleConnsPerHost의 기본값은 2로 설정돼있다.

 

MaxIdleConns

이는 즉 요청이 급격하게 들어오게 되면 100개를 넘어서서 connection은 생성되지만, idle connection pool로 커넥션이 들어오지 못하게 되고 즉시 종료되게 된다. 따라서 매우 많은 수의 커넥션이 생성되고, 많은 수의 커넥션이 즉시 종료된다.

 

MaxIdleConnsPerHost

근데 이뿐 아니라 MaxIdleConnsPerHost옵션도 매우 잘못설정하면 치명적이다.

 

MaxIdleConns에서 설정한 바에 따라 100개의 고루틴이 생성돼서 Connection을 만들지만, MaxIdleConnsPerHost옵션에 따라 생성된 Connection 중 2개만 남기고 나머지는 모두 Close하게 된다.

 

Connection pool의 갯수는 100개이지만 Host당 사용가능한 갯수가 2개이기 때문에 지속적으로 Connection 생성, Close를 반복하면서 TIME_WAIT이 증가할 수 있다.

 

Reuse Transport

또 transport의 소스 코드를 유심히 살펴보면 다음과 같은 주석이 있다.

// Transports should be reused instead of created as needed.
// Transports are safe for concurrent use by multiple goroutines.

Transport는 재사용되니 매번 만들지 말라는 경고다. 그리고 소스 코드를 보면 모든 요청에 대해 http.Client를 계속 생성하고 있다. 즉 위에서 설정한 MaxIdleConns, MaxIdleConnsPerHost 갯수 만큼 계속해서 만들어지는 것이다.

따라서 다음과 같이 코드를 수정해서 최초 한번만 생성하고, 이를 재사용하는 방식으로 개선하면 TIME_WAIT문제를 방지할 수 있다.

// Customize the Transport to have larger connection pool
  defaultRoundTripper := http.DefaultTransport
  defaultTransportPointer, ok := defaultRoundTripper.(*http.Transport)
  if !ok {
      panic(fmt.Sprintf("defaultRoundTripper not an *http.Transport"))
  }
  defaultTransport := *defaultTransportPointer // dereference it to get a copy of the struct that the pointer points to
  defaultTransport.MaxIdleConns = 100
  defaultTransport.MaxIdleConnsPerHost = 100

  myClient = &http.Client{Transport: &defaultTransport}

위처럼 설정하고 부하테스트를 진행한 뒤 아래 커맨드로 time_wait상태의 포트를 조회해보면

netstat -n | grep -i 8080 | grep -i time_wait | wc -l

확연히 줄어드는 걸 확인할 수 있다.

 

본 글은 정말 많은 글들을 참고하고 재구성했다. 필자한테는 좀 흥미롭고도 힘든 과정이었지만 그래도 얼추 정리돼서 다행이다. 물론 이것만으로는 부족하기 때문에 추후에 각 키워드랑 주제별로 더 디테일한 자료를 다룰 예정이다.

 

실제로 실행 예제를 보고 싶다면 https://www.popit.kr/마이크로-서비스와-time_wait-문제/ 이 글을 참고하자.

 

 

 

참고자료

https://www.popit.kr/마이크로-서비스와-time_wait-문제/

https://developpaper.com/golang-connection-pool-you-must-understand/

http://tleyden.github.io/blog/2016/11/21/tuning-the-go-http-client-library-for-load-testing/

https://velog.io/@evelyn82ny/4-way-handshake

https://brainbackdoor.tistory.com/127

https://www.loginradius.com/blog/async/tune-the-go-http-client-for-high-performance/

https://blog.naver.com/myca11/221389847130

https://medium.com/fantageek/understanding-socket-and-port-in-tcp-2213dc2e9b0c

https://medium.com/@nieldeokar/port-vs-socket-3c2d6ea854cc

Comments