Profile picture

[Linux] TCP 통신 구조 파악하기 (feat. Nginx)

JaehyoJJAng2024년 12월 05일

개요

이번 게시글에서는 TCP 통신이 어떻게 시작되고 종료되는지,

그 과정에서 왜 TIME_WAIT 상태가 필요한지, 그리고 이 상태가 때로는 왜 문제가 되며 어떻게 대처할 수 있는지

Nginx 실습 예제와 함께 이해해보겠습니다!


TCP 통신 과정

TCP는 신뢰성 있는 데이터 전송을 위해 '연결'이라는 과정을 거칩니다. 마치 통화를 시작하고 끊는 것처럼요!


먼저 서버에서 tcpdump 명령을 사용하여 덤프 파일을 생성해봐요.

tcpdump -A -vvv -nn port 80 -w server_dump.pcap

생성된 .pcap 파일을 와이어샤크 프로그램으로 연 후에, 발생된 패킷을 확인해보면서 아래 내용을 읽어봅시다!


3-way handshake (Connection Establishment)

image
이미지 출처: unicminds.com

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

4-way handshake (Connection Termination)

image
이미지 출처: ghdwlsgur - TIME_WAIT 소켓이 서비스에 미치는 영향

  • 1. 연결을 끊는 쪽에서 먼저 FIN을 보냅니다. (위 그림에서는 서버쪽에서 먼저 연결을 끊었죠?)
  • 2. 상대방은 ACK을 보냅니다.
  • 3. 상대방은 소켓을 정리한 다음에 마지막 FIN을 보냅니다.
  • 4. 연결을 끊는 쪽에서 확인했다는 ACK를 보내고 TIME_WAIT 상태에 들어갑니다.

TIME_WAIT 소켓

먼저 연결을 끊는 쪽을 active closer, 그 반대를 passive closer라고 합니다.
image
이미지 출처: ghdwlsgur - TIME_WAIT 소켓이 서비스에 미치는 영향


누가 먼저 연결을 끊느냐가 중요한 이유는 Active Closer쪽에 TIME_WAIT 소켓이 생성되기 때문이에요!

따라서 서버나 클라이언트 양쪽 모두 TIME_WAIT 소켓이 생성될 수 있는거죠.


TIME_WAIT 소켓 확인하는 방법은 다음과 같아요!

netstat -napo | grep -i time_wait

tcp        0      0 192.168.219.115:8000    192.168.219.155:58173   TIME_WAIT   -                    timewait (6.51/0/0)

위처럼 현재 TIME_WAIT 상태인 소켓은 타이머가 종료되어 커널로 다시 돌아갈 때까지는 사용할 수 없어요.


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과 같은 방법으로 연결을 재사용할 수 있게 구현하여 이러한 문제를 방지하고 있죠.

이 때 서버측에서는 tw_recycle 커널 파라미터를 사용하여 TIME_WAIT 소켓을 빠르게 회수할 수는 있지만,

특정 환경에서는 SYN 패킷이 버려지는 문제가 발생할 수 있기 때문에 권장하는 방법은 아니에요!


클라이언트에서의 TIME_WAIT

문제

대부분의 경우에서 서버가 먼저 연결을 끊는 경우가 많기 때문에 서버에서 TIME_WAIT가 무조건적으로 생긴다고 오해할 수 있지만

항상 그렇지는 않아요.
image


위와 같은 User - Web Server - DB Server 구조를 예로 들어볼까요.

이 때 Web 서버는 유저(Client)를 기준으로 했을 때 서버로 구분되지만,

DB 서버를 기준으로 보게되면 Web 서버는 클라이언트로 인식이 됩니다.


즉, 통신하는 과정에 따라 서버의 역할을 했던 서버는 반대로 클라이언트 역할을 하기도 한다는거죠.

이 과정에서 클라이언트의 역할을 하는 서버가 먼저 연결을 끊는다면, 클라이언트 입장의 TIME_WAIT 소켓이 발생할 수 있어요.


문제 재현해보기!

www.kakao.com으로 HTTP 프로토콜로 GET 요청을 다음과 같이 보내봅시다.

curl http://www.kakao.com 1>/dev/null

그리고 TIME_WAIT 소켓 상태를 확인해볼게요.

netstat -ant | grep -i 'time_wait'

tcp4       0      0  ${ip}:57684     ${ip}:80      TIME_WAIT

위 예시를 보면 클라이언트의 57684 포트를 사용해서 서버의 80 포트와 연결을 맺고 끊는 과정에서 TIME_WAIT이 발생한 걸 볼 수 있어요.


그럼 이번에는 로컬 포트가 가정되었을 때를 재현해볼게요.

로컬 포트 고갈을 재현하기 위해서는 로컬 포트를 강제로 1개만 사용하도록 해서 테스트할 수 있어요.

# 57684 포트로만 나가도록 설정
sysctl -w net.ipv4.ip_local_port_range="57684 57684"

# 연달아 요청 보내보기
curl -X GET http://www.kakao.com 1>/dev/null

# 다음과 같이 요청한 주소를 배정할 수 없다는 에러가 발생함!
curl: (7) failed to connect to <IP>

첫 요청에서 57684 포트를 사용했기 때문에 두 번째 요청에서 요류가 발생하는거죠!



방법1. net.ipv4.tcp_tw_reuser 설정해보기

커널 설정을 net.ipv4.tcp_tw_reuse=1로 하면 외부로 요청 시 TIME_WAIT 소켓을 재사용할 수 있어요.

