[JAVA] Apache HttpClient Connection 관리하기 (TIME_WAIT & CLOSE_WAIT 문제)

jundragon·2023년 6월 27일
1
post-thumbnail

예전에 겪었던 문제인데, 해결 방법은 심플하지만 문제를 탐구하는 과정에서 HTTP 네트워크 관련 여러 지식들을 배웠기 때문에 늦었지만 지금이라도 다시 정리해두고자 한다.
이해 됐을 때 바로 정리했어야 했는데..ㅠㅠ

TL;DR

문제상황

서버 부하테스트를 중 응답없음상태(Hang-Up)가 되는 현상을 겪었다. 서버는 이 상태로부터 자동으로 복구되지(Fail-Over) 못했고, 매번 직접 서버를 재시작 해야만 응답없음상태를 해결하고 다시 테스트를 수행할 수 있었다.
테스트 환경에서 100% 재현이 가능한 상황은 부하가 계속 걸리는 상황이 아니고, 서버의 부하를 일정시간 가하고(연결을 맺고) -> 잠시 부하를 (커넥션을 서버측에서 끊도록 충분한 시간동안)멈추고 -> 다시 부하를 발생시키면(다시 연결을 맺기) 서버는 스스로 회복할 수 없는 응답없음 상태에 빠지게 된다.

이와 같은 복잡한 조건(?)으로 계속해서 부하를 처리하고 있는 운영 환경에서는 문제로 식별되지 않았는 데, 그렇다고 그냥 넘어가기에는 찝찝한.. 그리고 가끔은 운영 서버들도 느려지고 결국 행업되기도 했었기에 혹시 어쩌면 근본적인 이유가 여기에 있지 않을까? 하는 생각이 들어서 탐구해보았다.

환경

backend에서 backend를 호출하는 환경임. HTTP 통신 관점에서 보면, 앞단의 Backend 서버가 클라이언트 역할을 하고 있고 응답없음 장애가 여기서 발견 되었다.

모니터링

$ netstat -t | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
TIME_WAIT 78
SYN_SENT 1
ESTABLISHED 66

부하 테스트 상황 (과부하)

$ netstat -t | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
TIME_WAIT 6
SYN_SENT 1
CLOSE_WAIT 23
ESTABLISHED 16

응답 없음 상황

이렇게 CLOSE_WAIT 상태의 소켓이 쌓여가다가 응답없음이 됨

해결방법

해결 방법은 의외로 간단한데,

  • HttpClient의 response를 finally에서 명시적으로 닫아주기
  • Idle, Expired 상태의 커넥션을 주기적으로 정리해주기
  • '클라이언트BE' 리눅스 커널에 net.ipv4.tcp_tw_reuse=1 설정하기

이렇게 세가지 정도만 적용해도 연결을 위한 소켓을 관리하는 데 충분할 것이다.

1. HttpClient의 response를 명시적으로 닫아주기

try {
    System.out.println(response1.getStatusLine());
    HttpEntity entity1 = response1.getEntity();
    // do something useful with the response body
    // and ensure it is fully consumed
    EntityUtils.consume(entity1);
} finally {
    response1.close();
}

apache 공식 가이드에서는 finally블럭에서 response close를 반드시 호출 하라고 가이드 한다.(response가 닫히지 않으면 반납되지 않고 네트워크 자원이 효율적으로 재사용되지 못함)

참고 : https://hc.apache.org/httpcomponents-client-4.5.x/quickstart.html

2. 'Idle', 'Expired' 상태의 커넥션을 주기적으로 정리해주기

PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
IdleConnectionEvictor connEvictor = new IdleConnectionEvictor(cm, 1000, TimeUnit.MILLISECONDS);

connEvictor.start();

IdleConnectionEvictor 의 구현부는 모니터링 스레드이다.

