HTTP connection pool in Go explained

Image not Found

안녕하세요. 버즈빌의 데이터 엔지니어 Raf입니다. 버즈빌에서는 Go 마이크로 서비스에서 Elasticsearch와 DynamoDB 등의 AWS SDK를 사용하거나, 내부 서비스가 아닌 애드 네트워크나 앱 퍼블리셔 등의 HTTP로 통신해야 하는 외부 서비스를 연동하는 경우 HTTP 요청을 사용하고 있습니다. 저는 DynamoDB의 응답시간을 낮추기 위해 최적화를 하던 도중 HTTP 커넥션 관리에 대해 집중적으로 분석을 하게 되었습니다. 이 포스팅에서는 Go에서 HTTP 통신에 사용되는 http.Transport를 분석하며 Go의 http.Client가 커넥션을 관리하는 방법, 효과적으로 쓰기 위해 설정할 수 있는 파라미터, http.Client의 내부로직에 trace를 심는 방법 등을 소개하려고 합니다.

먼저 간단하게 http.Client를 사용하여 웹사이트에 GET 요청을 보내는 예제를 가져왔습니다. 커넥션 풀을 10으로 설정하여 요청을 보내는 예시입니다. Go의 HTTP에 대해 파라미터를 직접 수정할 일이 없다면 http.Get() 함수를 호출해서 간단하게 사용 할 수도 있습니다.

http.Client, http.Transport 이 둘은 각각 어떤 역할을 하는가?

Go의 net/http 패키지에서 http.Client가 선언된 부분을 보면 아래와 같이 설명이 붙어있습니다.

A Client is higher-level than a RoundTripper (such as Transport) and additionally handles HTTP details such as cookies and redirects.

이것을 보면 http.Client는 통신에 대해서는 직접적으로 관여하는 것처럼 보이진 않고 헤더 등의 필요한 데이터를 채워 넣는 일 등을 할 것으로 추측할 수 있습니다. 또한, http.Clienthttp.RoundTripper 인터페이스를 통해 http.Transport를 가지고 있습니다. http.Transport에는 아래와 같은 설명이 있습니다.

Transport is an implementation of RoundTripper that supports HTTP, HTTPS, and HTTP proxies. By default, Transport caches connections for future re-use.

이를 통해 Transport가 실제로 요청을 주고받는 역할을 하며 커넥션 풀도 관리한다는 것을 알 수 있으므로 http.Client는 보지 않고 http.Transport를 보도록 하겠습니다.

http.Transport

커넥션 풀 관리에 쓰이는 공유변수와 파라미터

http.Transport가 가지는 필드 중, 커넥션 풀과 관련된 것들만 보면 아래와 같습니다.

아래 그림 1은 위의 필드 중, 커넥션 풀과 연관된 것들을 도식화하였습니다.

text 그림 1) 커넥션 풀 공유변수

먼저, 그림에서 굵게 표시된 것들은 모두 http.Transport가 가지는 필드입니다. Go를 사용해 보신 분들은 아시겠지만, 소문자로 시작하는 필드는 private이므로 애플리케이션 코드에서 접근할 수 없고, 대문자로 시작하는 필드는 public이므로 접근 가능한 필드입니다. 오른쪽 아래의 PersistConn은 하나의 커넥션을 가진 구조체이며, writeLoop, readLoop 이라는 두 개의 Go routine을 가지고 있습니다. 또한 다른 그림에서 PC라고 표현된 것은 모두 PersistConn을 의미합니다.

왼쪽 박스의 Idle Connections는 커넥션 풀에 관련한 필드들입니다. idleConn은 코드에 표현된 것처럼, 키, 값을 각각 호스트 정보(connectMethodKey)와 PersistConn 슬라이스를 가지는 맵입니다. 각 PersistConn은 타임아웃 시간이 설정되어 있으며 빨강색 숫자로 커넥션이 사라질 때까지의 남은 시간을 표현하였고, 슬라이스의 뒤쪽 인덱스에 있을수록 최근에 사용한 PersistConn 입니다. PersistConn의 타임아웃값은 IdleConnTimeout을 통해 설정 할 수 있습니다. 또한 MaxIdleConnsPerHost 값을 통해 호스트마다 가질 수 있는 최대 유휴 커넥션 수를 조정 할 수 있습니다. idleLRU는 전체 커넥션 풀 크기를 제어하기 위한 큐입니다. 오른쪽에 위치할수록 최근에 사용된 커넥션입니다. 전체 유휴 커넥션 수는 MaxIdleConns로 조정 할 수 있습니다.