sysctl -w "net.ipv4.ip_local_port_range=57684 57684" > 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
# 성공!

참고로 net.ipv4.tcp_tw_reusetimestamp 기능과 함께 사용해야하고, 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)

아래와 같이 TIME_WAIT 소켓이 1초 단위로 생성되고 있어요!

netstat -ant | grep 6379
tcp4       0      0  127.0.0.1.57684        127.0.0.1.6379         TIME_WAIT
tcp4       0      0  127.0.0.1.57684        127.0.0.1.6379         TIME_WAIT
tcp4       0      0  127.0.0.1.57684        127.0.0.1.6379         TIME_WAIT

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)

이번에는 TIME_WAIT 소켓이 발생하지 않네요!

netstat -ant | grep 6379
tcp4       0      0  127.0.0.1.6379         127.0.0.1.52358        ESTABLISHED
tcp4       0      0  127.0.0.1.52358        127.0.0.1.6379         ESTABLISHED
tcp6       0      0  ::1.6379               *.*                    LISTEN
tcp4       0      0  127.0.0.1.6379         *.*                    LISTEN

Connection Pool 방식은 하나의 포트를 계속해서 사용함으로써 로컬 포트의 무분별한 사용을 막고,

서비스의 응답 속도도 향상시킬 수 있기 때문에 사용하는 것을 권장해요!


서버에서의 TIME_WAIT

서버의 TIME_WAIT은 클라이언트의 TIME_WAIT과 조금 달라요.

서버는 소켓을 열어놓고 요청을 받아들이는 입장이기 때문에 로컬 포트 고갈과 같은 문제는 일어나지 않죠.


그러나 다수의 TIME_WAIT 소켓이 있으면 불필요한 연결 맺기/끊기의 과정이 계속 반복되겠죠.


문제 재현해보기!

nginx 서버의 keepalive_timeout을 0으로 설정하고 클라이언트에서 서버로 요청을 연속으로 보내봅시다!

# 클라이언트에서 실행
curl -s http://${nginx_server_ip}:80/

서버에서 다음과 같이 TIME_WAIT 소켓을 확인해봐요.

# 서버에서
netstat -napo | grep -i :80
tcp 0 0 ${ip}:80 ${ip}:55344 TIME_WAIT - timewait (46.22/0/0)

즉, 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 값을 기반으로 판단하면서 유의미한 요청을 잘못된 요청으로 간주하고 버리는 상황이 발생할 수 있어요!!


방법2. keepalive 설정해보기

TIME_WAIT 소켓을 완전히 없앨 수는 없지만 keepalive 옵션을 통해서 줄일 수는 있어요.

keepalive는 한번 맺은 세션을 요청이 끝나더라도 유지해주는 기능이에요.


문제 비교해보기: keepalive를 사용하지 않을 때 (nginx, keepalive_timeout=0)

  • 응답 헤더에 Connection: close가 내려오는 것을 확인할 수 있어요.
  • TIME_WAIT 소켓이 발생한 것을 확인할 수 있습니다.

telnet server.domain.com 80
Trying <Server_IP>..
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
netstat -napo | grep -i "time_wait"

tcp 0 0 127.0.0.1:8080 127.0.0.1:35593 TIME_WAIT - TIME_WAIT (55.89/0/0)
tcp 0 0 172.16.33.136:80 172.16.33.137:36619 TIME_WAIT - TIME_WAIT (55.89/0/0)`

문제 비교해보기: keepalive를 사용할 때 (nginx, keepalive_timeout=10

  • 응답 헤더에 Connection: keep-alive가 나오는 것을 확인할 수 있어요.
  • 10초가 지나야 연결이 해제됩니다.
telnet server.domain.com 80
Trying <Server_IP>...
Connected to server.domain.com.
Escape character 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:07:19 GMT
Content-Type: text/html;charset=ISO-8859-1
Content-Length:142
Connection : keep-alive
# ...
# 10초 후에 연결 종료

TIME_WAIT 상태가 존재하는 이유?

그렇다면 TIME_WAIT 문제점이 꾸준히 발생하는데도 불구하고 이 상태는 도대체 왜 존재하는 것일까요?


TIME_WAIT 소켓의 목적은 연결이 종료된 후에도 소켓을 바로 정리하지 않고 일종의 연결 종료에 대한 흔적을 남겨 놓는 것에 있어요.

일정 시간 동안 연결 종료에 대한 문제점을 방지하는 것이 TIME_WAIT 소켓의 목적인거지요.

즉, 패킷 유실에 따른 비정상적인 통신 흐름의 발생을 막는 것이에요.


바꾸어 말하자면 TIME_WAIT 상태가 매우 짧은 경우에는 다음과 같은 문제가 발생하겠죠.


  • 1. 서버에서 FIN을 보내고 통신 종료 후 서버에서 마지막으로 ACK를 보낸다고 가정할 때, 중간에 해당 상태가 유실되면 클라이언트 입장에서는 자신이 보낸 FIN에 대한 ACK를 받지 못하는 상황이 발생함.
  • 2. 클라이언트는 Ack를 받지 못했으므로 서버에 다시 fin을 보내지만 서버는 이미 TIME_WAIT 소켓을 정리해서 정상적인 FIN으로 판단하지 않고 RST(Reset, 비정상 종료)를 전송함.
  • 3. 클라이언트는 정상적인 ACK를 받지 못했으므로, LAST_ACK(연결은 종료되었고 승인을 기다리는 상태) 소켓이 증가할 수 있게됨.

참조

    Tag -

Loading script...