public IdleConnectionEvictor(final HttpClientConnectionManager connectionManager, ThreadFactory threadFactory, long sleepTime, TimeUnit sleepTimeUnit, long maxIdleTime, TimeUnit maxIdleTimeUnit) {
    this.connectionManager = (HttpClientConnectionManager)Args.notNull(connectionManager, "Connection manager");
    this.threadFactory = (ThreadFactory)(threadFactory != null ? threadFactory : new DefaultThreadFactory());
    this.sleepTimeMs = sleepTimeUnit != null ? sleepTimeUnit.toMillis(sleepTime) : sleepTime;
    this.maxIdleTimeMs = maxIdleTimeUnit != null ? maxIdleTimeUnit.toMillis(maxIdleTime) : maxIdleTime;
    this.thread = this.threadFactory.newThread(new Runnable() {
        public void run() {
            while(true) {
                try {
                    if (!Thread.currentThread().isInterrupted()) {
                        Thread.sleep(IdleConnectionEvictor.this.sleepTimeMs);
                        connectionManager.closeExpiredConnections();
                        if (IdleConnectionEvictor.this.maxIdleTimeMs > 0L) {
                            connectionManager.closeIdleConnections(IdleConnectionEvictor.this.maxIdleTimeMs, TimeUnit.MILLISECONDS);
                        }
                        continue;
                    }
                } catch (Exception var2) {
                    IdleConnectionEvictor.this.exception = var2;
                }

                return;
            }
        }
    });
}

사실상 이 조치가 핵심이며, 서버측에서 연결을 닫으면 클라이언트 측은 연결 상태의 변경을 감지할 수 없기 때문에 연결 모니터링 스레드를 실행하여 주기적으로 Idle, Expired 연결을 정리해야 한다.

특히나 서버측 Backend는 타임아웃 설정이 매우 타이트 했으므로(5ms) 서버 측에서 연결을 닫는 Passive Close로 부터 유발된 CLOSE_WAIT 소켓이 정리되지 못하고 쌓이다가 행업되었다.

참고 : https://hc.apache.org/httpcomponents-client-4.5.x/current/tutorial/html/connmgmt.html#d5e418

3. 클라이언트 서버에서 net.ipv4.tcp_tw_reuse=1 설정하기

$ sysctl -w net.ipv4.tcp_tw_reuse=1
$ sysctl -w net.ipv4.tcp_timestamps=1
$ sysctl -w net.ipv4.tcp_tw_recycle=0

중간에 proxy가 없다면 net.ipv4.tcp_tw_recycle도 고려할 수 있지만, 패킷 유실이 발생할 수 있기 때문에 사용하지 말 것. (쓰지 말라면 쓰지말자. 이런 옵션들은 나중에 꼭 예기치 못한 장애를 만든다) 그리고 어차피 리눅스 커널 4.12에서 이 옵션은 제거되었다.

Deep Dive

위의 조치들이 왜 필요한지 알고 싶다면 HTTP에 대한 좀 더 깊은 지식을 요구한다.

우선 통신을 위한 '포트'와 '소켓'을 알아야 함

리눅스에서 소켓의 구현체는 파일이기 때문에(리눅스에서 I/O는 다 파일임..) 프로세스에 한개만 할당 할 수 있는 포트와는 다르게 다수의 소켓(수만개, openfile 수 만큼)이 만들어 질 수 있다. (그래서 부하테스트 할 때 Too Many Open files 에러가 발생한다면 네트워크 자원의 고갈 문제를 가장 먼저 고려할 수 있다.) 소켓은 'protocol-srcIP-srcPort-dstIP-dstPort' 5가지 정보로 구분되고 이 정보를 통해 서버와 클라이언트의 프로세스끼리 연결되어 메시지를 주고 받을 수 있는 것이다.

즉, 클라이언트와 서버의 프로세스는 각각 하나의 포트를 할당 받아야 연결할 수 있고 연결 되는 과정은 TCP 소켓을 만들어서 가능하다. 이것을 TCP Connection이라고 하고 이 프로토콜을 HTTP 통신에서 사용한다.
한마디로 소켓을 만드는 것이 TCP 연결이다.

