본문 바로가기
Spring

Webclient Timeout 과 connection pool 전략

by ybs 2021. 6. 11.
반응형

1) Webclient timeout 

아래 코드를 보자. 다양한 timeout 옵션들이 있다.

new ReactorClientHttpConnector(
	reactorResourceFactory,
	httpClient -> httpClient
		.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
		.doOnConnected(connection ->
			connection.addHandlerLast(new ReadTimeoutHandler(5)
			).addHandlerLast(new WriteTimeoutHandler(5))
		).responseTimeout(Duration.ofSeconds(5)) // 0.9.11 부터 추가
);

 

 

ChannelOption.CONNECT_TIMEOUT_MILLIS 는 서버와 커넥션 맺는데 기다리는 시간이다. 이건 http client level 이다.

responseTimeout 은 순수 http 요청/응답 시간에 대한 timeout 이다. 이게 무슨말인지는 밑에서 계속 설명하겠다.

 

ReadTimeoutHandler/WriteTimeoutHandler 를 responseTimeout 으로 쓰기엔 부족하다(responseTimeout 는 0.9.11 부터 추가됐기 때문에 이전에는 다른 timeout 를 써야했다). 특히 TCP level에서 적용되므로 TLS handshake 동안에도 적용된다. 그래서 관련된 암호화 작업으로 인해 순수 HTTP 응답보다 오래 걸릴 수 있다. 따라서 HTTP 응답보다 timeout을 높게 설정해야한다.

 

심지어 HTTP request가 진행되지 않는 경우에도 ReadTimeoutHandler/WriteTimeoutHandler 핸들러가 작동된다. 예를들어 핸들러는 커넥션풀에 있는 커넥션을 닫을 수 있다(나중에 다른 요청에 의해 순식간에 사용될 수 있을지라도).

 

즉 ReadTimeoutHandler 은 HTTP 와 관련이 없다. 이거는 다른 read 작업들 사이에 시간을 체크하는 표준 네티 핸들러다.

 

reactive stream 의 timeout() 메서드도 responseTimeout 역할로 사용하기엔 부족하다. 클라이언트가 응답을 받는 시간 뿐만 아니라, 커넥션 풀로부터 커넥션을 얻고 새로운 연결을 생성하는 작업들도 reactive stream 에서 수행한다(TLS handshake 과정도 포함).

그러므로 responseTimeout 은 connection timeout + 커넥션풀에서 커넥션 얻는 시간 보다 무조건 커야한다.

 

response timeout 은 idle 커넥션을 닫거나 커넥션을 맺거나 하는 시간을 고려하지 않은 순수 http 요청/응답 시간을 timeout 으로 제한한다.

 

위 글에 대한 번역 출처는 여기에 있다. reactor-netty 메인테이너가 직접 얘기한 내용들이다.

2) Webclient connection pool

ConnectionProvider provider = ConnectionProvider.builder("ybs-pool")
	.maxConnections(500)
	.pendingAcquireTimeout(Duration.ofMillis(0))
	.pendingAcquireMaxCount(-1)
	.maxIdleTime(Duration.ofMillis(8000L))
	.maxLifeTime(Duration.ofMillis(8000L))
	.build();

maxLifeTime : 커넥션 풀에서 살아있을 수 있는 커넥션의 최대 수명시간
maxIdleTime : 커넥션 풀에서 idle 상태의 커넥션을 유지하는 시간
pendingAcquireMaxCount : 커넥션 풀에서 커넥션을 얻기위해 기다리는 최대 시간(-1 넣으면 제한시간없음)

 

타겟 톰캣 서버 connectionTimeout, keepAliveTimeout 설정에 따라서 connection close 원인이 될 수 있다.

 

그래서 톰캣 keepAliveTimeout 을 염두하고 클라이언트 측에서 maxIdleTime을 설정하는 것이 좋다.

다시말해 keepAliveTimeout 보다 maxIdleTime 이 작은게 좋다. 그렇지 않으면 Reactor Netty는 '풀에서 커넥션을 얻고, 실제 요청을 보내기 전' 이 사이에 언제든지 close 이벤트를 받을 수 있다.

 

이 문제를 해결하기 위해 항상 가장 최근에 사용한 connection 을 사용하는 LIFO 전략을 사용할 수 있다. https://github.com/reactor/reactor-netty/issues/1092#issuecomment-648651826

 

많은 이슈들이 timeout 때문에 커넥션이 닫혀서 야기된다. 이것 때문에 pool의 해제 전략을 switching 할 수 있게 추가했는데(0.9.5부터) FIFO 가 디폴트고 LIFO 가 추가됐다. cf) FIFO는 first in first out 이다. 흔한 예로 큐가 있다. LIFO는 last in first out 으로 스택이 있다.

 

LIFO + max idle timeout 은 아래와 같이 동작한다.

  1. 커넥션이 얻어졌을 때 가장 최근것을 사용한다.
  2. 만약 max idle timeout 시간에 달했다면, 이 커넥션이 닫힐것이고 이 커넥션은 가장 최근에 사용됐기 때문에 풀에서 다른 나머지(active가 아닌) 들이 또한 닫힐것을 의미한다. 새로운 커넥션이 생성되고 요청에 사용된다.
  3. 만약 커넥션이 remote peer 에 의해서 닫혔다면(acquire 와 실제 사용 사이에) connection reset by peer 가 보내질것이고 우리는 요청을 retry 할 것이다. 이 커넥션이 가장 최근에 사용한거고 remote peer에 의해 닫혔기 때문에 풀에서 모든 나머지 (active가 아닌) 들도 또한 닫힐거고 그러므로 두번째 시도를 위해서 새로운 커넥션이 생성되고 요청에 사용된다.

