어느날 아래와 같은 오류가 발생했습니다. 결론적으로는 Keep Alive 설정에 문제가 있었습니다.
앞으로는 비슷한 오류가 발생하지 않도록 대응했던 과정을 정리해보겠습니다.
2025-04-16T01:58:49.626Z
[ERROR][2.0.1][tp-epoll-2] .s.i.h.a.o.g.XXXXXXClient : invoke Sending mutation failed: error=org.springframework.web.reactive.function.client.WebClientRequestException: recvAddress(..) failed: Connection reset by peer
2025-04-16T02:13:42.896Z
[ERROR][2.0.1][tp-epoll-1] s.i.h.a.s.XXXXXXXService : uploadXXXXXX$lambda$28 Connection has been closed BEFORE response, while sending request body
2025-04-16T02:14:21.254Z
[ERROR][2.0.1][tp-epoll-3] s.i.h.a.s.XXXXXXXService : invoke Failed to complete analysis: analysisJobId=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX XXXXXServerId=XXXXX errorMessage=Retries exhausted: 2/2 metric=
{
"type": "XXXXXXXXXMetric",
"eventType": "FAILED",
"instanceId": "i-XXXXXXXXXXXXXXX",
"autoScalingGroup": "XXX-XXXX-XXXX-XXXX"
}
상황
먼저 대략적인 서버의 구조는 아래와 같은 상황이었습니다. 에러 로그가 발생한 곳은 Server A
입니다.
Client - Server A - Server B
Client는 Server A에게 요청을 보냅니다. Server A는 요청을 받으면 S3로부터 Image를 다운로드 받아서 Server B로 Upload 합니다.
S3로부터 Image를 Streaming 방식으로 다운로드 받은 뒤에 바로 Server B에게 MultiPart Upload를 하는 형태입니다.
분석
에러 로그는 두 가지 종류가 있습니다.
- Connection reset by peer
- Connection has been closed BEFORE response, while sending request body
첫 번째 로그는 'Server A가 사용하고 있는 연결을 Server B가 끊었다'는 뜻입니다. 두 번째 로그는 'Server A가 File Upload를 하고 있는 도중에 연결이 끊어졌다'는 뜻입니다. 두 에러는 사실 같은 원인으로 발생하고 있었습니다. 바로 KeepAlive 설정입니다.
Checking for dead peers를 보면 KeepAlive가 필요한 상황이 잘 나와있습니다. 요역해보면 다음과 같습니다.
- Server A와 Server B가 연결을 맺음
- Server A는 연결된 커넥션을 커넥션 풀에 넣어서 사용함
- 일정 시간이 지난 뒤에 Server B는 연결되었지만 데이터 전송이 없는 커넥션을 종료함
- Server B는 연결을 종료했지만 Server A는 이를 알지 못하는 상황이 발생
- Server A에서 커넥션 풀에서 커넥션을 조회해서 Server B로 데이터 전송을 시도
- Server B는 종료된 연결로부터 데이터가 수신되었기 때문에 RST 패킷을 전송
- Server A는 커넥션 풀에서 커넥션을 조회해서 사용했음에도 RST 패킷을 받아서 연결 종료 (에러 로그 1번)
- 또는 Server A가 데이터를 전송하고 있는 도중에 Server B에서 일정 시간이 지난 뒤에 연결을 종료함 (에러 로그 2번)
_____ _____
| | | |
| A | | B |
|_____| |_____|
^ ^
|--->--->--->-------------- SYN -------------->--->--->---|
|---<---<---<------------ SYN/ACK ------------<---<---<---|
|--->--->--->-------------- ACK -------------->--->--->---|
| |
| system crash ---> X
|
| system restart ---> ^
| |
|--->--->--->-------------- PSH -------------->--->--->---|
|---<---<---<-------------- RST --------------<---<---<---|
| |
원인
위와 같은 문제가 발생한 워인은 Server A에서 TCP KeepAlive 설정을 지정하지 않았기 때문에 발생했습니다. KeepAlive 설정을 지정하지 않으면 KeepAlive가 자동으로 활성화되고 아래와 같은 기본값이 사용됩니다.
$ cat /proc/sys/net/ipv4/tcp_keepalive_time
7200
$ cat /proc/sys/net/ipv4/tcp_keepalive_probes
9
$ cat /proc/sys/net/ipv4/tcp_keepalive_intvl
75
위 설정은 커넥션이 연결되고 tcp_keepalive_time
시간이 지난 뒤부터, tcp_keepalive_intvl
의 간격으로 tcp_keepalive_probes
번 duplicated ACK을 전송하겠다는 뜻입니다. linux의 tcp_keepalive_time의 기본값이 2시간(7200)으로 설정되어 있기 때문에 한 번 연결된 커넥션은 커넥션 풀에 (2시간 + 75초 * 9)동안 살아있습니다.
Server A에서는 2시간이 넘는 시간동안 동일한 커넥션을 유지하고 사용하려고 하는데, 이미 Server B로에는 종료된 커넥션이기 때문에 RST 패킷을 받고 연결이 끊어지는 현상이 발생한 것입니다. (에러 로그 1번) 그리고 이 타이밍이 데이터를 전송하고 있는 순간(markSentBody
)이었다면 에러로그 2번이 발생한 것입니다.
// reactor.netty.http.client.HttpClientOperations#onInboundClose
@Override
protected void onInboundClose() {
...
else if (markSentBody()) {
exception = new PrematureCloseException("Connection has been closed BEFORE response, while sending request body");
...
}
대응
projectreactor http-client document에는 keep alive 설정을 하는 방법이 나와있습니다. NIO transport
와 Epoll transport
중 어떤 것을 사용하는지에 따라서 설정값이 달라진다고 되어있습니다. 저는 에러 로그에 [ERROR][2.0.1][tp-epoll-2]
이라고 나와있어서 epoll을 사용하는 것을 바로 알 수 있었습니다.
public class Application {
public static void main(String[] args) {
HttpClient client =
HttpClient.create()
.bindAddress(() -> new InetSocketAddress("host", 1234))
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
.option(ChannelOption.SO_KEEPALIVE, true)
// The options below are available only when NIO transport (Java 11) is used
// on Mac or Linux (Java does not currently support these extended options on Windows)
// https://bugs.openjdk.java.net/browse/JDK-8194298
//.option(NioChannelOption.of(ExtendedSocketOptions.TCP_KEEPIDLE), 300)
//.option(NioChannelOption.of(ExtendedSocketOptions.TCP_KEEPINTERVAL), 60)
//.option(NioChannelOption.of(ExtendedSocketOptions.TCP_KEEPCOUNT), 8);
// The options below are available only when Epoll transport is used
.option(EpollChannelOption.TCP_KEEPIDLE, 300)
.option(EpollChannelOption.TCP_KEEPINTVL, 60)
.option(EpollChannelOption.TCP_KEEPCNT, 8);
keepalive 설정을 지정하는 방법은 알았으니, 어떤 값으로 지정할지를 결정해야합니다. 그런데 Serer B의 시스템은 외부 시스템이라서 정확한 Timeout을 알 수 없는 상황입니다. 그래서 좀 reference를 찾아보니 IBM TCP/IP settings에서 두 가지 기준을 발견했습니다.
- firewall의 connection timeout보다 작은 값으로 지정해야 한다
- 정확한 값을 모르는 경우 2분으로 지정하고 검증하라
The parameters that control the keep alive frequency vary with each operating system. You must set the keep alive interval value lesser than the connection timeout value of the firewall. If you do not know the value of the firewall setting, set keep alive interval value to 2 minutes and verify.
firewall의 connection timeout보다 작은 값으로 지정해야한다는 점은 Serer B에서 주기적으로 연결을 끊는 시간보다 짧은 시간을 설정해야함을 의미합니다. Server B가 이미 연결을 끊은 뒤에 keepAlive를 체크한다면 매번 RST를 받고 의미가 없게 된다는 뜻입니다. 결론적으로 (2분은 너무 짧은 것 같아서) keepalive의 값은 위 코드에서 제사힌 300, 60, 8 의 값을 그대로 사용해보기로 결정했습니다.
고려사항
keepalive의 값을 지정할 때 다양한 timeout의 시간을 고려해야하는지 따져봤습니다.
- client의 connection timeout : keepavlie는 connection이 연결된 이후에 동작하기 때문에 client의 connection timeout과는 무관합니다.
- HttpClient의 ReadTimeout, WriteTimeout : Server A와 Server B가 주고 받는 데이터가 커서 ReadTimeout, WriteTimeout을 좀 크게 지정한 상황입니다. 하지만 KeepAlive Timeout에는 영향을 주지 않습니다. KeepAlive는 TCP 레벨에서, ReadTimeout/WriteTimeout은 HTTP 레벨에서 동작합니다. 두 Timeout이 겹치더라도 KeepAlive를 ACK만 보내기 때문에 Read/Time 트레픽에 큰 영향을 주지 않을 것입니다.
좀 더 깊게 알아보기
Preventing disconnection due to network inactivity에는 NAT Proxy를 사용하는 환경에서 네트워크 끊김을 방지하기 위한 노하우를 소개합니다. 당장 필요한 상황은 아니지만 한 번 정리해봤습니다.
| | | | | |
| A | | NAT | | B |
|_____| |_____| |_____|
^ ^ ^
|--->--->--->---|----------- SYN ------------->--->--->---|
|---<---<---<---|--------- SYN/ACK -----------<---<---<---|
|--->--->--->---|----------- ACK ------------->--->--->---|
| | |
| | <--- connection deleted from table |
| | |
|--->- PSH ->---| <--- invalid connection |
| | |
위와 같은 상황에서 Server A에서 가지고 있는 연결은 아무 이유도 없이 끊어질 수 있습니다. 이러한 원인은 NAT에서 '모든 연결을 모두 메모리에 관리할 수는 없다'는 현실적인 이유에서 발생합니다. NAT 입장에서 가장 일반적이고 현실적인 접근은 오래된 커넥션은 끊고 가장 최신의 연결만 유지하는 것입니다. 이러한 상황에서 Server A와 Server B 사이에 TCP 연결을 계속 유지해야하는 경우에도 NAT의 정책에 따라서 우선순위가 밀려서 연결이 끊어질 수 있습니다. 이러한 경우에는 A와 B 사이에 일정 간격으로 의미없는 데이터를 지속적으로 주고 받는 방법이 있습니다. 이렇게 되면 지속적으로 커네션이 최신화되고 NAT에서 제외될 가능성이 줄어드는 효과를 가지게 됩니다.
이러한 Heartbeat는 IdleStateHandler를 사용해서 구현할 수 있습니다. IdleStateHandler는 일정 시간동안 데이터가 없으면 IDLE 이벤트를 발생시켜줍니다.
- readerIdleTime 동안 inbound 데이터가 없으면 READER_IDLE 이벤트
- writerIdleTime 동안 outbound 데이터가 없으면 WRITER_IDLE 이벤트
- allIdleTime 동안 어느 쪽도 이벤트가 없으면 ALL_IDLE 이벤트
connection
.addHandlerLast(IdleStateHandler(0, 30, 0, TimeUnit.SECONDS))
.addHandlerLast(object : ChannelDuplexHandler() {
override fun userEventTriggered(ctx: ChannelHandlerContext, evt: Any) {
if (evt is IdleStateEvent && evt.state() == IdleState.WRITER_IDLE) {
// 30초 동안 쓸 데이터가 없으면 의미없는 데이터 전송
ctx.writeAndFlush(Unpooled.wrappedBuffer("\r\n".toByteArray()))
}
}
})
'DEV > OS' 카테고리의 다른 글
Block/File/Object Storage 그리고 Goofys(Fuse) (0) | 2025.04.14 |
---|---|
HDD는 왜 IOPS 기준으로 provisioning이 제공되지 않을까? (0) | 2025.04.14 |
Everything is a File (How a storage device is treated as a file) (0) | 2025.04.13 |
GFS와 RocksDB의 Block Cache 모델 비교 (feat. OS Page/Buffer Cache) (0) | 2025.04.13 |
Possibilities of Performance Bottlenecks for Storage (feat. RocksDB) (0) | 2025.04.12 |