TIME_WAIT 소켓이 서비스에 미치는 영향
아래 내용은 DevOps와 SE를 위한 리눅스 커널 이야 기 7장 TIME_WAIT 소켓이 서비스에 미치는 영향을 읽고 정리한 내용입니다.
[root@ip-172-31-35-50 ~]# tcpdump -A -vvv -nn port 80 -w server_dump.pcap
tcpdump: listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
10 packets captured
11 packets received by filter
0 packets dropped by kernel
# the other terminal
[ec2-user@ip-172-31-35-50 ~]$ curl http://3.34.122.23
<h3>This response is from ip-172-31-37-40.ap-northeast-2.compute.internal. Region: Seoul. Have a great Day</h3>
4-way-handshake
- 먼저 연결을 끊는 쪽: active closer
- 그 반대: passive closer
누가 먼저 연결을 끊느냐가 중효한 이유는 active closer 쪽에 TIME_WAIT 소켓이 생성되기 때문
watch -n 1 "netstat -napo | grep -i time_wait"
...
Every 1.0s: netstat -napo | grep -i time_wait Wed Oct 4 05:34:30 2023
tcp 0 0 172.31.35.50:55100 3.34.122.23:80 TIME_WAIT - timewait (54.24/0/0)
위와 같이 현재 TIME_WAIT 상태인 소켓은 타이머가 종료되어 커널로 다시 돌아갈 때까지는 사용할 수 없다.
TIME_WAIT 소켓이 많아지면 발생할 수 있는 문제
클라이언트 입장에서는 로컬 포트 고갈 문제가 있고 서버 입장에서는 잦은 TCP 연결로 인한 성능 저하 문제가 있다.
- 로컬 포트 고갈에 따른 애플리케이션 타임아웃 발생
net.ipv4.ip_local_port_range
라는 커널 파라미터는 외부와 통신하기 위해 필요한 로컬 포트의 범위를 지정하는 역할- 모든 로컬 포트가 TIME_WAIT 상태에 있다면 할당할 수 있는 로컬 포트가 없어 외부와 통신 불가, 이로 인해 애플리케이션에서 타임 아웃 발생
- 할당할 수 있는 로컬 포트가 고갈되지 않도록 커널 파라미터 tw_reuse 사용 (클라이언트 입장에서 TIME_WAIT 소켓을 재사용하므로 로컬 포트 고갈 문제 방지)
- 잦은 TCP 연결 맺기/끊기로 인해 서비스의 응답 속도 저하
- Connetcion Pool 방식 사용
- keepalive 기능 사용
서버 입장에서 tw_recycle 파라미터를 통해 TIME_WAIT 소켓을 빠르게 회수할 수 있지만 특정 환경에서는 SYN 패킷이 버려지는 문제가 발생할 수 있어 권장되지 않음
클라이언트에서의 TIME_WAIT()
웹 서버의 경우 유저의 요청을 처리하는 서버의 역할도 하지만 요청을 처리하는 과정에서 데이터베이스와 통신이 필요할 경우 데이터베이스에 저장된 데이터를 조회 및 저장하기 위해 데이터베이스 서버에 요청하므로 클라이언트의 역할도 수행하기 때문에 클라이언트에서의 TIME_WAIT과 서버에서의 TIME_WAIT을 모두 고려해야 한다.
아래 그림은 웹 서버에서 데이터베이스 서버로 요청할 때 웹 서버의 커널에서 애플리케이션에 로컬 포트를 할당하고 소켓을 생성하여 소켓에 접근할 때 사용할 수 있는 파일 디스크립터를 전달하는 과정이다.
# 의도적으로 가용한 로컬 포트 범위를 32768 하나로 축소
[ec2-user@ip-172-31-35-50 ~]$ sudo sysctl -w "net.ipv4.ip_local_port_range=32768 32768"
net.ipv4.ip_local_port_range = 32768 32768
# 정상
[ec2-user@ip-172-31-35-50 ~]$ curl http://3.34.122.23
<h3>This response is from ip-172-31-37-40.ap-northeast-2.compute.internal. Region: Seoul. Have a great Day</h3>
# 할당할 수 있는 로컬 포트 부족
[ec2-user@ip-172-31-35-50 ~]$ curl http://3.34.122.23
# curl: (7) Failed to connect to 3.34.122.23 port 80 after 0 ms: Couldn't connect to server
# 할당할 수 있는 로컬 포트 부족
[ec2-user@ip-172-31-35-50 ~]$ curl http://3.34.122.23
# curl: (7) Failed to connect to 3.34.122.23 port 80 after 0 ms: Couldn't connect to server
이때 커널 TIME_WAIT 소켓을 처리하는 커널 파라미터 중 net.ipv4.tcp_tw_reuse
를 사용하여 외부로 요청할 때 TIME_WAIT 소켓을 재사용할 수 있게 해준다.
# TIME_WAIT 소켓 재사용 설정
[ec2-user@ip-172-31-35-50 ~]$ sudo sysctl -w "net.ipv4.tcp_tw_reuse=1"
net.ipv4.tcp_tw_reuse = 1
# 정상
[ec2-user@ip-172-31-35-50 ~]$ curl http://3.34.122.23
<h3>This response is from ip-172-31-37-40.ap-northeast-2.compute.internal. Region: Seoul. Have a great Day</h3>
# 정상
[ec2-user@ip-172-31-35-50 ~]$ curl http://3.34.122.23
<h3>This response is from ip-172-31-37-40.ap-northeast-2.compute.internal. Region: Seoul. Have a great Day</h3>
# 정상
[ec2-user@ip-172-31-35-50 ~]$ curl http://3.34.122.23
<h3>This response is from ip-172-31-37-40.ap-northeast-2.compute.internal. Region: Seoul. Have a great Day</h3>
위와 같이 net.ipv4.tcp_tw_reuse 커널 파라미터로 TIME_WAIT 소켓을 재사용하였더니 연속된 curl 명령에도 동일한 소켓을 재사용하여 정상 응답을 하는 것을 확인할 수 있다. 위 커널 파라미터는 TIME_WAIT 상태인 소켓을 재사용하는 것이기 때문에 1초 이내로 연속 수행할 경우 TIME_WAIT 상태가 아닌 FIN_WAIT 상태가 되기 때문에 에러가 발생할 수 있다.
본문에서는 net.ipv4.tcp_tw_reuse
동작원리를 도식화하여 이해하기 쉽게 설명한다.
:::importantnet.ipv4.tcp_tw_reuse
net.ipv4.tcp_tw_reuse는 timestamp 기능과 함께 사용해야 하고 net.ipv4.tcp_timestamps 값이 반드시 1이어야 한다.
:::
Connection Pool
커넥션 풀 방식을 사용하기 이전에는 클라이언트의 요청마다 매번 소켓 연결이 필요하지만 Connection Pool 방식은 소켓을 미리 열어 놓고 사용하여 매번 소켓 연결이 필요하지 않기 때문에 커넥션 풀 방식을 사용하기 이전에 발생할 수 있는 TCP Handshake 오버헤드를 방지할 수 있다.
Connection less 테스트
#!/usr/bin/python
import redis
import time
count = 0
while True:
if count > 10000:
break;
r = redis.Redis(host='172.31.37.40', port=6379, db=0)
print("SET")
r.setex(count, 10, count)
# 원래 설정대로 원복
sudo sysctl -w net.ipv4.ip_local_port_range="10240 65535"
sudo sysctl -w "net.ipv4.tcp_tw_reuse=0"
# 파이썬 코드 실행
[ec2-user@ip-172-31-35-50 ~]$ python3 connection_less.py
# the other terminal
[root@ip-172-31-35-50 ~]# watch -n 1 "netstat -napo | grep -i 6379"
Every 1.0s: netstat -napo | grep -i 6379 Wed Oct 4 06:44:01 2023
tcp 0 0 172.31.35.50:33426 172.31.37.40:6379 TIME_WAIT - timewait (29.08/0/0)
tcp 0 0 172.31.35.50:35936 172.31.37.40:6379 TIME_WAIT - timewait (31.40/0/0)
tcp 0 0 172.31.35.50:31456 172.31.37.40:6379 TIME_WAIT - timewait (27.29/0/0)
tcp 0 0 172.31.35.50:29102 172.31.37.40:6379 TIME_WAIT - timewait (25.15/0/0)
tcp 0 0 172.31.35.50:31358 172.31.37.40:6379 TIME_WAIT - timewait (27.20/0/0)
tcp 0 0 172.31.35.50:32098 172.31.37.40:6379 TIME_WAIT - timewait (27.86/0/0)
tcp 0 0 172.31.35.50:32240 172.31.37.40:6379 TIME_WAIT - timewait (28.00/0/0)
tcp 0 0 172.31.35.50:30366 172.31.37.40:6379 TIME_WAIT - timewait (26.30/0/0)
tcp 0 0 172.31.35.50:33882 172.31.37.40:6379 TIME_WAIT - timewait (29.49/0/0)
tcp 0 0 172.31.35.50:33210 172.31.37.40:6379 TIME_WAIT - timewait (28.88/0/0)
tcp 0 0 172.31.35.50:35200 172.31.37.40:6379 TIME_WAIT - timewait (30.72/0/0)
tcp 0 0 172.31.35.50:34414 172.31.37.40:6379 TIME_WAIT - timewait (29.99/0/0)
tcp 0 0 172.31.35.50:33678 172.31.37.40:6379 TIME_WAIT - timewait (29.31/0/0)
tcp 0 0 172.31.35.50:33816 172.31.37.40:6379 TIME_WAIT - timewait (29.43/0/0)
tcp 0 0 172.31.35.50:34186 172.31.37.40:6379 TIME_WAIT - timewait (29.78/0/0)
tcp 0 0 172.31.35.50:36072 172.31.37.40:6379 TIME_WAIT - timewait (31.52/0/0)
tcp 0 0 172.31.35.50:34682 172.31.37.40:6379 TIME_WAIT - timewait (30.24/0/0)
tcp 0 0 172.31.35.50:36182 172.31.37.40:6379 TIME_WAIT - timewait (31.62/0/0)
tcp 0 0 172.31.35.50:30160 172.31.37.40:6379 TIME_WAIT - timewait (26.11/0/0)
tcp 0 0 172.31.35.50:35784 172.31.37.40:6379 TIME_WAIT - timewait (31.26/0/0)
tcp 0 0 172.31.35.50:30658 172.31.37.40:6379 TIME_WAIT - timewait (26.56/0/0)
tcp 0 0 172.31.35.50:34134 172.31.37.40:6379 TIME_WAIT - timewait (29.73/0/0)
Connection less의 경우 TIME_WAIT 소켓이 1초 단위로 생성
Connection Pool 테스트
#!/usr/bin/python
import redis
import time
count = 0
pool = redis.ConnectionPool(host='172.31.37.40', port=6379, db=0)
while True:
if count > 10000:
break;
r = redis.Redis(connection_pool=pool)
print("SET")
r.setex(count, 10, count)
python3 connection_pool.py
# the other terminal
[root@ip-172-31-35-50 ~]# watch -n 1 "netstat -napo | grep -i 6379"
Every 1.0s: netstat -napo | grep -i 6379 Wed Oct 4 06:48:54 2023
tcp 0 0 172.31.35.50:44926 172.31.37.40:6379 TIME_WAIT - timewait (54.76/0/0)
Connection Pool 방식은 하나의 포트를 계속해서 사용함으로써 로컬 포트의 무분별한 사용을 막고 서비스의 응답 속도도 향상시킬 수 있기 때문에 가능한 한 사용하는 것을 권장한다.
tw_recycle
tw_recycle
는 서버 입장에서 TIME_WAIT 상태의 소켓을 빠르게 회수하고 재활용할 수 있게 해주는 파라미터- 재전송은 TCP 메커니즘에서 반드시 발생할 수 밖에 없으므로 재전송이 일어났을 때 빨리 일어나도록 하여 성 능을 향상시킬 수 있다.
C1과 C2의 두 클라이언트가 동일한 통신사를 사용한다고 가정한다면 동일한 통신사를 사용할 경우 동일한 NAT를 사용할 수 있고 웹 서버 입장에서는 같은 소스 IP를 달고 오기 때문에 동일한 클라이언트로 보게 된다. 포트는 다르기 때문에 같은 클라이언트가 출발지 포트만 다르게 해서 요청하는 것과 같다.
서버는 클라이언트 C1과의 통신을 잘 마무리하고 로직상에 구현되어 있는 대로 TIME_WAIT 소켓을 RTO 값으로 세팅해서 금방 정리하고, C1의 Timestamp를 저장한다. 그 후 C2가 다시 한번 연결 오픈 요청을 보내는데, 이때 C1과 C2는 동일한 클라이언트가 아니기 때문에 시간이 살짝 다를 수 있으며 이때 Timestamp 값이 C1이 보낸 FIN에 기록된 Timestamp보다 작을 수 있다.
웹 서버 입장에서는 동일한 IP를 가진 목적지에서 기존보다 더 작은 Timestamp를 가지고 통신 연결을 요청하기 때문에 잘못된 연결 요청으로 판단하여 패킷을 처리하지 않고 버린다. 하지만 C2는 패킷이 버려진 것을 모르고 재전송한다. 자신이 보낸 SYN에 대한 응답이 오지 않았기 때문이다. 이렇게 연결은 되지 않고 연결 요청만 계속 일어나게 되는 현상이 클라이언트의 요청을 직접 받는 웹 서버에서 주로 발생할 수 있기 때문에 웹 서버에서는 절대로 tw_recycle을 켜서는 안된다고 책에서 강조한다.
keepalive
keeaplive의 타임아웃이 10초로 설정되어 있다면 10초가 지나야만 서버에서 먼저 연결을 끊게 된다.
TIME_WAIT 상태의 존재 이유
- 패킷 유실에 따른 비정상적인 통신 흐름의 발생 중 연결 해제 시 발생 후 있는 문제를 방지하기 위해 TIME_WAIT 소켓이 필요
TIME_WAIT 상태가 매우 짧을 경우 서버 입장에서 클라이언트가 보낸 FIN에 대한 응답으로 ACK를 보내는데 이 패킷이 유실되어 클라이언트는 자신이 보낸 FIN에 대한 ACK 응답을 받지 못하므로 계속해서 FIN을 보내고 서버 입장에서는 이미 소켓을 정리했기 때문에 클라이언트의 FIN 패킷을 비정상적으로 보고 RST 응답을 함으로써 비정상적으로 LAST_ACK 상태의 소켓이 계속해서 증가하게 된다.