오른쪽 위 박스 Wating Requests는 요청이 들어왔을 때 커넥션 풀에서 커넥션을 받지 못한 경우 대기하고 있는 요청을 나타냅니다. idleConnWait은 커넥션 풀에 커넥션이 없어서 Go routine을 통해 커넥션이 생성되기를 잠시 기다리는 상태이며 idleConn 처럼 호스트마다 슬라이스를 유지합니다. connsPerHostWait은 모든 커넥션이 사용되고 있는 경우 요청을 기다리도록 하는 큐입니다.

# of Connections 박스에 있는 MaxConnsPerHost 필드로 호스트마다 가질 수 있는 최대 커넥션 수를 제한 할 수 있습니다. connsPerHostMaxConnsPerHost를 위해 호스트마다 가지고 있는 커넥션 수를 저장하고 있습니다.

요청 관점에서 커넥션 풀 다시 보기

위 그림을 통해 간략하게 커넥션 풀 제어를 위해 사용되는 공유변수와 파라미터를 보았습니다. 이 섹션에서는 애플리케이션 코드에서 HTTP 요청을 보냈을 때, 커넥션 풀을 활용하며 커넥션을 생성/반납하는 과정에 대해 알아보겠습니다.

text 그림 2) 커넥션을 가져오는 과정

그림 2는 요청에 사용할 커넥션을 가져오는 과정입니다. 시작지점은 위의 http.Client.Do()가 호출하는 Transport.roundTrip() 메소드입니다. roundTrip()은 커넥션을 얻어오기 위해 getConn() 메소드를 호출합니다. getConn()wantConn 객체를 만듭니다. wantConn 객체는 실제 커넥션에 해당하는 PersistConn을 담을 수 있게 되어있으며, tryDeliver() 메소드를 통해 다른 Go routine이 PersistConn을 전달 할 수 있습니다.

wantConn 객체를 만든 뒤 queueForIdleConn() 메소드에 wantConn을 전달합니다. queueForIdleConn()은 공유변수인 idleConn에 접근하여 필요로 하는 호스트에 대해 PersistConn을 가져오려고 시도합니다. idleConn에서 PersistConn을 가져올 때는 가장 최근에 쓴, 즉 인덱스의 뒤쪽에 있는 PersistConn을 가져와 wantConn 객체의 tryDeliver() 메소드를 호출할 때 전달합니다. 커넥션 풀에 커넥션이 있는 경우엔 위처럼 커넥션을 가져오게 됩니다.

idleConn에서 가져올 PersistConn이 없으면 PersistConn을 생성해야 합니다. PersistConn을 생성하는 과정은 Go routine을 통해서 수행합니다. 먼저 idleConnWait 큐에 waitConn을 넣어둡니다. 커넥션을 생성하는 과정은 Handshake 등으로 오랜 시간이 걸리기 때문에 커넥션이 생성되는 동안 다른 유휴 커넥션이 생기게 되면 빠르게 waitConn에게 전달해 주기 위해 idleConnWait을 활용합니다.

그리고 queueForDial() 메소드를 호출하여 새 Go routine 띄워 커넥션을 만들고 wantConntryDeliver() 메소드를 호출하여 커넥션이 전달되도록 합니다. getConn()을 수행 중인 Go routine은 wantConn 객체에 PersistConn이 들어올 때까지 기다리게 됩니다. wantConn 객체에 PersistConn이 들어오면 getConn() 메소드가 끝나고 PersistConn을 활용해 호스트로 요청을 보내게 됩니다.

