네트워크/네트워크 공통

[스크랩] TCP의 TIME_WAIT 없애는 법.

99iberty 2017. 9. 18. 17:17


http://sunyzero.tistory.com/198


* TCP의 TIME_WAIT는 없애는 방법은?


TCP 소켓 네트워크 프로그래밍을 하다 보면 TIME_WAIT 상황에 대한 고민을 하는 시점이 오게 된다. 학부 시절 네트워크 프로그래밍 수업을 듣고 실습실에서 열심히 프로그래밍 해봤다면 학부 때 맞닥뜨리게 되는 경우도 있다. 만일 학생 때 고민하지 않고 넘어갔다면 회사에서 주먹구구식으로 혼동을 일으키는 내용이기도 하다. 그래서 이에 대해 좀 정확한 정보를 전달하고자 이 글을 쓴다.


아래 글은 각종 책과 표준안의 레퍼런스와 실제 코딩으로도 검증했지만, 그래도 혹시 틀린 점이 있다면 개의치 말고 지적해주면 감사하겠다. ^^

  1. TIME_WAIT란 무엇인가?
  2. 이 글을 쓰게 된 계기
  3. TIME_WAIT는 당신을 해치지 않는다. 그래도 없애고 싶다면?
  4. SO_REUSEADDR의 사용
  5. 결론







1. TIME_WAIT란 무엇인가?

TCP의 TIME_WAIT는 TCP 연결을 종료 할 때 신뢰성을 높이기 위해 존재하는 것으로 자연스럽게 발생하는 상태다. TCP/IP 네트워크 교과서인 TCP/IP Illustrated volume1에 보면 TCP 상태 전이도(TCP state transition diagram)에 표시되어있다.[각주:1] TCP 상태 전이도는 복잡할 수도 있으니 본인의 저서에 좀 쉽게 풀어서 그린 그림을 편집해서 붙여보겠다.[각주:2]


TCP state transitionTCP state transition (출처 : 내 책)



위 그림을 보면 클라이언트측의 마지막 상태에 TIME_WAIT가 발생하는데, 이는 클라이언트측에서 active close를 했다고 가정했기 때문이다. 만일 서버측에서 먼저 active close를 했다면 그림의 아래 부분의 좌우는 바뀌게 되어 , 서버측에 TIME_WAIT가 발생하게 된다. 아주 드물게 simultaneous close 경우에는 서버, 클라이언트 양측에 TIME_WAIT가 발생한다. 


* active close

접속 종료를 먼저 하는 행위를 말한다. TCP 프로토콜에서 active close는 FIN 세그먼트를 먼저 전송하는 측을 의미한다. 소켓 프로그래밍에서는 close 혹은 shutdown 함수를 먼저 호출하는 측이 active close를 하는 측이다.


일반적으로 클라이언트가 active close 하도록 설계하므로 TIME_WAIT는 클라이언트측에 발생한다. 하지만 타임 아웃이나 비정상 종료를 처리하는 경우에는 서버측에서 active close하는 경우가 있다. 그렇지만 세상일은 항상 일반적인 케이스만 있는 것은 아니다. 간혹 비정상이 일반적인 케이스도 있는데, 웹서버가 그런 경우다. 웹서비스에서 사용하는 HTTP 프로토콜은 구현의 특성상 여러 클라이언트 커넥션을 빠르게 받기 위해 서버측에서 active close를 시도하는 상황이 일반적이다. 그래서 대부분의 TIME_WAIT 이슈는 웹서비스에서 주로 발생한다. 게임이나 증권 시스템들은 time_wait보단 fin_wait2나 close_wait 상태가 문제가 되는 경우가 많다.


참고로 TIME_WAIT 상태의 타임아웃은 시스템마다 다르지만 리눅스의 경우에는 60초로 고정되어있다. 커널 설정으로 바꿀 수 있다고 하는 경우도 있는데 사실과 다르다. 리눅스 커널 코드를 보면 60초로 고정이다. 다만 커널 설정으로 TIME_WAIT 상태를 재사용 하도록 설정할 수 있다.