fifo 는 항상 가장 오래된 것을 취한다. 따라서 가장 오래된 항목이 max idle time 에 도달했다고 해서 다음 커넥션이 max idle time 에 도달한다는 의미는 아니다.

3) reactor.netty.http.client.PrematureCloseException: Connection prematurely closed BEFORE response

PrematureCloseException 가 발생한 이슈 사례가 하나 있다.

A서버(3초마다 polling 요청) -> B서버(webClient로 요청) -> C서버(nginx keepalive 3초 타임아웃 설정)

위와 같은 구조에서 B -> C 요청이 3초에서 살짝 길어지면 C서버 timeout 으로 끊어지고(RST 보냄),  PrematureCloseException 에러가 발생한다. 이 문제는 reactor.netty.http.client.HttpClient keepAlive 설정을 false로 바꿔서 해결했다.

 

PrematureCloseException 은 TCP RST flag 때문에 발생한다. TCP RST flag 에 대한 자세한 내용은 다음과 같다.

RST : The Reset flag indicates that the connection should be rest, and must be sent if a segment is received which is apparently not for the current connection. On receipt of a segment with the RST bit set, the receiving station will immediately aobrt the connection. Where a connection is aborted, all data in transit is considered lost, and all buffers allocated to that connection are released.
Reset flag는 connection이 reset되야 함을 나타내며, segment가 보기에 현재 connection을 위한게 아닌걸 받았을 때 보내져야 한다. RST 비트가 설정된 segment를 수신하면 즉시 연결을 중단한다. 연결이 중단된 경우, 전송 중인 모든 데이터는 손실된 것으로 간주되며 해당 연결에 할당된 모든 버퍼가 해제된다.

 

다양한 RST 재설성 세그먼트 발생 시나리오가 존재한다. 즉 PrematureCloseException 원인은 다양하다.

 

[시나리오1 : 중복으로 SYN을 보냈을 때]

TCP A가 시퀸스 번호 200으로 SYN을 보냈는데 세그먼트 전송이 지연된다.
TCP B는 지연된 SYN 세그먼트(시퀀스 번호 90)를 받았다(line 3). 
TCP B는 처음 수신된 SYN이므로 ACK를 전송하고, 초기 시퀀스 번호 500을 사용할 것임을 나타낸다.
그러나 TCP A는 TCP B에서 보낸 ACK 필드가 올바르지 않다는 것을 감지하고 SYN 세그먼트가 목적지에 도달하지 못했다고 생각해서 Reset을 전송하여 세그먼트를 거부한다(line 5). 
TCP A는 세그먼트를 믿을 수있게 만들기 위해이 재설정 세그먼트에서 91이라는 시퀀스 번호를 사용한다. 
TCP B는 Reset 플래그가 설정된 세그먼트를 받고 LISTEN 상태로 다시 들어간다.
TCP B는 자신의 새 시퀀스 번호를 사용하여 정상적인 방식으로 ACK로 응답한다.
TCP A는 ISN(초기 시퀀스 번호)과 연결이 설정되었음을 확인한다.

 

Connection Establishment를 논의할 때, 두개의 프로세스간 정상적으로 communication할 때와 한쪽이 crash날 때 둘다 봐야한다.

 

[시나리오2 : 한쪽이 crash나고 다시 가동될 때, 에러 복구 메커니즘]

TCP A 와 TCP B가 정상적으로 동작하다가 TCP B가 segment를 보냈는데 TCP A에서 crash가 발생했다. 
TCP A는 close하고 다시 three way handshake를 위한 SYN을 보낸다. 
TCP B는 자신이 synchronized 되어있다고 생각한다(잘 연결되어 있다고 생각). 
따라서 SYN 세그먼트를 수신하면 시퀀스 번호를 확인하고 문제가 생겼다는걸 알 수 있다.(line3) 
TCP B는 시퀀스 번호 150을 기대한다는 ACK를 다시 전송한다(line4). 
TCP A는 수신 된 세그먼트가 자신이 보낸거에 맞지 않다고 생각하고(자신은 three way handshake 첫 과정인 SYN을 보냈음) 
Reset 세그먼트를 보낸다(line5). 
TCP B는 중단되고 close된다. 
TCP A는 이제 다시 three way handshake(line 7)로 연결을 시도 할 수 있다. 

 

[시나리오3]

TCP A가 crash났을 때 TCP A의 이벤트를 인식하지 못한 TCP B는 데이터를 포함하는 세그먼트를 전송한다.

이 세그먼트를 수신하면 TCP A는 Reset을 전송한다(그러한 연결이 존재하는지 모르기 때문에).

 

[시나리오4]

위 케이스는 둘 다 LISTEN 상태에서 시작한다. 이 때 위에서 봤던 중복 SYN 문제가 발생하고, TCP A는 Reset을 보낸다.

TCP B는 다시 LISTEN 상태로 돌아간다.

 

 

 

 

출처 : TCP/IP The Ultimate Protocol Guide

출처 : effective tcp/ip programming

 

반응형