dialConnFor() Go routine이 커넥션을 생성하는 동안 반납되는 다른 커넥션이 먼저 wantConn에 전달될 수 있습니다. 이때 dialConnFor() Go routine이 만든 커넥션은 wantConn에게 커넥션 전달에 실패하므로 커넥션을 반납해야 합니다.

text 그림 3) 커넥션을 반납하는 과정

그림 3은 커넥션 반납을 수행하는 tryPutIdleConn() 메소드의 동작을 표현하였습니다. tryPutIdleConn() 메소드가 호출되는 시점은 1) 위의 dialConnFor() 에서 wantConn 에게 커넥션 전달에 실패했을 때, 2) wantConn 이 커넥션을 받았으나 컨텍스트에서 cancel이 생겼을 때, 3) HTTP 요청이 처리되고 난 뒤 애플리케이션 코드에서 res.Body.close()를 호출하여 PersistConnreadLoop() Go routine이 종료될 때입니다. res.Body.Close()를 호출해주지 않으면, PersistConn이 유휴상태가 되지 못하고 계속 살아있게 되어 메모리 릭이 발생하게 되는 것을 알 수 있습니다.

tryPutIdleConn() 메소드는 커넥션 풀로 커넥션을 반납하기 전에 idleConnWait에 저장된 wantConn을 하나씩 꺼내어 tryDeliver() 메소드를 호출하여 커넥션 전달을 시도합니다. 이를 통해 wantConn이 새 Go routine을 띄워 PersistConn을 생성하는 것을 기다리지 않고 빠르게 커넥션을 얻어 갈 수 있습니다. idleConnWaitwantConn이 없으면 커넥션 풀인 idleConn에 커넥션을 저장하면서 커넥션 타임아웃을 설정하게 됩니다.

커넥션 풀을 효과적으로 사용하기 위한 파라미터

Go는 위와 같이 커넥션 풀을 관리하여 커넥션을 재사용하도록 도와주며, 그림 1에 나온 몇 가지 파라미터를 조정하여 커넥션 풀을 더 효과적으로 사용 할 수 있습니다. 커넥션 풀과 관련한 파라미터와 default 값을 나열해 보면 아래와 같습니다.

  • MaxIdleConns: 유지 가능한 최대 유휴 커넥션 수, default: 100
  • MaxIdleConnsPerHost: 호스트마다 유지 가능한 최대 유휴 커넥션 수, default: 2
  • IdleConnTimeout: 유휴 커넥션 타임아웃, default: 90초
  • MaxConnsPerHost: 호스트마다 사용 가능한 최대 활성/유휴 커넥션 수, default: 0 (무제한)

이 중 MaxIdleConnsPerHost의 default 값은 호스트마다 유지하는 유휴 커넥션 수가 최대 두 개까지 되는 것을 알 수 있습니다. 이 값을 고려했을 때, 트래픽이 줄어들면 빠르게 유휴 커넥션을 제거하여 메모리를 확보 할 수 있습니다. 하지만 이 값은 한 호스트로 요청을 보내는 트래픽이 갑자기 증가할 때 응답시간에 악영향을 미칠 수 있습니다. 단위 시간당 사용 중인 커넥션이 유휴 커넥션으로 돌아오는 속도보다, 커넥션을 사용하려는 요청이 더 많게 되면 커넥션을 생성하려고 할 것입니다. 따라서 요청의 Critical path에 커넥션 생성 과정이 포함되어 응답 시간이 늘어나게 되므로 커넥션 풀 활용의 이점이 사라지게 됩니다. 이처럼 서비스가 참조하는 호스트의 수가 일정하게 정해져 있다면 MaxIdleConnsPerHost 값을 바꾸어 사용하는 것을 권장 드립니다.

httptrace.ClientTrace

getConn() 메소드를 통해 커넥션을 가져온 뒤엔 이 커넥션을 통해 실제 통신을 하는 과정이 남아있습니다. 이 내용은 요청이 진행되는 과정에 trace를 심을 수 있는 httptrace.ClientTrace 와 함께 소개해드리고자 합니다.

httptrace.ClientTrace는 아래와 같은 함수들을 정의하여 trace를 심을 수 있습니다.