위의 그림으로 제대로 이해가 안간다면 TCP/IP Illustrated Volume1을 보던가 아니면 설명이 잘 나와있는 다른 사람의 블로그를 소개하니 읽고 오자. : TIME_WAIT를 남기지 않는 세션종료 (Graceful Shutdown)


참고로 TIME_WAIT를 소개한 http://kuaaan.tistory.com/118 은 거의 다 맞는 내용이지만 약간 혼동을 줄 수 있는 내용도 있어 보완 설명을 하겠다. 문제가 되는 부분은 중간에 "2. linger 옵션에 대하여"의 윗 단락 부분이다. 아래 인용 부분을 보자.


"이 TIME_WAIT라는 상태가 중요한 이유는, 만약 종료절차가 잘못 진행되어 서버쪽에 TIME_WAIT가 남게 되면 심각한 문제가 발생할 수도 있기 때문입니다. 일단 TIME_WAIT가 시작되면 2분여 이상 상태가 지속되게 되는데 모든 클라이언트들의 세션 종료시마다 서버 측에 TIME_WAIT가 발생한다면, 서버측에 부하가 될 뿐만 아니라 최악의 경우 서버에서는 더이상 새로운 연결을 받아들일 수 없는 상황이 발생할 수 있습니다. 말하자면.. 장애 상황이 발생하는 거죠. (실제로 실 운영서버에 이런 일이 발생하는 것을 직접 목격한 적이 있습니다. ) - http://kuaaan.tistory.com/118

인용한 부분에서 서버 측이 TIME_WAIT로 인해 새로운 연결을 받아들이지 못한다는 했지만, 이는 웹서비스와 같은 특수한 케이스에서 주로 나타난다. 지속적인 연결을 사용하는 시스템에서는 발생하지 않을수도 있다. 만일 리눅스 시스템을 사용하고 있고 time wait 때문에 문제가 발생한다면 tcp_max_tw_buckets, tcp_tw_reuse, tcp_tw_recycle, ip_local_port_range를 튜닝하는 것도 방법일 수 있다.



그러나 이런 경우라고 할지라도 서버측의 가용 포트가 줄어드는 것은 아니다.(클라이언트측의 경우에는 가용 포트가 줄어들 수 있다.) 다른 블로그나 KLDP에서도 TIME_WAIT로 인해 가용 할 수 있는 port가 줄어들어 서버에 문제가 생긴다는 글이 꽤 많은데, 가용 port는 줄어드는 것이 없다. 왜냐하면 서버측은 listen port만 사용하기 때문이다. 소켓의 주소는 local과 foreign address가 페어(pair)로 되어있는데 서버측은 listen port로 고정되고 클라이언트 주소만 달라진다. 예를 들어 ssh 서버에 접속한 클라이언트가 3개가 있는 그림을 보면 쉽게 이해가 갈 것이다.


netstat 화면netstat 화면


위 그림을 보면 서버 측의 local address는 모두 22번 포트를 사용하는 것을 볼 수 있다. 이와 반대로 클라이언트측 주소인 foreign address는 모두 포트 번호가 달라진다. 결국 서버 측은 1개의 포트만 사용하므로 TIME_WAIT로 가용 포트가 줄어든다는 것은 사실과 다르다. 


즉 가용 port 개수가 문제가 아니라 time_wait 버킷의 제한값에 도달하거나 오픈된 파일 개수 제한에 걸려서 문제가 발생하는 경우가 대다수이다.



* TIME_WAIT 상태를 해결하는 SO_LINGER, SO_REUSEADDR

원래 TIME_WAIT가 문제되는 경우는 클라이언트측이 빠르게 접속, 종료를 반복할 때 클라이언트측의 가용 port가 소진되는 것을 의미한다. 주로 스트레스 테스트 클라이언트에서 이런 문제가 보고된다.(서버측이 active close를 빠르게 반복하는 경우에는 조금 다른 양상의 문제가 발생한다.) 본인도 회사에서 스트레스 테스트를 위한 더미 웹 브라우저 클라이언트를 개발할 때 이런 문제를 겪었었다. 물론 교과서에서 배운대로 SO_LINGER로 간단히 처리했다. (SO_LINGER가 싫다면 SO_REUSEADDR을 이용하여 TIME_WAIT 상태의 주소를 재사용하는 방법도 있다.)