TIME_WAIT, CLOSE_WAIT는 왜 발생하나?

일단 TCP 커넥션이 맺어지면, 클라이언트와 서버 컴퓨터 간에 교환되는 메시지가 없어지거나, 손상되거나, 순서가 뒤바뀌어 수신되는 일은 결코 없다. (HTTP 완벽 가이드)

통신의 신뢰성을 확보하기 위해, 연결과 연결의 해제는 3-WAY-HANDSHAKE4-WAY-HANDSHAKE의 절차로 수행되며 단계마다 연결(소켓)은 다양한 상태를 가진다. 이중 TIME_WAITCLOSE_WAIT은 연결 종료에 관한 확인 ACK을 기다리기 위한 상태다.

연결을 맺는 3-WAY-HANDSHAKE
1. (Client->Server) 나 메시지 보낼껀데 준비(포트) 되었어?
2. (Server->Client) 응 준비 되었어.
3. (Client->Server) OK, 곧 메시지 보낼께

이 과정을 통해 생성된 소켓은 Establish 상태가 된다.

연결을 끊는 4-WAY-HANDSHAKE 인데, 맺는 것보다 복잡하다.

  1. (Client->Server) 나 이제 통신 그만할래
  2. (Server->Client) 알았어 일 마무리 할께
  3. (Server->Client) 일 마무리 했어!
  4. (Client->Server) 이제 소켓 닫아도 돼!

이렇게 소켓은 CLOSED가 되는데, 문제는 연결은 클라이언트가 끊을 수도 서버가 끊을 수도 있다는 점이다.
그래서 사실은 저 위에 있는 4-WAY-HANDSHAKE 장표는 Client(Active Close), Server(Passive Close) 라고 써야 더 올바르다.

  • Active Close : TCP 연결 해제를 요청한 대상 (이별을 통보한 여자친구)
  • Passive Close : TCP 연결 해제를 수신한 대상 (이별 당한 나)

여기서 이별을 당한 나(Passive Close)는 CLOSE_WAIT 이별을 통보한 여자친구(Active Close)는 TIME_WAIT 상태를 가질 수 있다.

CLOSE_WAIT

내가 경험한 문제는 CLOSE_WAIT으로 발생한다. 이 상태의 소켓은 리눅스 커널 옵션으로 대응이 어렵다. OS에서는 프로세스나 네트워크를 재시작하는 방법 외에는 제거할 수 있는 방법이 없다.
즉, 애플리케이션에서 close 해야만 한다. 이 이유로 모니터링 스레드를 실행해서 서버역할을 하는 백엔드(백엔드에서 요청한 다른 백엔드)와 연결 상태를 검사하고 끊어진 연결을 close 하는 것이다.

TIME_WAIT

이 상태는 FIN 패킷 보다 늦게 들어올 수 있는 지연 패킷들을 안전하게 수신하기 위해 일정시간 대기하는 것으로 데이터 유실이나 시퀀스가 꼬이는 것을 방지하기 위해 필요하다.
문제는 FIN 수신 후 대기하는 시간이 꽤 길다는 것인데 우분투에서는 60초이고 변경은 가능하다.

보통 클라이언트들은 다수의 컴퓨터들이고 랜카드 개수 만큼 네트워크 자원이 있을 것이다.(풍부하다는 말) 따라서, 60초 정도를 TIME_WAIT 상태로 기다리는 것은 별 문제가 되지 않는다.
하지만 MSA 에서는 앞쪽 백엔드가 수많은 클라이언트의 요청을 처리하기 위한 대리자처럼 동작하므로 이 상태는 곧 네트워크 자원 고갈의 문제로 이어질 수 있다.

