Java & Spring

[JMeter] org.apache.http.NoHttpResponseException

ju_young 2024. 7. 2. 13:28
728x90

문제

JMeter를 사용하여 테스트 중 NoHttpResponseException가 발생했다.

org.apache.http.NoHttpResponseException: localhost:80 failed to respond
    at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:141)
    at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:56)
    at org.apache.http.impl.io.AbstractMessageParser.parse(AbstractMessageParser.java:259)
    at org.apache.http.impl.DefaultBHttpClientConnection.receiveResponseHeader(DefaultBHttpClientConnection.java:163)
    at org.apache.http.impl.conn.CPoolProxy.receiveResponseHeader(CPoolProxy.java:157)
    at org.apache.http.protocol.HttpRequestExecutor.doReceiveResponse(HttpRequestExecutor.java:273)
    at org.apache.http.protocol.HttpRequestExecutor.execute(HttpRequestExecutor.java:125)
    at org.apache.http.impl.execchain.MainClientExec.execute(MainClientExec.java:272)
    at org.apache.http.impl.execchain.ProtocolExec.execute(ProtocolExec.java:186)
    at org.apache.http.impl.execchain.RetryExec.execute(RetryExec.java:89)
    at org.apache.http.impl.execchain.RedirectExec.execute(RedirectExec.java:110)
    at org.apache.http.impl.client.InternalHttpClient.doExecute(InternalHttpClient.java:185)
    at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:83)
    at org.apache.jmeter.protocol.http.sampler.HTTPHC4Impl.executeRequest(HTTPHC4Impl.java:940)
    at org.apache.jmeter.protocol.http.sampler.HTTPHC4Impl.sample(HTTPHC4Impl.java:651)
    at org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy.sample(HTTPSamplerProxy.java:66)
    at org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.sample(HTTPSamplerBase.java:1311)
    at org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.sample(HTTPSamplerBase.java:1300)
    at org.apache.jmeter.threads.JMeterThread.doSampling(JMeterThread.java:651)
    at org.apache.jmeter.threads.JMeterThread.executeSamplePackage(JMeterThread.java:570)
    at org.apache.jmeter.threads.JMeterThread.processSampler(JMeterThread.java:501)
    at org.apache.jmeter.threads.JMeterThread.run(JMeterThread.java:268)
    at java.base/java.lang.Thread.run(Thread.java:840)

 

M1 Mac에서 테스트를 수행했으며 Thread Group은 다음과 같이 설정했다.

 

5000번 요청하는데 그 중 2.15%에서 에러가 발생했다.

원인

원인을 찾아보았을 때 HTTP/1.1 의 Keep-Alive 때문이라는 내용을 볼 수 있었다. 서버에서 통신이 완료되어 연결을 Close하더라도 Client에서는 여전히 Open된 상태인 상황이 될 수 있는데, 이를 half-closed connection이라 표현한다고 한다. 이런 상황이 되면 JVM상 Connection은 살아있지만 내부 소켓은 CLOSE_WAIT 상태가 된다. 그리고 CLOSE-WAIT 상태가된 Connection을 다시 사용하려고 할 때 서버에서 이미 연결을 Close 해버렸기 때문에 NoHttpResponseException이 발생하는 것이라고 한다.

해결 시도

Nginx

현재 WebSocket과 SSE 통신을 위해 Nginx에 HTTP/1.1과 keep-alive가 적용된 상황이다. 그리고 지속 연결이 필요없는 요청도 keep-alive가 적용되어있다.

 

위에서 알아본 원인을 통해 keep-alive를 유지할 필요가 없는 요청을 따로 분리하여 timeout을 짧게 설정했다. (편의를 위해 다른 부분은 생략한다.)

upstream backend {  
    server host.docker.internal:8080;  
    keepalive_timeout 0;  
}  

location ~ /(api|imgs) {  
    ...
    proxy_pass http://backend/$1$is_args$args;  
    proxy_set_header Connection '';  
    proxy_http_version 1.1;  
    ...
}

하지만 결과는 달라지지 않았다. 에러가 더 많이 발생하거나 비슷한 비중으로 발생했다.

 

그럼에도 불필요한 keep-alive 연결을 다시 설정할 수 있는 기회가 되어서 의미있는 작업이라고 생각한다.

properties

jmeter의 properties 설정을 해주면 해결이 된다는 글을 보고 다음과 같이 파일을 수정했다. (각 파일에 추가/수정 해주면 된다.)

  • bin/hc.parameters
    • HttpClient는 stale check를 해줌으로써 서버 쪽에서 닫혀서 더이상 유효하지 않은 connection을 확인한다고 한다. 하지만 100% 신뢰성이 있지 않기 때문에 eviction policy를 적용하여 명시적으로 expired connection을 모두 닫아버리는 솔루션을 제안한다.
http.connection.stalecheck$Boolean=true
  • bin/user.properties
    • retrycount를 1로 설정해주면 1번 재시도해준다.