참고로 별다른 조치를 취하지 않을 경우, 클라이언트측이 초당 몇 개의 커넥션을 열면 모든 포트를 TIME_WAIT로 소진하는지 계산한 문제가 있다. 바로 TCP/IP Illustrated 연습 문제로서 다음과 같이 적혀있다.



TCP/IP Illustrated Volume1. p262

Q) Exercises 18.14 With an MSL of 120 seconds, what is the maximum at which a system can initiate new connections and then do an active close?

A) The limit is about 268 connections per second: the maximum number of TCP port numbers (65536-1024 = 64512, ignoring the well-known ports) divided by the TIME_WAIT state of 2MSL.




2. 이 글을 쓰게 된 계기


이 글을 포스팅하게 된 계기는 따로 있다. KLDP에서 오래전에 쉰 떡밥(2003년도 떡밥)인 TIME_WAIT 관련 질문, 답변에 새로운 답글이 10년만에 달렸기 때문이다. 도저히 지나칠 수 없는 강력한 떡밥이라서 몇 시간을 들여 관련 글을 포스팅을 하게되었다. 물론 KLDP에는 답글을 달지 않았다. 이유는 나보다 더 까칠하지만 간략하게 오류를 지적한 다른 분이 있어서...


* 떡밥 링크 : Linux에서는 TCP_NODELAY이 없나요? - http://kldp.org/node/165


그런데 답글을 읽다 보니 둘 중에 누가 맞는지 혼동을 줄 수 있는 내용들이 있어 혹시 검색하는 학생이나 신입 사원들에게 도움이 될까하여 자세히 포스팅 해두기로 했다. 사실 여기 있는 내용들은 TCP/IP Illustrated나 내 책에도 다 있는 내용이다. 책을 열심히 읽은 사람들은 대부분 알고 있는 내용일 것이다. 그러면 이제 논란이 된 부분을 정리하자. 


1. TCP_NODELAY는 TIME_WAIT와 상관이 없다. TCP_NODELAY는 소켓에 Nagle's algorithm을 on/off 시키는 기능이다.

2. 원 글을 쓴 사람은 웹 서버를 개발하는데 있어서 서버 측에서 active close하여 발생하는 TIME_WAIT를 문제 삼은 것이다. 그러나 앞서 이야기 한 것처럼 TIME_WAIT는 문제가 되지 않는다. Java 서버 개발자분이 TIME_WAIT 를 문제 삼은 것은 아마도 TCP/IP에 대해 잘 몰라서 그런 것이 아닐까 생각된다.


KLDP의 떡밥글에 10년 만에 새롭게 달린 굉장히 공격적인 답글이 보이는데, 정답이 아니라서 상당히 유감이다. 해당 답글에서는 서버측에서는 shutdown을 하고, 클라이언트 측에서는 SO_LINGER를 사용하면 TIME_WAIT이 발생되지 않는다는 말이 나오는데 절반 정도만 맞은 사실상 오답이다. 정답은 클라이언트든 서버든 무조건 SO_LINGER로만 TIME_WAIT을 없앨 수 있다. 


리눅스 서버, 즉 POSIX 시스템에서 shutdown과 close의 차이는 linked channel이 있는 경우 무시하고 close를 할 것인지 말 것 인지의 차이만 있을 뿐 기능은 같다. 즉 shutdown으로 소켓을 닫는다면 fork로 파생된 프로세스에서 공유하고 있던 파일서술자(file descriptor) 소켓까지 모조리 닫힌다. 고로 정답을 말한 답글은 다음과 같다.


KLDP 정답 답글KLDP 정답 답글



참고로 글을 쓰기 전에 네이버나 구글로 TIME_WAIT를 검색해보니 다른 블로그들에도 비슷한 내용이 있지만 틀린 내용을 적은 블로그들이 절반이 넘었다. 그래서 교과서가 아닌 웹 검색으로만 공부하는 학생들은 틀린 내용을 알고 있는 경우도 많을 것 같다. 정확한 검증을 위해 웹 검색보다는 교과서나 표준안, 매뉴얼을 먼저 보도록 하자.




