Search

TIME_WAIT 소켓이 서비스에 미치는 영향

Date
2021/05/06
Tags
tags

TCP 통신 과정

3-way handshake (Connection Establishment)

1.
클라이언트는 서버로 통신을 시작하겠다는 SYN을 보낸다.
2.
서버는 그에 대한 응답으로 SYN+ACK을 보낸다.
3.
마지막으로 클라이언트는 서버로부터 받은 패킷에 대한 응답으로 ACK을 보낸다.

4-way handshake (Connection Termination)

1.
연결을 끊는 쪽에서 먼저 FIN을 보낸다. (위의 그림에서는 클라이언트가 연결을 끊었다.)
2.
상대방은 ACK을 보낸다.
3.
상대방은 소켓을 정리한 다음에 마지막 FIN을 보낸다.
4.
연결을 끊는 쪽에서는 확인했다는 ACK를 보내고 TIME_WAIT 상태에 들어간다.

TIME_WAIT 소켓

먼저 연결을 끊는 쪽을 active closer, 그 반대를 passive closer라고 한다.
TIME_WAIT 소켓은 active closer에서 생성된다.
따라서 서버, 클라이언트 양쪽 모두 TIME_WAIT 소켓이 생성될 수 있다.
TIME_WAIT 소켓 확인 방법
> netstat -napo | grep -i time_wait tcp 0 0 ${ip}:${port} ${ip}:${port} TIME_WAIT - timewait (57.20/0/0) tcp 0 0 ${ip}:${port} ${ip}:${port} TIME_WAIT - timewait (8.79/0/0)
Bash
복사

TIME_WAIT의 문제점

1. 로컬 포트 고갈에 따른 어플리케이션 타임아웃 발생한다.

리눅스에는 net.ipv4.ip_local_port_range라는 커널 파라미터가 존재한다.
커널은 프로세스가 외부와 통신하기 위해 소켓을 생성할 때 해당 소켓이 사용할 로컬 포트를 net.ipv4.ip_local_port_range에 정의된 값 중 하나를 할당한다.
이 때 TIME_WAIT 상태의 포트는 사용할 수 없으므로, 포트를 할당해 줄 수 없게 되면 타임 아웃이 발생한다.

2. 잦은 TCP 연결 맺기/끊기로 인해 응답속도 저하가 발생한다.

지속적으로 통신량이 많을 때에도 연결을 자주 맺고 끊게 되면 잦은 TCP 3-way handshake가 발생하여 서비스의 응답 속도 저하를 야기할 수 있다.
대부분 어플리케이션에서는 Connection Pool과 같은 방법으로 연결을 재사용할 수 있게 구현하여 이러한 문제를 방지하고 있다.

클라이언트에서의 TIME_WAIT

문제

대부분 서버가 먼저 연결을 끊는 경우가 많기 때문에 서버에서 TIME_WAIT가 생긴다고 오해할 수 있지만 항상 그렇지는 않다.
위와 같이 User(Client) - Web Server - DB Server 구조에서는 Web Server는 User 입장에서는 서버이지만 DB Server와의 입장에서는 클라이언트이다. 즉, 통신하는 과정에 따라 서버의 역할을 했던 서버는 반대로 클라이언트 역할을 하기도 한다.
이 과정에서 클라이언트의 역할을 하는 서버가 먼저 연결을 끊는다면 클라이언트 입장의 TIME_WAIT 소켓이 발생할 수 있다.

재현

www.kakao.com으로 HTTP 프로토콜로 GET 요청을 다음과 같이 보낼 수 있다.
> curl http://www.kakao.com > /dev/null % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0 > netstat -ant | grep -i time_wait tcp4 0 0 ${ip}:59788 ${ip}:80 TIME_WAIT
Bash
복사
위의 예시를 보면 클라이언트의 59788 포트를 사용해서 서버의 80 포트와 연결을 맺고 끊는 과정에서 TIME_WAIT이 발생함을 알 수 있다.
로컬 포트가 고갈되었을 때를 살펴 보자. 로컬 포트 고갈을 재현하기 위해서는 로컬 포트를 강제로 1개로 사용하도록 해서 테스트할 수 있다.
# 32768 포트로만 나가도록 설정 > sysctl -w net.ipv4.ip_local_port_range="32768 32768" # 연달아 요청 보내기 > curl http://www.kakao.com > /dev/null > curl http://www.kakao.com > /dev/null curl: (7) Failed to connect to ${ip}: 요청한 주소를 배정할 수 없다
Bash
복사
첫 요청에서 32768 포트를 사용했기 때문에 두번째 요청에서 오류가 발생하는 것이다.