hc.parameters.file=hc.parameters
httpclient4.retrycount=1

 

이 설정 또한 결과에 큰 차이가 없다. 에러 비중이 1.96%가 확인되었지만 거의 같은 수준이다.

 

이후 retry는 테스트를 수행하는데 적당해보이지 않아 삭제했다.

number of threads

number of thread를 줄이면서 테스트해보았을 때 3,500에서 에러 없이 모두 정상적으로 돌아가는 것을 확인할 수 있었다. 즉, 동시 요청은 최대 약 3,500건이 정상적으로 처리된다는 사실을 알게되었다. (조금씩 조정한다면 더 많은 요청을 처리할 수 있겠지만 불필요한 작업이라 생각한다.)

ramp-up period

Wireshark로 확인해보았을 때 아래처럼 4-way handshake가 약 1.1 ~ 1.2ms 정도 걸리는 것을 볼 수 있다.

이 시간에 맞추어서 요청 당 실행 간격을 ramp-up period로 조정해보기로 했다. 현재 설정은 1이므로 요청당 1/4000(0.25ms) 간격으로 수행되며, 이를 2로 늘리고 다시 number of threads 4000으로 설정하고 테스트했다. 그러면 0.5ms 간격으로 수행되길 기대해야한다.

여러번 테스트해보았을때 가끔 에러가 발생하지만 대부분 위처럼 정상적으로 모든 요청이 완료된 모습을 확인할 수 있었다.

 

4000으로 설정해서 그런지 ramp-up period를 1로 설정해도 에러가 0.5%미만으로 적게 나오는 것을 확인할 수 있었다.

Connection eviction policy

앞서 apache httpcomponents 문서에 따르면 stale check로는 유효하지 않은 connection을 확인하는 것에 대해 100% 신뢰할 수 없다고 했다. 그래서 Connecion eviction policy를 제안했는데 이 방법을 적용해보려고 한다.

 

우선 dependency를 추가해준다.

implementation 'org.apache.httpcomponents:httpclient:4.5.13'

 

이후 다음과 같이 빈을 추가해주면 된다.

@Bean  
public PoolingHttpClientConnectionManager poolingHttpClientConnectionManager() {  
    PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager();  
    // 허용 최대 connection 수
    poolingHttpClientConnectionManager.setMaxTotal(200);  
    // 라우팅 경로에 대한 최대 connection 수
    poolingHttpClientConnectionManager.setDefaultMaxPerRoute(20);  
    return poolingHttpClientConnectionManager;  
}  

@Bean  
public Runnable idleConnectionMonitor(PoolingHttpClientConnectionManager connectionManager) {  
    return new Runnable() {  
        @Override  
        @Scheduled(fixedDelay = 1000)  
        public void run() {  
            try {  
                if (connectionManager != null) {  
                    // 만료된 모든 connection close
                    connectionManager.closeExpiredConnections(); 
                    // 주어진 시간(1000ms)이 지난 idle connection close 
                    connectionManager.closeIdleConnections(1000, TimeUnit.MILLISECONDS);  
                } else {  
                    log.trace("run IdleConnectionMonitor - Http Client Connection manager is not initialised");  
                }  
            } catch (Exception e) {  
                log.error("run IdleConnectionMonitor - Exception occurred. msg={}, e={}", e.getMessage(), e);  
            }  
        }  
    };  
}

Schedule을 사용하여 1초 간격으로 실행하도록 했다. 그리고 number of threads=4000, ramp-up periods=1로 설정하고 다시 테스트해보았다.

 

 

에러가 0.03%, 개수로 보면 1개만 실패한 것을 확인할 수 있다. 여러번 테스트해보아도 확실히 개선된 결과가 보인다.

 

하지만 number of threads를 5000으로 늘려버리면 똑같이 Error가 비례해서 늘어나버린다는 사실...

결론

  1. ramp-up period를 증가시켜 에러율을 감소시킬 수 있다.
  2. Connection eviction policy를 적용하여 에러율을 감소시킬 수 있지만 number of threads가 증가함에따라 에러율이 비례해서 증가한다. 적용해도 에러가 발생하는 것은 마찬가지이다.
  3. stale check를 설정해주어도 에러율에 큰 차이를 확인할 수 없었다.

확실한 해결책은 thread가 순차적으로 실행되게 설정하는 것이라고 생각한다.

 


Update

Mac 기준으로 4-way handshake의 클라이언트 측의TIME_WAIT 상태가 지속되는 시간을 다음과 같이 설정해줄 수 있다.

sudo sysctl net.inet.tcp.msl=1

기본 값은 15000(15초)로 설정되어있고, 극단적으로 1으로 바꾸어주었다. 그리고 Jmeter로 number of threads=4000, ramp-up period=1 설정 후 결과 에러가 나오지 않았다.

NOTE
msl = Maximum Segment Lifetime

NOTE
sysctl -a로 모든 값을 확인할 수 있다.

 

[reference]

728x90