3. TIME_WAIT은 당신을 해치지 않는다. 그래도 없애고 싶다면?


TIME_WAIT는 정상적인 상태이며 당신과 당신의 시스템을 해치지 않지만, 그래도 꼭 없애고 싶다면 SO_LINGER를 사용하면 된다. 이것도 kuaaan님이 쓰신 블로그 글에 포스팅 되어있다. 인터넷엔 워낙 검증되지 않은 글들이 많으니 의심이 많은 사람은 이 글을 믿지 못할 수 있다. 


그래서 공신력 있는 스티븐스의 TCP/IP Illustrated의 설명을 인용해 보겠다.



W. Richard Stevens. TCP/IP Illustrated Volume 1, p247 Aborting a Connection

Aborting a connection provides two features to the application: 

(1) any queued data is thrown away and the reset is send immediately, and (2) the receiver of the RST can tell that the other end did an abort instead of a normal close. The API begin used by the application must provide a way to generate the abort instead of a normal close.

We can watch this abort sequence happen using our sock program. The sockets API provides this capability by using the "linger on close" socket option(SO_LINGER).



위 인용한 부분을 코드로 쓰면 다음과 같다. 아래 코드에서 cfd는 클라이언트와 연결된 소켓 파일 기술자다.

1
2
3
4
5
struct linger solinger = { 1, 0 };
if (setsockopt(cfd, SOL_SOCKET, SO_LINGER, &solinger, sizeof(struct linger))
        == -1) {
    perror("setsockopt(SO_LINGER)");
}


위와 같이 SO_LINGER를 설정한 상태에서 active close를 하면 정말 스티븐스 아저씨 말대로 TIME_WAIT가 생기지 않는지 확인해봐야 한다. 확증을 위해 예제를 작성해서 살펴보면 된다. 


패킷은 wireshark로 캡쳐했다. 그림이 크니 꼭 클릭해서 보자. 예제 코드는 너무 간단해서 첨부하지 않았는데, 혹시 필요한 사람이 있다면 나중에라도 첨부해 놓겠다.



wireshark : TCP SO_LINGER optionwireshark : TCP SO_LINGER option 확인


그림의 클라이언트측(192.168.0.10)과 서버측(192.168.0.100)은 연결 후 몇 초 뒤 서버측에서 active close하도록 프로그래밍 한 상황이다. 서버측(192.168.0.100)에서는 SO_LINGER 옵션에서 타임아웃을 0초로 설정하였다. 그 결과 10번 프레임을 보면 서버측이 FIN을 먼저 보내는 것을 볼 수 있다.


그리고 11번 프레임에서 ACK를 수신 받고, 12번 프레임에서 서버측은 RST를 송신하여 연결을 취소하는 것을 볼 수 있다. 이 과정을 설명하면 다음과 같다.


앞서 서버측은 FIN을 보낸 다음에 기다리지 않고 즉시 소켓 연결을 파괴해버렸다. 그러나 클라이언트는 FIN을 수신받고 잘 받았음을 의미하는 ACK를 발송하게 된다. 하지만 서버측에선 이미 파괴된 연결에 ACK가 수신되었으므로 "이미 없는 연결에 왜 패킷을 보내니?" 하면서 클라이언트에게 RST를 보낸 것이다. 이에 클라이언트는 RST를 수신하고 취소 작업에 들어가게 된다. 


그 다음에 서버에 도착한 FIN(13번 프레임)은 클라이언트가 워낙 빠르게 반응해서, 첫 번째 RST가 수신되기 전에 close()를 호출한 결과이다. 따라서 두 번째 RST의 등장은 클라이언트의 반응 속도와 RTT에 따라 생길 수도 있고 아닐 수도 있다. 만일 위와 같이 로컬에서 테스트하면 거의 대부분 생긴다. 이에 서버는 두 번째 RST를 보내서 "뭔 소리야? 아까 내가 보낸 RST 몰라? 응? 응?" 하면서 다시 RST를 보내게 된 것이다. 


이렇게 linger가 설정되면 서버측은 active close를 하고도 TIME_WAIT가 발생되지 않는다.[각주:3]