필자의 상황은 광고를 송출하는 애플리케이션 특성상 많은 요청을 처리해야 했으므로 리눅스 커널 옵션의 TIME_WAIT 소켓 재사용 옵션을 활성화 하였다. net.ipv4.tcp_tw_reuse=1 이 옵션은 리눅스 timestamp 기준으로 구분하여 소켓을 재사용 할 수 있도록 해준다. (안정적인 옵션)

가끔 다른 글을 보면 net.ipv4.tcp_tw_recycle 옵션을 활성화 하는 경우가 있는데 고려하지 말자. (어차피 리눅스 커널 4.12에서 제거된 옵션임)

Connection Pool을 사용하자

TCP Connection Pool은 우리에게 친숙한 DBCP(Database Connection Pool)를 사용하는 것과 같은 이유이다.

  • 연결에 대한 비용을 절약할 수 있다.
  • 연결을 유지하고 재사용함으로써 TIME_WAIT 상태의 소켓이 쌓이지 않도록 한다.

이유를 보면 당연히 사용해야겠지만, 이것을 사용하고 관리하는 것은 나름 쉽지 않은데 Http 커넥션 stateful하며 thread-safe하지 않기 때문이다. 따라서, Http 커넥션은 여러 스레드에서 동시에 사용하면 안된다.

HttpClient는 Http 커넥션을 관리하기 위해 HttpClientConnectionManager를 사용한다. HttpClientConnectionManager의 역할은 다음과 같다.

  • 새로운 Http 커넥션 생성
  • 지속 커넥션 라이프사이클 관리
  • 지속 커넥션에 대한 접근을 동기화하여 지속 커넥션을 여러개의 스레드에서 접근하지 못하도록 관리

출처 : https://gunju-ko.github.io/http/httpclient/2019/01/23/Apache-HttpClient.html

우리는 Connection Pool을 사용할 것이기 때문에 PoolingHttpClientConnectionManager를 사용하면 되지만,
커넥션의 크기는 얼마나 설정해야하며, 관리 옵션들은 어떻게 설정 해야하는지 의문이 들 것이다.

정답은 없지만, 필자의 경우의 사례를 참고해서 적용해보길 바란다.
일단 기본으로 적용되어 있는 커넥션의 크기는 너무 작아서 늘려야 했다. (기본 설정은 라우트당 최대 2개의 커넥션을 생성하며, 총 커넥션의 개수는 최대 20개까지 생성한다.)

필자는 커넥션을 충분히 늘려놓고(일단 기본 옵션에 10배인 perRoute 20, totalMax 200 적용) 부하테스트를 수행하면서 측정된 결과에 약간의 버퍼를 두는 옵션을 적용했다.

설정할 옵션은 다음과 같다.

  • setMaxTotal(int max) : Set the maximum number of total open connections
  • setDefaultMaxPerRoute(int max) : Set the maximum number of concurrent connections per route, which is two by default
  • setMaxPerRoute(int max) : Set the total number of concurrent connections to a specific route, which is two by default

connectionManager = new PoolingHttpClientConnectionManager(1000, TimeUnit.MILLISECONDS);
connectionManager.setMaxTotal(Integer.parseInt(System.getProperty("http.connection.pool.maxConnections", "400")));
connectionManager.setDefaultMaxPerRoute(Integer.parseInt(System.getProperty("http.connection.pool.maxConnectionsPerRoute", "100")));
connectionManager.setDefaultSocketConfig(getSocketConfig());

// 아래 지표들을 Prometheus Metric 으로 노출 시켜서 확인
connectionManager.getTotalStats().getAvailable();
connectionManager.getTotalStats().getLeased();
connectionManager.getTotalStats().getPending();
connectionManager.getTotalStats().getMax();
# HELP http_connection_pool status of http_connection_pool
# TYPE http_connection_pool gauge
http_connection_pool{name="leased",} 0.0
http_connection_pool{name="max",} 200.0
http_connection_pool{name="pending",} 0.0
http_connection_pool{name="available",} 100.0