godoc의 httptrace 패키지를 보면 위 trace들이 언제 호출되는 지 주석을 통해 알 수 있습니다. 하지만 저는 HTTP 통신 과정을 자세히 알지 못하다 보니 어떤 순서로 호출되는지 알 수 없어서 실제로 사용할 때 모든 trace를 심어 로그를 보면서 대강 순서를 파악했습니다. 그래서 저처럼 HTTP 통신 과정을 잘 알지 못해도 호출되는 순서를 통해 trace를 심을 곳을 쉽게 파악 할 수 있도록 통신 과정 사이에 호출되는 trace를 도식화하였습니다. 그림 4은 커넥션을 얻는 과정, 그림 5는 서버와 통신하는 과정입니다. 타임라인에 따라 Go routine이 하는 행동을 설명해두었고, Go routine을 호출하거나 채널을 통해 데이터를 주는 경우는 점선 화살표로 표시하였습니다. 또한 동작하는 중에 httptrace.ClientTrace의 메소드를 호출하는 경우는 밑줄로 표시해두었습니다. 타임라인에서 굵게 칠해진 선들은 채널을 통해 기다리고 있는 상태를 의미합니다.

text 그림 4) 커넥션을 얻는 과정에서 호출되는 trace

 

text 그림 5) 통신하는 과정에서 호출되는 trace

이 중 가장 유용했던 것은 TLSHandshakeStart()TLSHandshakeDone()을 통해 TLS handshake 과정이 응답시간에 큰 영향을 미치는 것을 확인한 것이었습니다. DynamoDB를 사용하던 서비스에서 평소에는 10ms 정도의 빠른 응답시간으로 처리되던 요청이 가끔 100ms를 넘긴 적이 있었는데, trace를 심고 나서 TLS Handshake 과정으로 인해 많은 시간이 소요되는 것을 확인했었습니다.

httptrace.ClientTrace는 아래처럼 사용 할 수 있습니다.

위와 같이 http.Request에 Context를 주입하여 trace를 심을 수 있습니다. 하지만 HTTP 요청이 생기는 코드마다 이 로직을 심는 것 대신, 아래처럼 http.RoundTripper 구현을 통해 요청을 생성하는 코드로부터 분리 할 수 있습니다. 또한 HTTP 요청에서 리디렉션 등으로 여러 번의 실제로 HTTP 요청이 생기는 것으로 추정되면, RoundTrip() 메소드 호출 전/후에도 trace를 심어 각각의 HTTP 요청에 대한 수행시간을 확인 할 수 있습니다. 버즈빌에서는 trace 함수에 Datadog을 연동하여 flame graph로 trace를 보고 있으며, 오픈 소스 대체재로는 opentelemetry, jaeger 등이 있습니다. (버즈빌 백 엔드 기술 스택을 소개합니다)

마치며

httptrace.ClientTrace를 활용하여 커넥션 생성과 HTTP 요청 중 병목이 되는 구간을 파악 할 수 있었고, 서비스의 HTTP 요청 패턴에 따라 http.Transport의 파라미터를 조정하여 커넥션 풀을 더 잘 활용할 수 있게 되었습니다. 사실 DynamoDB의 응답 시간을 최적화하기 위해 여러 시도를 해보다가 http.Transport 까지 내려와서 코드를 보게 되었는데, 다음 포스팅에서는 AWS SDK를 사용하면서 숨겨진 HTTP 요청이 간헐적으로 느린 응답 시간을 보이는 문제를 해결했던 이야기를 공유해 드리도록 하겠습니다.

작성한 내용은 http 패키지의 모든 내용을 자세히 확인한 것은 아니니 다소 부족한 점이 있을 수 있습니다. 혹시 알고 계시는 것과 다른 것을 발견한다면 Github Gist에 댓글을 남겨주시거나, Linkedin Profile로 메시지를 보내주시면 감사하겠습니다.

버즈빌 개발자 지원하기 (클릭)

버즈빌 테크 리크루터와 Coffee Chat하기 (클릭)

You May Also Like

버즈빌, 아마도 당신이 원하던 회사!

지원하기