방법1. net.ipv4.tcp_tw_reuse 설정

커널 설정을 net.ipv4.tcp_tw_reuse=1로 하면 외부로 요청시 TIME_WAIT 소켓을 재사용할 수 있다.
> sysctl -w "net.ipv4.ip_local_port_range=32768 32768"> sysctl -w "net.ipv4.tcp_tw_reuse=1"# 연달아 요청 보내기 (물론 너무 빠르면... 안된다...) > curl http://www.kakao.com > /dev/null > curl http://www.kakao.com > /dev/null > curl http://www.kakao.com > /dev/null # 성공!
Bash
복사
참고로 net.ipv4.tcp_tw_reuse는 timestamp 기능과 함께 사용해야고 net.ipv4.tcp_timestamps 값이 반드시 1이어야 한다.

방법2. Connection Pool 방식 사용하기

방법1보다 근본적인 해결방법이 있다. 연결은 끊고 소켓을 재사용하기 보다 연결을 끊지 않는 것이다.
클라이언트의 동작 방식은 다음과 같이 두 가지로 나눌 수 있다.
Connection Less 방식: HTTP가 많이 사용하는 방식으로, 요청할 때마다 소켓을 새로 연결하는 방식
Connection Pool 방식: 미리 소켓을 열어놓고 요청을 처리하는 방식
백문이 불여일견이니 코드를 통해서 살펴보자

Connection Less 방식

import redis count = 0 while True: if count > 10000: break # 매번 연결을 새로 맺는다. r = redis.Redis(host='127.0.0.1', port=6379, db=0) print("SET") r.setex(count, 10, count)
Python
복사
아래와 같이 TIME_WAIT 소켓이 발생한다.
> netstat -ant | grep 6379 tcp4 0 0 127.0.0.1.58258 127.0.0.1.6379 TIME_WAIT tcp4 0 0 127.0.0.1.58259 127.0.0.1.6379 TIME_WAIT tcp4 0 0 127.0.0.1.58260 127.0.0.1.6379 TIME_WAIT
Bash
복사

Connection Pool 방식

import redis count = 0 # 연결을 재사용한다. pool = redis.ConnectionPool(host='127.0.0.1', port=6379, db=0) while True: if count > 10000: break r = redis.Redis(connection_pool=pool) print("SET") r.setex(count, 10, count)
Python
복사
TIME_WAIT 소켓이 발생하지 않는다.
> netstat -ant | grep 6379 tcp4 0 0 127.0.0.1.6379 127.0.0.1.51445 ESTABLISHED tcp4 0 0 127.0.0.1.51445 127.0.0.1.6379 ESTABLISHED tcp6 0 0 ::1.6379 *.* LISTEN tcp4 0 0 127.0.0.1.6379 *.* LISTEN
Bash
복사

서버 입장에서의 TIME_WAIT

문제

서버의 TIME_WAIT은 클라이언트의 TIME_WAIT과는 상황이 조금 다르다. 서버는 소켓을 열어놓고 요청을 받아들이는 입장이기 때문에 로컬 포트 고갈과 같은 문제는 일어나지 않는다. 그러나 다수의 TIME_WAIT 소켓이 있으면 불필요한 연결 맺기/끊기의 과정이 반복된다.

재현

nginx(서버)의 keepalive_timeout을 0으로 설정하고 클라이언트에서 서버로 요청을 연속으로 보낸다.
# 클라이언트에서 curl -s http://${server}:80/\
Bash
복사
서버에서 다음과 같이 TIME_WAIT 소켓을 확인할 수 있다.
# 서버에서 > netstat -napo | grep -i :80 tcp 0 0 ${ip}:80 ${ip}:52496 TIME_WAIT - timewait (46.22/0/0)
Bash
복사
다시 말해서 nginx(서버)가 연결을 끊은 active closer임을 확인할 수 있다.

방법1. net.ipv4.tcp_tw_recycle 설정