이런식으로 Actuator로 Prometheus 지표로 노출 시켜서 Grafana로 모니터링하면서 적절한 값을 적용하였다.

Keep-alive는 구분해서 이해하자

제일 헷갈린건 Keep-alive 설정이었다. 설정할 수 있는 곳이 많아서 이해하지 않고 쓴다면 어디에 옵션을 적용할지, 그리고 어떤 옵션이 최종적으로 적용되는지 알기 어렵다. (Spring에서 Properties를 다양하게 적용할 수 있듯이) 이런 Keep-alive는 TCP와 HTTP로 구분해서 이해하도록 하자.

Keep-alive란?

연결을 얼마나 유지할지에 대한 옵션이다.

  • 운영체제, 커널에서 관리하는 TCP Keep-alive
  • 웹서버 프로그램에서 관리하는 웹서버 Keep-alive

이렇게 두가지를 구분하여 알고 있어야 하며,
자신이 운영하는 서버가 TCP Keep-alive 속성인지 웹서버 Keep-alive 속성인지 알고 적용해야 한다.

보통 HTTP 같은 웹 통신은 웹서버의 Keep-alive, Rabbit MQ 같은 TCP 통신은 TCP Keep-alive 속성을 따른다.

TCP Keep-alive

TCP keep-alive는 운영체제 커널에서 관리한다.

$ sysctl -a | grep keepalive

net.ipv4.tcp_keepalive_timeout = 10
net.ipv4.tcp_keepalive_probes = 20
net.ipv4.tcp_keepalive_intvl = 30
  • tcp_keepalive_timeout : 연결을 10초 동안 유지
  • net.ipv4.tcp_keepalive_intvl : 커넥션이 유효한지 확인, 유효하지 않다면 30초 뒤에 다시 확인
  • net.ipv4.tcp_keepalive_probes : 최대 20번까지 커넥션이 유효한지 확인

TCP는 기본적으로 연결지향적이므로, TCP Keep-alive 옵션은 데드 커넥션을 제거하는 옵션이다. 클라이언트에서 서버로 연결 종료(FIN) 요청을 보내지 않고 자체적으로 커넥션을 제거한다면 서버에선 이를 알 방법이 없기 때문에 서버에선 죽은 커넥션을 계속 들고있는 문제가 발생한다. Keep-alive 옵션으로 데드 커넥션을 제거할 수 있다.

웹서버 Keep-alive

웹 서버는 자체적으로 관리하는 keep-alive 옵션을 이용한다. 이 경우에는 TCP keep-alive의 속성을 무시한다.

Apache 서버와 같은 요청당 하나의 스레드를 할당하는 블로킹 구조에서는 맺어진 소켓별로 서버의 max_threads(max_clients)가 늘어난다. (nginx와 같은 논블로킹 구조에서는 소켓별로 늘어나지 않고, 이것이 대용량 트래픽에서 nginx가 유리한 이유임)

keepalive_time = 10s
keepalive_max = 10
  • keepalive_time : 연결을 10초 동안 유지
  • keepalive_max : 연결은 최대 10번의 요청이 가능

HTTP는 웹 서버 통신이기 때문에 TCP가 아닌 웹 서버의 keep-alive 속성을 따른다.

HTTP는 비연결성 통신으로, 연결을 지속하기 위해 keep-alive 헤더를 이용한다.

HTTP 1.0 에서는 헤더에 keep-alive 옵션을 추가해줘야 동작하고, HTTP 1.1 부터는 keep-alive가 기본 값으로 설정되어 있다.

헤더에 Keep-alive 를 넣는다고 무조건 동작하는 것은 아니고 웹 서버에서도 keep-alive 설정이 켜져 있어야 한다. 웹 서버에 keep-alive 옵션이 켜져있지 않다면, 이 헤더의 옵션은 사용할 수 없다.

Half-Closed Connection