비교를 위해 SO_LINGER를 설정하지 않은 경우에는 어떻게 되는지도 살펴보자. 예상한 대로라면 분명 FIN을 서로 주고받고 끝날 것이다. 마찬가지로 wireshark로 캡쳐를 해봤다.


wireshark : TCP normal closewireshark : TCP normal close


위 그림을 보면 앞서 SO_LINGER를 설정한 경우와는 다르게 RST을 주고 받는 부분이 보이지 않는다. 이것이 TIME_WAIT를 발생시키는 정상적인 close의 패킷 흐름이다. 두 그림을 비교하면서 살펴보면 그 차이를 쉽게 알 수 있을 것이다.



4. SO_REUSEADDR의 사용


앞서 SO_LINGER의 설정을 보았는데, 이번에는 SO_REUSEADDR에 대해서 살펴보자.

SO_REUSEADDR은 주로 서버측 listener socket (listen을 호출하는 대상이 되는 서버측 소켓)에 사용하는 것으로만 아는 분들이 많은데, 사실은 클라이언트에도 사용 가능하다. 클라이언트측에서 사용하는 경우에는 주로 TIME_WAIT 상태에 빠진 소켓을 재사용할 때 사용한다.


TCP 클라이언트측은 주로 socket, connect, send or recv, close 순서로 함수 호출이 이뤄지는데, connect 이전에 bind를 하여 socket, bind, connect, send or recv, close 순서가 되기도 한다. 이렇게 직접 binding을 하는 경우를 explicit binding이라고 한다. 이때 바인딩하는 소켓이 TIME_WAIT 상태의 주소라면 SO_REUSEADDR을 사용하여 재사용을 할 수 있다.


단 주의할 점이 있는데 SO_REUSEADDR로 TIME_WAIT에 빠진 소켓을 재사용하려면 RFC-1323 TCP TS(Timestamp)가 켜져 있어야만 한다는 점이다. 리눅스에서 TCP TS는 커널의 net.ipv4.tcp_timestamps이며 기본값으로 켜져있다. 간혹 이 설정이 꺼진 시스템이 있는 경우도 있는데 그때는 TIME_WAIT관련 이슈가 SO_REUSEADDR로 해결되지 않을 수 있으니 조심해야 한다.


혹시 코드로 검증하고자 하는 사람들을 위해 작성된 C 코드를 첨부한다. 참고로 이 예제는 어디서 가져온게 아니라, 내가 직접 작성한 코드이다. 특히 이 예제는 국제 표준인 POSIX 1003.1의 2001년도 이 후 개정판의 제안에 맞춰 구닥다리 inet_addr이나 gethostbyname같은 함수는 쓰지 않았다. 그 대신 새로 제안된 표준 함수인 getaddrinfo, getnameinfo을 사용하여 작성하였다. 혹시라도 과거에 소켓 프로그래밍을 배울때 옛날 함수인 gethostbyname, gethostbyaddr, inet_addr같은 함수를 사용하고 있다면 지양하고 아래처럼 작성하도록 하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#define _XOPEN_SOURCE   700
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <errno.h>
#include <arpa/inet.h>
 
