PoolingHttpClientConnectionManager 를 xml기반 Spring bean으로 등록하기.

개발한 서비스 중 commons httpclient 로 서버 <-> 서버 간 rest api 호출하는 부분에  부분에서 간헐적으로 NoHttpResponseException : The target server failed to respond 예외가 발생하기 시작했다. 이를 해결하기위해 자료를 좀 찾아봤더니 HttpClient 4.4에서 존재하던 버그였고 4.4.1에서 해결된 문제( https://issues.apache.org/jira/browse/HTTPCLIENT-1610 )라고 하는데... 개발한 서비스에는 4.5.1을 쓰는데?

현상의 이유는 HTTP/1.1의 Keep-Alive로 인해 httpclient는 통신이 끝난 connection을 종료하지 않고 동일host:port에 대해 동일한 커넥션을 이용하려하기 때문이다.

비록 서버측은 통신이 완료되어 해당 연결을 close 할지라도 client 측은 커넥션 객체가 여전히 열여있고 데이터가 인입되길 기다리고 있게된다.  ( close()의 실제 의미는 소켓의 단절이 아닌 "나는 더 이상 보낼 데이터가 없습니다."로 상대측에서는 해당 커넥션을 단절하지 않는 이상 여전히 데이터가 인입될 수 있음을 의미한다. )
이때를 half-closed connection 으로 표현하며 이는 TCP가 그렇게 동작하게끔 설계되었기 때문으로 버그가 아니다. 이런 상황이 되면 JVM상의 connection 객체는 당연히 살아있지만 내부 소켓은 CLOSE_WAIT 상태가 된다.

문제는 httpclient가 이 CLOSE_WAIT 상태에있는 connection 객체를 다시 사용하려고 할때 앞에서 설명한것과 같이 서버 측은 이미 연결을 끊어버렸기 때문에 NoHttpResponseException - The target server failed to respond 예외를 발생한다.
이를 해결하기위해서는 httpclient의 connectionManager에서 통신이 완료된 connection을 적절하게 제거할 필요가 있다.

Spring 4.X 환경에서 commons HttpClient 4.5.x 를 소켓 설정과 KeepAlive 설정등을 포함하여 xml로 설정하는 방법.

PoolingHttpClientConnectionManager를 통하여 CloseableHttpClient 를 사용하는 과정에 ConnectionManager의 closeIdleConnections 설정 통해 특정시간 idle인 커넥션을 종료하고 싶은 경우 java 코드가 아닌 spring xml 설정정으로 bean을 등록하려 할 때. 

사용은 당연히
@Autowired
private CloseableHttpClient httpClient;

[code]
<!-- ===================================================================== -->
<!-- =======================   HttpClient 4.5.X   ======================== -->
<!-- ===================================================================== -->        
<bean id="requestConfigBuilder" class="org.apache.http.client.config.RequestConfig" factory-method="custom">
        <property name="socketTimeout" value="10000" /> 
        <property name="connectTimeout" value="12000" /> 
        <property name="connectionRequestTimeout" value="12000" />
</bean>

<bean id="requestConfig" factory-bean="requestConfigBuilder" factory-method="build" />

<bean id="socketConfigBuilder" class="org.apache.http.config.SocketConfig" factory-method="custom">
    <!-- 소켓이 연결된후 InputStream에서 읽을때 timeout -->
    <property name="soTimeout" value="10000" /> 
    <!-- SO_KEEPALIVE를 활성화 할 경우 소켓 내부적으로 일정시간 간격으로 heartbeat을 전송하여, 비정상적인 세션 종료에 대해 감지.
    unix 계열 : /etc/sysctl.conf
    windows : \HKEY_LOCAL_MACHINE\SystemCurrentControlSet\Services\TCPIP\Parameters
    -->
    <property name="soKeepAlive" value="true" /> 
    <!-- 비정상종료된 상태에서 아직 커널이 소켓의 bind정보를 유지하고 있을 때 해당 소켓을 재사용 할 수 있도록 -->
    <property name="soReuseAddress" value="true" /> 
    <!-- nagle 알고리즘 적용 여부 -->
    <property name="tcpNoDelay" value="true" /> 
    <!-- socket이 close 될 때 버퍼에 남아 있는 데이터를 보내는데 기다려주는 시간(blocked)-->
    <property name="soLinger" value="100" /> 
</bean>

<bean id="poolingHttpClientConnectionManager" class="org.apache.http.impl.conn.PoolingHttpClientConnectionManager" destroy-method="shutdown">
    <constructor-arg value="2000" type="long" index="0" /> <!-- pool에 있는 커넥션 제거 idle time -->
    <constructor-arg value="MILLISECONDS" type="java.util.concurrent.TimeUnit" index="1" />
    <property name="maxTotal" value="60" />
    <property name="defaultMaxPerRoute" value="15" />
    <property name="defaultSocketConfig"><bean factory-bean="socketConfigBuilder" factory-method="build" /></property>
</bean>

<bean id="connectionKeepAliveStrategy" class="com.http.client.HttpShortKeepAliveStrategy" />

<bean id="httpClientBuilder" class="org.apache.http.impl.client.HttpClientBuilder" factory-method="create">            
    <property name="defaultRequestConfig" ref="requestConfig" />
    <property name="connectionManager" ref="poolingHttpClientConnectionManager" />
    <property name="userAgent" value="Mozilla/5.0 (Windows NT 6.1; WOW64) CUSTOM-CLIENT" />
    <property name="keepAliveStrategy" ref="connectionKeepAliveStrategy" />
</bean>

<bean id="httpClient" factory-bean="httpClientBuilder" factory-method="build" destroy-method="close" />
[/code]

HttpShortKeepAliveStrategy 클래스는...
[code]
package com.http.client;

import org.apache.http.HeaderElement;
import org.apache.http.HeaderElementIterator;
import org.apache.http.HttpResponse;
import org.apache.http.conn.ConnectionKeepAliveStrategy;
import org.apache.http.message.BasicHeaderElementIterator;
import org.apache.http.protocol.HTTP;
import org.apache.http.protocol.HttpContext;

/**
 * HttpShortKeepAliveStrategy (UTF-8) created : 2016. 5. 25
 *

 */
public class HttpShortKeepAliveStrategy implements ConnectionKeepAliveStrategy {

    /**
     *
     * @param response
     * @param context
     * @return
     */
    @Override
    public long getKeepAliveDuration(HttpResponse response, HttpContext context) {
        // Honor 'keep-alive' header
        HeaderElementIterator it = new BasicHeaderElementIterator(
                response.headerIterator(HTTP.CONN_KEEP_ALIVE));
        while (it.hasNext()) {
            HeaderElement he = it.nextElement();
            String param = he.getName();
            String value = he.getValue();
            if (value != null && param.equalsIgnoreCase("timeout")) {
                try {
                    return Long.parseLong(value) * 100;
                } catch (NumberFormatException ignore) {
                }
            }
        }


        HttpHost target = (HttpHost) context.getAttribute(HttpClientContext.HTTP_TARGET_HOST);
        if ("www.mydomain.com".equalsIgnoreCase(target.getHostName())) {
            // Keep alive for 5 seconds only
            return 5 * 1000;
        } else {
            // otherwise keep alive for 1 seconds
            return 1 * 1000;
        }
    }

}
[/code]

코드로 표현하면 대강 다음과 같음.
[code]
static final CloseableHttpClient httpClient;
static {
        PoolingHttpClientConnectionManager pooledManager = new PoolingHttpClientConnectionManager(20L,java.util.concurrent.TimeUnit.MILLISECONDS);
        pooledManager.setMaxTotal(15);
        pooledManager.setDefaultMaxPerRoute(5);
        pooledManager.closeIdleConnections(20L, TimeUnit.MILLISECONDS);
        pooledManager.setDefaultSocketConfig(SocketConfig.custom()
                // nagle 알고리즘 적용 여부
                .setTcpNoDelay(true)
                // SO_KEEPALIVE를 활성화 할 경우 소켓 내부적으로 일정시간 간격으로 heartbeat을 전송하여, 비정상적인 세션 종료에 대해 감지.
                // unix 계열 : /etc/sysctl.conf
                // windows : \HKEY_LOCAL_MACHINE\SystemCurrentControlSet\Services\TCPIP\Parameters
                .setSoKeepAlive(true)
                // socket이 close 될 때 버퍼에 남아 있는 데이터를 보내는데 기다려주는 시간(blocked)
                .setSoLinger(200)
                // 비정상종료된 상태에서 아직 커널이 소켓의 bind정보를 유지하고 있을 때 해당 소켓을 재사용 할 수 있도록
                .setSoReuseAddress(true)
                //소켓이 연결된후 InputStream에서 읽을때 timeout
                .setSoTimeout(10000)
                .build()
        );
        httpClient = HttpClients.custom()
                        .setConnectionManager(pooledManager)
                        .setUserAgent("Mozilla/5.0 (Windows NT 6.1; WOW64) API-CLIENT")
                        .setRedirectStrategy(new DefaultRedirectStrategy() {
                                @Override
                                public boolean isRedirected(HttpRequest request, HttpResponse response, HttpContext context) {
                                        boolean isRedirect = false;
                                        try {
                                                isRedirect = super.isRedirected(request, response, context);
                                        } catch (ProtocolException e) {
                                                logger.error(null, e);
                                        }
                                        if (!isRedirect) {
                                                int responseCode = response.getStatusLine().getStatusCode();
                                                if (responseCode == 301 || responseCode == 302) {
                                                        return true;
                                                }
                                        }
                                        return false;
                                }
                        })
                        .setKeepAliveStrategy(new ConnectionKeepAliveStrategy() {
                                @Override
                                public long getKeepAliveDuration(HttpResponse response, HttpContext context) {
                                        // Honor 'keep-alive' header
                                        HeaderElementIterator it = new BasicHeaderElementIterator(
                                                        response.headerIterator(HTTP.CONN_KEEP_ALIVE));
                                        while (it.hasNext()) {
                                                HeaderElement he = it.nextElement();
                                                String param = he.getName();
                                                String value = he.getValue();
                                                if (value != null && param.equalsIgnoreCase("timeout")) {
                                                        try {
                                                                return Long.parseLong(value) * 100;
                                                        } catch (NumberFormatException ignore) {
                                                        }
                                                }
                                        }
                                        HttpHost target = (HttpHost) context.getAttribute(
                                                        HttpClientContext.HTTP_TARGET_HOST);
                                        if ("www.mydomain.com".equalsIgnoreCase(target.getHostName())) {
                                                // Keep alive for 5 seconds only
                                                return 5 * 1000;
                                        } else {
                                                // otherwise keep alive for 0.1 seconds
                                                return 1 * 100;
                                        }
                                }
                        })
                        .build();
}
[/code]

2016/05/25 15:28 2016/05/25 15:28
Trackback Address:이 글에는 트랙백을 보낼 수 없습니다
  1. Blog Icon
    개발 김

    해당 글 너무나도 잘 봤습니다!! ㅎㅎ 해결하신 방법은 일정한 타임 아웃을 주고 해당 시간동안 아무런 반응이 없으면 해당 커넥션을 없애는 방식인데 그렇다면 사용자가 잠깐 아무 입력없이 가만히 있게 되면 커넥션이 죽게 될 텐데 이 부분에 대해서는 어떻게 생각하시는지요???

  2. Blog Icon
    서비

    개발 김님 안녕하세요.
    질문 내용은 일반적인 연결 지향인 소켓 상황과 헷갈리신것 같습니다.
    http 역시 소켓에 기반하고있습니다만 아무입력없이 대기 시 커넥션이 죽는
    현상은 일반적인 http 에서는 해당되지 않는 사항입니다.
    http 프로토롤 자체가 필요 시 연결, 데이터 송수신하고 연결을 끊는 컨셉이기 때문입니다.
    keep-alive 는 비교적 짧은 리드타임으로 송수신을 반복할 경우 접속 오버헤드를 줄이고자하는 기법의 하나입니다.

  3. Blog Icon
    개발 김

    너무 감사합니다~ 최근 이부분을 좀 관심있게 보고 있는 중이여서요!!
    HttpShortKeepAliveStrategy 클래스에서
    위에 while문이 어떤 부분을 하는 건가요??
    밑에는 해당 도메인이면 keep alive 시간을 조금 더 주는 건가요??
    직접 이 에러를 발생시켜보고 싶은데 잘 안돼네요 일부러 버전도 4.3.6 쓰는데 close_wait인 커넥션은 자기가 알아서 피해가네요;;

  4. Blog Icon
    서비

    @개발 김님
    링크의 기술문서가 도움이 되지 않을까해서 소개 드립니다.
    http://tech.kakao.com/2016/04/21/closewait-timewait/