A라는 상태에서 어떤 과정을 거치면 B라는 상태가 될까
TCP는 연결할 때 3-way handshake를 하며, 연결을 끊을 때는 4-way handshake를 진행한다. 여기서 handshake는 악수로 만나면 3번의 과정을 통해 악수를 하고 헤어질 땐 4번의 과정의 악수를 한다고 생각하면 된다. 그럼 TCP 소켓은 어떤 상태로 변화하며 그 상태의 특징은 무엇일까? 또 상태는 어떤 식으로 이동될까?
우선 시작하기에 앞서 TCP는 무엇이며, UDP는 무엇인지 간략하게 알아보자.
A => B 서버로 파일을 전송
A서버는 데이터를 네트워크를 통한 실질적인 전송을 위하여 파일을 쪼개서 나열 (TCP header에)한며, Source Port와 Destination Port 가 포함된 메세지, 이를 세그먼트라고 한다.
전송을 위해 분할된 데이터 조각(세그먼트)에 목적지까지의 전달을 위하여 Source IP와 Destination IP가 포함된 IP Header가 붙은 형태의 메시지, 이것을 패킷이라고 한다
최종적으로 데이터를 전송하기 전에 패킷에 Header(Mac Address 포함)와 CRC를 위한 Trailer가 붙은 메시지를 프레임이라고 한다.
나중에 TCP 분석 시 세그먼트, 패킷, 프레임 에러가 났을 경우 어떤 레벨의 문제인지 파악해야 한다.
TCP는 신뢰성 있는 데이터 전송을 지원하는 연결 지향형 프로토콜이다. UDP와 동일하게 OSI 7 계층 중 L4에 위치하고 있다.
3 way handshake 과정을 통해 연결을 설정하고, 4 way handshake 과정을 통해 해제한다.
데이터 흐름 제어(수신자 버퍼 오버플로우 방지) 및 혼잡 제어(네트워크 내 패킷 수가 과도하게 증가하는 현상 방지)가 가능하다.
높은 신뢰성(Sequence Number, Ack Number를 통한 신뢰성)을 보장한다.
UDP 보다 속도가 느리다.
HTTP 웹 통신, 이메일, 파일 전송에 사용된다.
UDP는 비연결형 프로토콜이다. 동일하게 L4에 위치하고 있다.
비연결형 프로토콜로 데이터그램 방식을 제공한다.
정보를 주고받을 때 정보를 보내거나 받는다는 handshake를 거치지 않는다.
UDP헤더의 CheckSum 필드를 통해 최소한의 오류만 검출한다. 즉, 부하가 적다.
패킷 손실이 발생할 수 있으며, 신뢰성이 낮다.
TCP보다 속도가 빠르다
신뢰성보다는 연속성이 중요한 서비스에서 많이 사용된다.(스트리밍 서비스, 브로드캐스팅 등)
자, 어느 정도 TCP와 UDP에 대한 특징을 간략히 알아보았다. 가장 핵심은 신뢰성의 유무와 속도라고 볼 수 있겠다. 그러면 다시 TCP에 집중하고 알아가 보자.
초면에도 인사 먼저 하듯이, 3-way handshake에 대해서 먼저 알아보자.
모든 TCP 커넥션은 통신을 시작하기 전, 최초의 연결을 위해 3-way handshake를 진행한다.
첫 번째 단계에서는 클라이언트가 서버와 연결을 하기 위하여 SYN 플래그 비트가 설정된 세그먼트를 전송한다(전송층이므로 세그먼트라고 표현한다). 해당 세그먼트는 통신의 시작을 알리며, 세그먼트 내 순서 동기화를 위한 Sequence Number 인 J(임의 명)를 담아 보낸다. SYN(seq=j)
이때 클라이언트는 SYN을 보냈으므로 SYN-SENT 상태에 접어들게 된다.
이 부분은 RTT 값을 구할 수 없기 때문에 RTO를 구할 수 없으므로 별도로 리눅스 자체에서 initRTO라는 것을 지정했으며 1초로 설정해놓았다.(추후 설명 예정)
관련 커널 파라미터 : net.ipv4.tcp_syn_retries
재전송 발생 시 : Connection Timeout
두 번째 단계에서 서버는 클라이언트로부터 SYN을 받았다. 여기서 J값을 확인 후, J+1의 값과 서버 측 Sequence Number 인 K를 생성하여 포함한다. 그러고 나서 J+1과 K가 포함된 SYN+ACK의 플래그 비트가 설정된 세그먼트를 전송한다. SYN+ACK(ack=j+1, seq=k)
이때 서버는 SYN 응답을 받은 상태이기 때문에 SYN-RECEIVED 상태가 된다. 별도로 응답을 본 낸 것에 대해서는 상태를 변화시키지 않는다.
이 부분 역시 RTT 값을 구할 수 없기 때문에 RTO를 구할 수 없으므로 별도로 리눅스 자체에서 initRTO라는 것을 지정했으며 1초로 설정해놓았다.(추후 설명 예정)
관련 파라미터 : net.ipv4.tcp_synack_retries
재전송 발생 시 : Connection Timeout
마지막 단계에서는 클라이언트는 서버의 응답을 수신받았다는 의미로, 서버 측 K+1을 하여 다시 서버로 ACK 플래그가 설정된 세그먼트를 전송한다. 이후 실제 데이터 전송을 할 수 있는 상태인 ESTABLISHED 상태가 된다. ACK(ack=k+1)
서버 역시 클라이언트로부터 ACK를 전달받고 EASTABLISHED 상태로 변한다.
이 부분부터는 한 번의 요청과 응답이 오고 간 상황이므로 RTT 구할 수 있게 되었다. 따라서 수집된 RTT를 기반으로 RTO를 사용한다.
관련 파라미터 : net.ipv4.tcp_retries1, net.ipv4.tcp_retries2
재전송 발생 시 : Read Timeout
이런 3 way handshake 에도 문제가 있을까?
이러한 일반적인 절차에도 공격이 가능하다. 바로 Syn flooding 공격인데, 이는 말 그대로 Syn을 계속해서 보내 Syn 소켓을 담아놓은 저장 창고가 넘치게 하는 공격이다.
그림과 같이 공격자는 서버로 임의의 발신지 주소인 IP를 사용하여 SYN만 계속 보낸다. 서버는 당연히 정상적인 클라이언트로 판단하여 열심히 SYN+ACK를 응답하지만 클라이언트로부터 확인받았다는 'ACK'이 오지 않기 때문에 SYN_RECV 상태의 세션이 계속해서 쌓이게 된다. 이러한 세션이 쌓이면 SYN Backlog가 가득 차게 되며, 결국 더 이상 SYN 소켓 정보를 쌓을 수 없어 SYN Drop이 발생하게 된다.
즉, SYN Backlog가 가득 차 새로운 세션을 생성하지 못하는 문제점이 발생하게 된다.
답은 간단하다. 바로 이럴 때 사용할 수 있는 커널 파라미터가 존재한다. net.ipv4.tcp_max_syn_backlog와 net.ipv4.tcp_synack_retries, net.core.somaxconn이 바로 그 파라미터이다.
net.ipv4.tcp_max_syn_backlog는 이러한 세션을 쌓는 공간의 크기를 설정한다. 이는 SYN_RECV 상태의 소켓들을 좀 더 많이 쌓을 수 있도록 하여 어느 정도 수준의 Backlog도 버텨낼 수 있도록 하는 것이다.
net.ipv4.tcp_synack_retries는 이름에서도 알 수 있듯이 SYN에 대한 응답인 SYN+ACK의 재전송 횟수를 말한다. 기본값은 5로, 상대방이 보낸 SYN에 최대 5번의 SYN+ACK를 보낸 뒤 응답이 없으면 소켓 연결을 끊는 것이다. 이를 통해 SYN_RECV 상태의 소켓 유지를 막을 수 있다.
net.core.somaxconn 도 있지만 이건 나중에 다루도록 하자.
Client가 보낸 SYN에 대한 ACK를 받지 못하면 보냈던 SYN을 다시 보내게 된다. 그리고 이런 과정은 Retransmission Timer라는 커널 타이머에 의해 동작하게 된다. 이러한 TCP 재전송은 보냈던 패킷을 다시 보내는 것이므로 성능 저하를 가져올 수밖에 없지만, 신뢰성이 특징인 TCP에서는 필수이다. 앞서도 설명했지만 RTO와 RTT가 이러한 재전송에 가장 큰 역할을 한다.
그러면 RTO와 RTT가 뭘까?
응답이 오지 않을 때 재전송을 하기까지의 기다릴 시간
요청을 보낸 직후, 응답을 수신받기까지의 시간
이를 통해 알 수 있는 것이 있다. RTT > RTO 가 된다면 재전송이 일어날 수밖에 없다는 것이다.
당연히 기다리는 시간보다 응답 시간이 더 길면 재전송을 하는 것이다. 그러므로 RTT < RTO여야만 한다.
그러면 RTT와 RTO는 어떻게 구할 수 있을까?
RTO는 RTT를 기반으로 설정되어야 한다. 위에서 언급한 것처럼 RTO는 오고 가는 시간을 알아야지 timeout 시간을 정할 수 있기 때문이다. 그렇다면 우선 RTT를 먼저 알아야 한다.
처음 3 way handshake에서, SYN과 SYN+ACK는 양측에서 서로 처음 보내는 세그먼트이다. 따라서 해당 과정이 지난 마지막 ACK때가 돼서야 RTT가 계산이 가능하다. 그러면 SYN, SYN+ACK에서는 RTT값을 어떻게 알까? 그건 바로 리눅스가 자체적으로 InitRTO라는 값을 제공하며 이는 1초로 지정되어있다. 즉, RTT를 알 수 없으므로 지정된 RTO 값인 InitRTO를 사용하겠다는 것이다.
그러면 RTO는 어떻게 설정할 수 있으며 얼마 큼이 적당할까?
RTO는 RTT를 기반으로 위와 같은 계산식을 통해 동적으로 생성이 되는데, 이 외에도 RTO_MIN 값을 통해 설정이 가능하다.
단어 그대로 RTO의 최솟값을 의미한다. 별도로 설정해서 쓰지 않는다면 디폴트로 200ms로 되어 있다. 이는 RTT가 아무리 작아도 RTO값은 200ms 밑으로 내려갈 수 없음을 의미한다.
(Linux에서는 ss -i 명령을 이용해서 현재 통신 중인 세션의 RTO 값을 확인할 수 있다)
RTO_MIN 변경은 ip route 명령을 통해 할 수 있다.
ip route change default via <default gw> dev <ethernet> rto_min 100
그러면 이러한 RTO_MIN은 얼마가 적당할까?
내부적으로 통신하는 서버에서는 200ms가 큰 값이다. 내부 통신은 RTT 가 매우 짧기 때문이다. 따라서 RTO_MIN 값을 이에 상응하는 수준으로 낮춰 서비스 품질을 높일 수 있다. 단, 너무 낮다면 잦은 TIMEOUT으로 인한 재전송이 있을 수 있으므로 적당한 수준으로 조정해야 한다.
애플리케이션 TIME OUT
Connection Timeout은 최초 Handshake 과정에서의 SYN, SYN+ACK 에서 InitRTO를 사용할 때 재전송이 일어나면 발생하는 에러이다. 위에 설명한 것처럼 InitRTO의 경우는 1초로 설정되어 있기 때문에 애플리케이션의 타임아웃은 1초보다 큰 값으로 설정해야 한다. 이미 맺어진 세션에서의 재전송은 RTT를 기반으로 생성되기 때문에 대부분 1초를 넘기지 않겠지만, TCP Handshake를 맺는 과정에서의 재전송은 최소 1초는 소요되기 때문에 애플리케이션에서 타임아웃을 1초로 설정한다면 재전송 시도 하기도 전에 타임아웃 에러로 끊어 버리게 된다.
즉, 내가 보낸 SYN(1초) + 상대방 SYN+ACK(1초) 보다 더 큰 값이 필요하다. 따라서 최소한 1번 이상의 재전송을 할 수 있도록 3초 이상으로 설정하는 것이 좋다.
이미 맺어진 세션을 통해서 데이터를 주고받는 과정에서 재전송이 발생하면 발생하는 에러이다. 이는 일반적인 RTT를 기반한 RTO로 동작하기 때문에 SYN, SYN+ACK를 제외한 나머지에 해당한다. RTO가 최소 200ms 이므로 최소 1번 이상의 재전송을 할 수 있도록 200ms보다 더 큰 300ms 정도로 하는 것이 좋다. 이역시도 RTO보다 짧게 설정할 경우 재시도도 하기 전에 연결을 끊어버리게 된다.
커넥션 풀 방식으로 네트워크 세션을 미리 만들어 두고 통신하는 경우에는 InitRTO가 발생할 일이 없기 때문에 더 작은 값으로 설정해도 된다.
그럼 이러한 timeout 관련된 파라미터는 무엇이 있을까?
[centos@ip-doz]$ sysctl -a | grep -i retries
net.ipv4.tcp_orphan_retries = 0
net.ipv4.tcp_retries1 = 3
net.ipv4.tcp_retries2 = 15
net.ipv4.tcp_syn_retries = 6
net.ipv4.tcp_synack_retries = 5
가장 첫 번째 orphan은 4-way handshake에서 다루도록 한다. 또한, tcp_synack_retries는 위 SYN Flooding에서 다뤘으니 나머지 3개에 대해서 알아보자.
SYN에 대한 재시도 횟수를 결정한다. SYN을 보낸 후 InitRTO로 1초씩 대기하면서 총 6번을 전송하게 된다.
즉, SYN 보내고 응답이 없다면 InitRTO로 1초 대기 -> tcp_syn_retries에 지정된 횟수 반복
1번은 IP 레이어의 네트워크가 잘못됐는지 확인하며, 2번은 더 이상 통신할 수 없다고 판단하는 기준이 된다.
즉, 3번 실패 시 IP 레이어의 네트워크가 잘못됐다고 판단하며 15번 실패 시 통신을 할 수 없다고 판단한다.
결론적으론 2번에 정의된 수만큼 재전송 후 연결이 끊어진다.
위 파라미터들을 보면 알 수 있듯이, SYN과 SYN+ACK는 별도로 재전송 횟수를 지정하며 이외의 전송 간 혹은 ACK에 대해서는 다른 옵션 없이 공통된 tcp_retries2를 따른다는 것을 알 수 있다. (orphan도 FIN-WAIT1에서 해당되는 것이지만 뒤에서 다루도록 한다.)
이제 TCP 연결 해제, 작별 인사를 공부해보자.
TCP 소켓을 통한 데이터 전송이 완료된 후, 서버와 클라이언트는 연결 해제를 위한 4-way handshake를 진행하게 된다.
사실 이는 서버와 클라이언트 간 구분은 없다. 먼저 서버를 끊는 쪽에서 FIN 신호를 보내기 때문이다. 그래도 절차적으로 나열해보도록 하자. 여기서는 요청을 한쪽을 Active_closer, 요청을 받은 쪽을 Passive_closer로 하겠다.
Active_closer에서 그만 소켓을 끊기 위하여 Passive_closer에게 FIN 세그먼트를 전송하게 된다. 시퀀스 넘버인 x를 전달한다. FIN(seq=x)
상태
Active_closer에서는 Passive_closer의 종료 요청 확인을 기다린다는 의미로 FIN-WAIT1 상태로 접어들게 된다. Passive_closer은 Active_closer과의 소켓 종료 세그먼트를 전달받았으므로, Passive_closer으로 자신의 소켓 프로세스에게 종료를 요청하며 기다리는 CLOSE-WAIT 상태에 접어든다.
이때, Active_closer의 FIN-WAIT1 상태는 소켓이 프로세스로부터 회수되어 커널이 처리하는 단계에 진입한 상태이다. 이러한 상태의 소켓은 어느 프로세스에도 바인딩되지 않은 orphan socket으로 불리어진다. 커널은 이러한 소켓을 처리할 수 있도록 net.ipv4.tcp_orphan_retries(앞에서 언급)라는 커널 파라미터를 지정해놓았다.
Active_closer 가 종료의 의미로 FIN 세그먼트를 전송했음에도 불구하고 계속해서 응답이 없을 경우 orphan socket은 쌓이게 될 것이다. 그러므로 해당 커널 파라미터를 사용하는데 기본값은 0이다. 사실은 0은 아예 재전송을 하지 않는다는 의미가 아니라 커널의 코드 내부 함수에 의해 강제로 8로 변경되게 된다. 즉, 8번의 FIN 세그먼트 전송을 재시도를 진행한 뒤 소켓을 끊는다는 의미이다. 따라서 커널에 의해 영향받지 않는 설정 가능 최소 값은 1이 된다. 하지만 1로 하게 될 경우, Active_closer에서 FIN 패킷이 유실됐어어도 재전송을 2회(+1 회를 한다) 진행 후 빠르게 소켓을 닫아버린다. 이렇게 되면 Passive_closer이 되는 쪽에선 소켓이 닫히지 않는 문제점이 발생할 수 있다. (7이 적당하다)
Active_closer : FIN-WAIT1
Passive_closer : CLOSE-WAIT
그러면 FIN-WAIT1에서만 발생하는 건가? FIN-WAIT2 도 있는데?
잘 보면 FIN-WAIT1을 제외하곤 응답 말곤 먼저 보내는 패킷이 없다. orphan socket은 연결 끊을 대상이 되는 쪽에서 요청에 대한 응답이 없을 경우에 발생하는 현상이므로, 다른 부분은 해당하지 않는 것이다.
Passive_closer은 종료 신호를 받아 내부적으로 소켓을 종료하겠다는 의미로 FIN에 대한 ACK를 Active_closer 쪽으로 보내게 된다. ACK(ack=x+1)
상태
ACK를 받은 Active_closer은 종료 신호를 수신했다는 세그먼트를 전달받았으므로 이제 Passive_closer이 소켓을 종료했다는 신호를 기다리게 된다. 이러한 상태를 FIN-WAIT2라고 한다.
Active_closer : FIN-WAIT2
Passive_closer : CLOSE-WAIT (내부 소켓 종료까지 유지)
Passive_closer에서 내부적으로 socket이 정상적으로 close() 되었으면, 이를 Active_closer 측에 알려주고자 FIN 신호를 보내게 된다. FIN(seq=y)
상태
이제 Passive_closer 은 '소켓을 정상적으로 종료했다'는 것을 Active_closer 가 확인했다는 마지막 응답 ACK 만 받으면 Closed 될 수 있다. 따라서 LAST-ACK 상태로 변하게 된다.
반대로, Active_closer에서는 정상적으로 종료됐다는 신호를 전달받았다. 이에 확인했다는 의미로 ACK를 보내게 되는데 이때 ACK가 유실되어 요청 수신 측에서 소켓이 제거되지 않는 문제점이 발생할 수 있다. 그러므로 일정 시간 동안의 시간을 대기하는 TIME-WAIT 상태에 접어들게 된다. 일정 시간 경과 후 socket을 종료하게 된다.
Active_closer : TIME-WAIT
Passive_closer : LAST-ACK
Passive_closer로부터 `소켓이 정상 종료되었다`는 신호를 받았음을 응답하는 ACK를 보낸다. ACK(ack=y+1)
이에 대해 Active_closer로부터 자신의 FIN 신호에 대한 응답을 받았으므로 socket을 종료한다.
이제 4 way handshake의 이슈에 대해서 알아보자
time_wait 소켓은 먼저 연결을 끊는 Active closer 쪽에서 발생한다. 즉, 서버에서도 발생할 수 있으며 클라이언트에서도 발생할 수 있다. time_wait 상태의 소켓이 얼마나 있는지 확인은 어떻게 할 수 있을까?
간단히 netstat -napo 명령어를 통해 알 수 있다
(a: 모든 소켓, n:도메인이 아닌 ip, p: 소켓의 PID 프로그램 정보 출력, o: 네트워킹 관련 타이머 출력)
[root@ip-172-31-27-12 centos]# netstat -napo | grep -i time_wait
tcp 0 0 172.24.0.1:55824 172.24.0.2:5044 TIME_WAIT - timewait (44.16/0/0)
소켓은 발신지 IP/PORT, 수신지 IP/PORT의 총 4가지로 이루어져 있으며 유일하다. 따라서, 위의 값을 지닌 소켓은 timewait의 남은 시간 (44.16초)가 지날 때까지 사용될 수 없다. 이러한 소켓이 많아지면 어떤 문제점이 생기게 될까?
여기서 말하는 클라이언트는 일반적인 사용자들 PC 가 아닌, 실제 서버가 클라이언트 역할을 할 때 발생할 수 있는 문제점을 다룬다.
대규모 서비스에서는 웹서버라고 할지라도 다른 서버에 질의하는 경우가 있다. 이 경우, 서버는 또 다른 서버의 클라이언트로 동작하게 된다.
[ 사용자 ] <---> [ 웹 서버 ] <-----> [API 서버]
그러면 여기서 웹 서버와 API 서버 간 통신만을 두고 얘기해보자.
이 통신에서 웹 서버가 API 서버와의 연결을 먼저 끊게 되면 웹 서버 내 time wait 소켓이 발생하게 된다. 이렇게 time wait 소켓이 반복되어 생성되게 되면 가장 먼저 로컬 포트가 고갈되게 된다.
클라이언트는 외부로 요청을 하기 위하여 소켓을 만드는데, 이때 자신의 가용 포트 중 임의로 한 개 선택하여 생성하게 된다. 앞서 설명한 것처럼 해당 소켓은 유일하기 때문에 해당 소켓이 커널로 다시 돌아갈 때까지 사용할 수 없게 되므로, 이러한 time wait 소켓이 늘어나면 사용 가능한 로컬 포트 역시 고갈되게 된다.
해결 방법
이러한 로컬 포트 고갈 문제를 해결할 수 있는 방법은 두 가지가 있다.
net.ipv4.tcp_tw_reuse는 사용할 수 있는 로컬 포트 수가 모자라면, 현재 TIME_WAIT 상태의 소켓 중 프로토콜상 사용해도 무방해 보이는 소켓을 재사용한다. 0과 1로 이루어져 있으며 0은 disabled, 1은 enable을 나타낸다.
로컬 포트를 reuse를 하는 것은 임시방편책이다. 이에 근본적인 원인을 해결하는 방법으로 Connection Pool 방식이 존재한다. HTTP는 connection less 방식으로 요청할 때마다 소켓을 새로 생성한다. 하지만 Connection Pool 방식은 미리 소켓을 열어놓고 해당 소켓을 통해 데이터를 주고받는 방식이다. 이를 통해 불필요한 TCP의 연결 맺기와 끊기가 없어지므로 더 빠른 응답 속도가 가능하다.
Connection Pool 방식은 애플리케이션을 수정해야 한다는 단점이 존재하지만, 로컬 포트의 무분별한 사용을 막을 수 있으며 서비스의 응답 속도도 향상할 수 있다는 점에서 가능한 한 사용하는 것이 좋다.
서버는 클라이언트와 달리 소켓을 열어 놓고 클라이언트의 요청을 받아들이는 입장이기에 로컬 포트 고갈은 발생하지 않는다. 그렇다면 이러한 TIME_WAIT 소켓이 많아지면 문제가 될까? 결론부터 말하면 큰 문제가 되지는 않는다. 그러면 어떤 문제가 있을까?
바로 net.ipv4.tcp_max_tw_buckets라는 파라미터와 연관되어 있다. 해당 파라미터는 TIME_WAIT 상태의 소켓 개수를 제한하는 파라미터이다. 만약 해당 수치보다 더 많은 TIME_WAIT 상태의 소켓이 생성되면 어떻게 될까? 이미 이 설정값만큼의 TIME_WAIT 상태의 소켓이 있다면, TIME_WAIT 상태로 전이되어야 할 소켓은 더 이상 대기하지 않고 파괴(destroy)되어 버린다. 즉, gracefully shutdown하지 않고 과격하게 닫혀버리면서 서버에서 클라이언트로 아직 보내지지 않은 데이터도 즉시 사라지게 된다.
또한, time_wait이 많다는 것은 불필요한 연결 맺기와 끊기가 많다는 의미로 받아들이고 이를 해결할 방법을 찾는 것이 좋다.
해결 방법
앞서 설명한 과격한 닫힘을 해결하기 위해서는 net.ipv4.tcp_max_tw_buckets은 적당히 상향시킴으로써 해결할 수 있다.
$ sysctl net.ipv4.tcp_max_tw_buckets
net.ipv4.tcp_max_tw_buckets = 65536
해당 커널 파라미터는 서버 입장에서 TW상태의 소켓을 빠르게 회수하고 재활용할 수 있도록 하는 파라미터이다. tw_recycle은 TIME_WAIT의 시간을 RTO 기반으로 변경하게 되며, 이러한 RTO는 ms 단위이므로 TIME_WAIT에 빠진 소켓은 눈 깜짝할 사이에 사라지게 된다.
하지만 이러한 net.ipv4.tcp_tw_recycle 옵션은 큰 문제점이 있다.
클라이언트가 NAT(network address translation) 또는 LoadBalancer, 방화벽 환경일 경우 여러개의 클라이언트는 동일한 발신지와 포트를 갖고 있게 되기 때문이다.
예를 들어, 어떤 소켓이 있고 active closing 되었다고 하자. TIME_WAIT 상태에서 매우 짧은 시간 머무른 후 삭제될 것이다. 이후 같은 발신지 IP / PORT로부터 연결 요청이 와서 새로운 연결이 맺어졌다. 근데 이때 sequence number가 역전된 패킷이 도착했다. 여기서 서버는 이 패킷이 이전에 연결이 끊어진 소켓에 대한 패킷인지, 지금 연결이 맺어진 소켓에 대한 패킷인지 알 수 있을까?
이러한 문제를 해결 하기 위하여 timestamp를 사용한다. TIME_WAIT 상태에 접어든 소켓은 해당 주소와 timestamp 값을 기록해놓는다. 이후 같은 주소로 들어온 요청에 대해서는 timestamp 값일 비교하게 되며, 동일 주소인데 연결을 종료한 시간보다 적은(더 이른) 시간의 패킷이 들어오면 Drop시켜버리게 된다. 따라서 동일 LB에 묶이거나 NAT로 구성된 클라이언트 들을 대상으로 한 웹서버는 net.ipv4.tcp_tw_recycle 옵션을 켜서는 안 된다.
keepalive는 한번 맺은 세션을 요청이 끝나더라도 유지해주는 기능이다. 이렇게 되면 패킷을 보내고 일정 시간이 흐른 뒤 다시 패킷을 보낼 때 종료가 되어있지 않기 때문에, 별도의 종료 및 연결을 위한 handshake가 필요하지 않게 된다. 즉, 불필요한 연결과 끊음을 생략하는 것은 속도적인 측면에서도 빠른 응답을 가능하게 하며, 4 way handshake를 하지 않으므로 time_wait 소켓을 줄일 수 있게 된다.
TCP KeepAlive는 일정 시간이 지나면 연결된 세션에서 필요한 쪽이 먼저 아주 작은 크기의 살아있는지 확인하는 TCP Keep Alive 패킷을 전송한다. 이 패킷을 주고받은 다음에는 다시 keepalive 카운트가 초기화된다. 즉, 서버나 클라이언트 둘 중 하나라도 이 기능을 사용하면 연결이 유지된다.
해당 소켓이 KeepAlive를 지원하는지 확인하기 위해서는 netstat 명령어를 사용해서 확인 가능하다.
[root@ip-172-31-27-12 centos]# netstat -napo | grep -i keep
tcp 0 0 172.31.27.12:22x.x.x.x:53920 ESTABLISHED 24269/sshd: centos keepalive (644.39/0/0)
이 처럼 ESTABLISHED 상태에서는 keepalive 타이머를 확인할 수 있다. 현재 sshd 데몬이 사용하는 소켓이 보이며 타이머가 644초가 남아있는 상태이다. 해당 타이머가 다 되면 연결이 살아있는지 확인하는 timealive 패킷이 전달되게 된다.
그러면 어떻게 활성화시킬 수 있을까? TCP 경우에는 setsockopt()에 SO_KEEPALIVE를 추가하면 된다.
그러면 이제 좀 더 디테일한 Keep alive 설정을 해보자. 우선 커널 파라미터에 대해서 알아보도록 하자.
keep alive와 관련된 커널 파라미터는 다음과 같이 3가지가 존재한다.
keepalive 커널 파라미터
net.ipv4.tcp_keepalive_time
TCP 세션에 대해 keepalive를 유지하는 시간 (Default : 7200)
net.ipv4.tcp_keepalive_probes
keepalive가 끊어졌다고 판단하고 세션을 정리하는 동안 보낼 재전송 패킷 수 (Default : 9)
net.ipv4.tcp_keepalive_intvl
첫 번째 health_check 이후 재전송 패킷을 보내는데 걸리는 패킷 사이의 주기 (Default : 75)
이 세 가지를 합치면 다음과 같이 설명할 수 있다.
최초 tcp_keepavlie_time 동안 기다린 후 keepalive 확인 패킷을 보내게 된다. 이 패킷에 대한 응답이 오지 않게 된다면, tcp_keepalive_intvl 간격으로 tcp_keepalive_probes 번의 패킷을 더 보내도록 한다.
서버단에서 KeepAlive, 장단점은?
TCP keepalive의 장점은 명확한 두 가지가 존재한다.
첫 번째로, 불필요한 연결 맺고 끊음을 최소화할 수 있다는 것이다. Handshake를 줄임으로써 전체적인 서비스의 응답 속도가 빨라지게 되며 이는 곧 서비스 품질 향상으로 이어지게 되는 것이다.
두 번째로, 좀비 커넥션으로 불리는 잘못된 커넥션 유지 소켓을 방지할 수 있게 된다. keepalive가 해제된 상태에서 FIN 세그먼트가 유실되었을 경우 상대방 측은 연결이 끊겼는지 알 방법이 없다. 결국 상대방 측에서는 EASTABLISHED 상태로 계속 유지하게 되는 것이다. 하지만 keepalive 옵션을 사용하게 될 경우, 주기적인 keepalive 패킷을 전송하게 되므로 net.ipv4.tcp_keepalive_time 만큼 대기 후 응답이 오지 않을 경우 재전송 후 연결(수신자 측은 연결을 끊었기 때문에 응답이 아닌 Drop을 시킨다.)을 종료시키기 때문에 이러한 좀비 커넥션을 방지할 수 있다.
튜닝되지 않은 keepalive의 사용은 처리 가능한 웹서버 스레드의 부족 현상을 일으킬 수 있다. 예를 들어 1000개의 워커가 떠 있는 apache 서버에 1000개의 세션이 keepalive를 켜고 들어오게 되면 1001 번째 세션은 처리를 받아줄 워커가 없기 때문에 웹서버 스레드가 부족해지고, 이는 서비스의 전체적인 응답 속도 저하를 가져올 수 있다. 그렇기 때문에 트래픽과 request 기반의 정확한 측정을 통해서 적당한 값의 keepalive timeout 을 설정해 주어야 한다.
TCP keepalive와 HTTP keepalive 차이점
TCP keepalive는 앞서 설명한 것처럼 두 종단 간의 연결을 유지하기 위하여 특정 시간 단위로 주기적으로 패킷을 보내며 연결 타임아웃을 초기화하지만, HTTP keepalive는 특정 시간 동안 기다린 뒤 요청이 없으면 재시도 없이 끊어 버린다. HTTP keepalive는 웹 서버에서 설정하게 되는데, 설정하게 되면 해당 시간 동안 대기 후 서버가 먼저 FIN 신호를 보내 정리하게 된다.
그렇다면 TCP keepalive와 HTTP keepalive 두 개 다 설정하면 어떻게 될까?
앞서 설명한 포인트를 생각해보면 알 수 있다. TCP keepalive는 최초 keepalive time이 지나면 intvl 시간을 대기 후 probes 만큼 재전송하게 된다. 하지만 HTTP keepalive는 해당 시간 동안 유지 후 연결이 없으면 먼저 FIN 신호를 보내 연결을 끊는다. 다음과 같은 시나리오를 확인해보자.
1. 클라이언트가 서버와 연결을 완료 한 뒤 데이터를 주고받는다. 이후 아무런 요청을 하지 않는다.
2. 서버 쪽 TCP keepalive가 30초가 지났으므로 클라이언트에게 keepalive 패킷을 전송한다.
3. 클라이언트는 keepalive 패킷에 응답한다. keepalive는 타이머는 다시 초기화된다.
4. 다시 30초가 지나 서버 측에서 TCP keepalive 패킷을 전송한다.
5. 클라이언트는 다시 keepalive 패킷에 응답한다. keepalive 타이머는 다시 30초를 카운트한다.
6. 하지만, 웹 서버는 HTTP keepalive time인 60초가 지났으므로 클라이언트로 FIN 세그먼트를 보낸다. [연결 종료]
1. 클라이언트가 서버와 연결을 완료 한 뒤 데이터를 주고받는다. 이후 아무런 요청을 하지 않는다.
2. 서버 쪽 HTTP TCP keepalive가 30초가 지났으므로 클라이언트로 FIN 세그먼트를 보낸다. [연결 종료]
이와 같이 TCP keepalive는 HTTP keepalive와 같이 사용되더라도 결국에는 HTTP Keepalive 설정 값을 기준으로 동작한다.
로드벨런서와 keepalive를 모두 사용하게 될 경우 로드밸런서 측의 idle timeout으로 이슈가 생길 수 있게 된다. 로드 벨런서는 DSR(Direct Server Return) 방식과 inline 방식이 존재한다.
붉은색 선 : 요청
파란색 선 : 응답
이름처럼 DSR은 요청은 로드 밸런서를 거쳐 들어가지만, 응답은 바로 서버의 응답이 클라이언트로 전송되는 구조이다. inline의 경우는 요청과 응답 모두 로드 벨런서를 거쳐 전달되게 되는 방식이다.
로드 벨런서의 경우 서버와 클라이언트가 맺어진 세션 정보를 세션 테이블에 저장하게 된다. 이후 주고받는 요청에 대하여 세션 테이블을 참고하여 올바른 목적지로 데이터를 전송할 수 있도록 한다. 이러한 세션 테이블 역시 무한한 공간이 아니기 때문에 Idle timeout이라는 기능을 통해서 일정 시간 동안 사용하지 않은 세션 정보를 테이블에서 제거하는 기능도 있다. 만일 이 Idle timeout이 10초라면 매 10초라는 시간 안에 패킷 흐름이 없던 세션을 지우는 것이다. 하지만, 이렇게 지울 때는 로드벨런서는 각 종단 간에 어떠한 알림도 전송해주지 않는다.
로드벨런서 DSR 방식으로 다음 시나리오를 생각해보자.
[ 클라이언트 ] --------> [ Load Balancer (DSR) ] --------> [ Server A / B ]
└──<─────────<─────────<──┘
1. 클라이언트가 서버로 요청을 보낸다.
2. 로드 밸런서는 처음 보는 클라이언트이므로 발신지를 적어두고 뒷단에 위치한 서버 중 하나(A)를 선별하여 해당 서버의 수신지 주소를 세션 테이블에 작성한다. 이후 클라이언트 패킷을 서버로 전달한다.
3. A 서버는 SYN 패킷을 받았으므로 클라이언트로 SYN+ACK를 보낸다.
4. ACK를 보내고 데이터를 주고받는다(생략)
5. 패킷이 오고가지 않은 상태로 일정 시간이 지나면 로드 벨런서의 idle timeout에 의해 세션 테이블에서 세션이 지워진다.
6. 클라이언트는 다시 서버로 요청을 보낸다.
7. 로드 밸런서는 등록되지 않은 클라이언트이므로 다시 세션 테이블 작성 후 랜덤으로 B에게 할당한다.
8. B 서버는 이전에 handshake 한 적이 없는데 요청을 받았으므로 RST를 응답한다.
9. 클라이언트는 잘못됐음을 인지하고 새로운 세션을 생성하여 연결한다.
10. A 서버는 이러한 사실을 알 수 없으므로 연결은 계속 ESTABLISHED 상태로 유지된다.
(keepalive를 설정했다면 클라이언트로 패킷을 보낼 것이며, 클라이언트는 응답하지 않으므로 일정 시간 뒤 연결을 종료한다)
어떻게 해결해야 하나
이러한 문제를 막을 수 있는 방법은 간단하다. 바로 idle timeout 시간보다 더 적은 시간으로 tcp keepalive 파라미터들을 세팅하는 것이다. 이렇게 하게 되면 idle timeout 시간 내에 keepalive 패킷이 전송되게 되므로 좀비 커넥션을 방지할 수 있다.
idle timeout이 120초라면, 120초 안에 두 종단 간에 패킷이 흐르도록 다음과 같이 설정한다.
tcp_keepalive_time을 60초, tcp_keepalive_probes를 3, tcp_keepalive_intvl을 10초
이렇게 되면 패킷이 유실되어도 120초 안에 충분히 체크가 가능하게 된다. 그러므로 로드벨런서를 사용한다면 꼭 keepalive를 활성화 하자.
Q. 그러면 inline에서는 발생하지 않는 걸까?
어쩌면 너무 많은걸 담아내려고 했는 것 같다. 가장 중요한 내용이 빠진 것도 같다. 바로 문제가 생기면 바로 tcpdump를 하라는 것이다. 이런 문제가 발생했을 경우 tcpdump를 통해 handshake 과정을 살펴보고, netstat -napo 명령어를 통해 소켓의 상태를 모니터링 함으로써 더 빠른 대처가 가능하다.
추가