서버측에서 커넥션을 종료했는데, 클라이언트에서 해당 커넥션을 제거하지 않은 상황이라면 클라이언트가 이 커넥션을 다시 이용하려고 시도하는 경우 문제가 될 수 있다. 이를 Half-Closed Connection이라고 한다.

이 문제를 해결하기 위해서 클라이언트에서 서버에 설정된 keepalive_time보다 짧게 connection의 evict time(life time)을 설정해주어야 한다.

-> 커넥션을 모니터링 스레드로 주기적으로 관리 해줘야하는 이유

서버 별로 타임아웃을 어떻게 설정해야 할까?

서버에 연결을 유지시키는 옵션이 keep-alive 이다. 그렇다면, 여러대로 구성된 백엔드 서버간 타임아웃은 어떻게 설정해야 좋을까?

정상적으로 연결을 끊는다는 것은 클라이언트가 통신을 종료하는 Acive Close가 되어야 한다.
즉, 연결이 끊어야 하는 상황이 발생한다면(처리 지연) 가급적이면 아키텍처 상 앞쪽에서 연결을 먼저 끊을 수 있도록 타임 아웃 설정을 하는 것이 적절하다.

회고

뇌피셜 주의

필자의 환경은 짧은 시간 응답을 주고 받아야 광고 서비스를 운영하는 것이었고, e2e '30ms 이하'로 응답해야하기 때문에 서버간 타임아웃 설정이 굉장히 짧게(5ms) 되어 있었다.

공부하면서 드는 생각이 '서버에 설정된 너무 짧은 타임아웃 설정이 과연 적절했는가?' 이다. 내부 서버간 짧은 타임아웃 옵션(5ms)을 적절하게 늘려 주는 것이 성능에 더 유리하지 않았을까? (그냥 저의 뇌피셜입니다)

이 옵션은 DevOps 팀이 설정한 옵션인데, 재직 당시에는 '우리 서비스는 짧은 응답을 줘야 하는 거니까 당연히 타임아웃이 짧아야겠지?' 라고 생각하여 딱히, 이 설정에 대한 의문이 없었다.

그러나 네트워크 자원을 모니터링하면서 뭔가 풀리지 않는 의문이 있었는데, 과부하 상황임에도 활성화 된 커넥션이 커넥션 풀 설정의 1/10 도 못 채우는 것이었다. 분명 CPU나 메모리 자원은 넘쳐났었고 네트워크 자원이 부족했다. 짧은 시간 여러건의 메시지를 처리 해야했으므로 연결 소켓 비용을 최적화 하는 것이 중요했다. 그러므로, 과부하 상황에서 설정한 커넥션 풀을 거의 가득 채운 지표를 예상했는데 (그래야 커넥션 풀이 부족하다고 판단하고 늘려줌으로써 연결에 대한 비용을 최적화할 수 있는데) 전혀 근거를 찾을 수 없었다.

지금 생각해보면 원인은 뒷쪽 백엔드 서버(서버측)의 너무 짧은 타임아웃 옵션의 영향이 아니었을까 생각된다. 특히 TCP연결 설정은 길게 했어야 하지 않았을까? 아마도, 서버 측에서 계속 빠르게 연결을 끊어버려서 클라이언트 측은 연결을 유지시킬 수 없었고(Passive Close) 이것을 이유로 커넥션 풀을 효율적으로 사용하지 못했을 것 같다.

따라서, '내부 서버간 연결 타임아웃을 적절히 길게 설정' 한다면 연결을 오래 지속할 수 있고, connection의 제어권이 클라이언트에게 있는 정상적인 Active Close 상황이 잘 벌어질 것이고 이렇게 해야 Connection Pool을 더 효율적으로 사용할 수 있지 않았을 까?

혹시 나중에 부하테스트를 진행할 환경이 갖춰진다면 꼭 한번 실험해보고 싶다.

Reference

profile
내가 몰라서 적는 글 🐣

0개의 댓글