int main(int argc, char *argv[])
{
    int     fd, rc_gai, flag_once = 0;
    struct addrinfo ai_dest, *ai_dest_ret;
 
    if (argc != 4) {
        printf("%s <hostname> <port> <0(SO_REUSEADDR off) | 1(SO_REUSEADDR on)>\n", argv[0]);
        exit(EXIT_FAILURE);
    }
 
    struct sockaddr_storage sae_local;
    socklen_t   len_sae_local = sizeof(sae_local);
    char addrstr[INET6_ADDRSTRLEN], portstr[8];
    for (int i=0; ;i++ ) {
        memset(&ai_dest, 0, sizeof(ai_dest));
        ai_dest.ai_family = AF_UNSPEC;
        ai_dest.ai_socktype = SOCK_STREAM;
        ai_dest.ai_flags = AI_ADDRCONFIG;
        if ((rc_gai = getaddrinfo(argv[1], argv[2], &ai_dest, &ai_dest_ret)) != 0) {
            fprintf(stderr, "FAIL: getaddrinfo():%s", gai_strerror(rc_gai));
            exit(EXIT_FAILURE);
        }
 
        if ((fd = socket(ai_dest_ret->ai_family,
                        ai_dest_ret->ai_socktype,
                        ai_dest_ret->ai_protocol)) == -1) {
            perror("[Client] : FAIL: socket()");
            exit(EXIT_FAILURE);
        }
        if (argv[3][0] != '0') {
            int sockopt = 1;
            if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &sockopt, sizeof(sockopt)) == -1) {
                exit(EXIT_FAILURE);
            }
            printf("Socket option = SO_REUSEADDR(on)\n");
        } else {
            printf("Socket option = SO_REUSEADDR(off)\n");
        }
        if (flag_once) {
            /* explicit binding to the previous address */
            if (bind(fd, (struct sockaddr *)&sae_local, len_sae_local) == -1) {
                perror("FAIL: bind()");
                exit(EXIT_FAILURE);
            }
        }
 
        if (connect(fd, ai_dest_ret->ai_addr, ai_dest_ret->ai_addrlen) == -1) {
            perror("FAIL: connect()");
            exit(EXIT_FAILURE);
        }
        /* if first connection */
        if (flag_once == 0) {
            if (getsockname(fd, (struct sockaddr *)&sae_local, &len_sae_local) == -1) {
                perror("FAIL: getpeername()");
                exit(EXIT_FAILURE);
            }
            if ((rc_gai = getnameinfo((struct sockaddr *)&sae_local, len_sae_local,
                            addrstr, sizeof(addrstr), portstr, sizeof(portstr),
                            NI_NUMERICHOST|NI_NUMERICSERV))) {
                perror("FAIL: getnameinfo()");
                exit(EXIT_FAILURE);
            }
        }
        flag_once = 1;
 
        printf("Connection established\n");
        printf("\tLocal(%s:%s) => Destination(%s:%s)\n", addrstr, portstr, argv[1], argv[2]);
        printf(">> Press any key to disconnect.");
        getchar();
        close(fd);
        printf(">> Press any key to reconnect.");
        getchar();
    }
 
    return 0;
}

서버는 테스트 목적이므로 netcat을 이용하면 된다. 즉 nc -k -l 5000으로 띄워놓으면 된다. 그 다음에 다른 터미널을 하나 열어서 watch -n 1 "ss -4tan" 을 명령해둔다. 이렇게 하면 1초마다 자동으로 ss -4tan 명령을 실행하므로 연결을 쉽게 확인할 수 있다.


그런 다음에 위 예제를 실행시킨다. 예제 실행 파일이 tcp_client 라면 ./tcp_client 192.168.10.120 5000 1 로 실행하면 된다. 주소를 192.168.10.120으로 가정했다. 맨 뒤에 1은 SO_REUSEADDR을 켤 것인지를 결정하는 옵션이다. 0이면 끄는 것이고 non-zero이면 SO_REUSEADDR을 켠다. 


이제 접속 후에 엔터를 계속 쳐보면 접속이 끊기고 다시 붙는 것을 볼 수 있다. SO_REUSEADDR 끄고 똑같은 실험을 해보면 바로 주소 재사용에 실패한다.



5. 결론


TIME_WAIT에 대해 인터넷에 돌아다니는 정보 중에는 틀린 내용도 많다. 교과서를 먼저 보자.


그리고 TIME_WAIT는 당신의 서버를 해치지 않는다는 것을 명심하자. 간혹 서버측에서 TIME_WAIT로 인해 재시동시에 socket bind 실패로 에러가 발생하는데, 이것은 SO_REUSEADDR 옵션으로 간단하게 해결 된다. 클라이언트측에서도 SO_REUSEADDR을 사용하여 바인드(explicit binding)가 가능하다. 



* 히스토리

2016.05.21 SO_REUSEADDR의 예제 추가

2016.04.22 SO_REUSEADDR과 TCP TS의 내용 보강

2015.10.08 time wait bucket에 대한 설명 추가


* 레퍼런스



출처: http://sunyzero.tistory.com/198 [IT 지식 창고]