net.ipv4.tw_reuse는 나갈 때 사용하는 로컬 포트에서 TIME_WAIT 상태의 소켓을 재사용할 수 있게 해주는 파라미터라면 net.ipv4.tw_recycle은 그 반대로 서버 입장에서 TIME_WAIT 상태의 소켓을 빠르게 회수하고 재활용할 수 있게 해주는 파라미터이다.
그러나 net.ipv4.tw_recycle은 사용함에 있어서 유의해야 한다.
net.ipv4.tw_recycle은 요청이 잘못되었는지 아닌지 여부를 timestamp를 기반으로 판단한다. 만약 같은 NAT를 사용하는 클라이언트들이 불규칙적으로 요청을 보내는 상황에서 서버 입장에서는 이 timestamp 값을 기반으로 판단하면서 유의미한 요청을 잘못된 요청으로 간주하고 버리는 상황이 발생할 수 있다.
net.ipv4.tw_recycle=1로 설정하는 것을 권장하지 않는 자세한 이유에 대해서는 포스트1 혹은 포스트2 를 참고

방법2. keepalive 사용하기

TIME_WAIT 소켓을 완전히 없앨 수는 없지만 keepalive 옵션을 통해서 줄일 수 있다. keepalive란 한번 맺은 세션을 요청이 끝나더라도 유지해주는 기능

keepalive를 사용하지 않을 때 (nginx, keepalive_timeout=0)

응답 헤더에 Connection: close가 내려오는 것을 확인할 수 있다.
TIME_WAIT 소켓이 발생한 것을 확인할 수 있다.
> telnet server.domain.com 80 Trying 172.16.33.136.. Connected to server.domain.com. Escape Charactr is '^]'. GET /index.jsp HTTP/1.1 Host: server.domain.com HTTP/1/1 200 OK Server: nginx/1.9.4 Date: Sun, 28 Feb 2016 14:00:02 GMT Content-Type: text/html;charset=ISO-8859-1 Content-Length:142 Connection: close # ...
Bash
복사
> netstat -napo | grep -i time_wait tcp 0 0 127.0.0.1:8080 127.0.0.1:47055 TIME_WAIT - TIME_WAIT (55.89/0/0) tcp 0 0 172.16.33.136:80 172.16.33.137:43961 TIME_WAIT - TIME_WAIT (55.89/0/0)`
Bash
복사

keepalive를 사용할 때 (nginx, keepalive_timeout=10)

응답 헤더에 Connection: keep-alive가 나오는 것을 확인할 수 있다.
10초가 지나야 연결이 끊어지게 된다.
> telnet ptom278.dakao.io 80 Trying 172.16.33.136... Connected to ptom278.dakao.io. Escape character is '^]'. GET /index.jsp HTTP/1.1 Host:ptom278.dakao.io HTTP/1.1 200 OK Server: nginx/1.9.4 Date: Sun, 28 Feb 2016 14:07:19 GMT Content-Type: text/html;charset=ISO-8859-1 Content-Length:142 Connection : keep-alive # ... # 10초 후에 연결 종료
Bash
복사

TIME_WAIT 상태의 존재 이유

TIME_WAIT 소켓으로 인해서 문제점이 발생함에도 불구하고 TIME_WAIT 소켓이 필요한 이유는 무엇일까?
TIME_WAIT 소켓의 목적은 연결이 종료된 후에도 소켓을 바로 정리하지 않고 일종의 연결 종료에 대한 흔적을 남겨 놓는 것에 있다. 일정 시간 동안 연결 종료에 대한 문제점을 방지하는 것이 TIME_WAIT 소켓의 목적이다. 다시 말해서 패킷 유실에 따른 비정상적인 통신 흐름의 발생을 막는 것이다.
바꾸어 말하면 TIME_WAIT의 상태가 매우 짧은 경우 다음과 같은 문제가 발생할 수 있다.
서버에서 FIN을 보내고 통신 종료 후 서버에서 마지막으로 ACK를 보낼 때 중간에 유실되면, 클라이언트 입장에서는 자신이 보낸 FIN에 대한 ACK를 받지 못한 상황이 발생
클라이언트는 ACK를 받지 못했으므로 서버에 다시 FIN을 보내지만 서버는 이미 TIME_WAIT 소켓을 정리해서 정상적인 FIN으로 판단하지 않고 RST(Reset, 비정상 종료)를 전송
클라이언트는 정상적인 ACK를 받지 못했으므로 LAST_ACK(연결은 종료되었고 승인을 기다리는 상태) 소켓이 증가할 수 있음

참고