This is a Korean translation of a existing post by Marek Majkowski, translated by Junho Choi.

Spectrum 서버 작업을 하는 동안 이상한 점을 알게 되었습니다. 닫혔어야 할 TCP 소켓이 계속 남아있는 것이었습니다. 사실 TCP 소켓이 언제 타임아웃되는지 제대로 알고 있지 못하다는 점을 알게 된 것입니다!

Tcp_state_diagram_fixed_new.svga

Image by Sergiodc2 CC BY SA 3.0

‌우리 코드에서는 죽은 서버에 대한 연결을 계속 갖고 있지 않다는 것을 확인하고 싶었습니다. 초기 코드에서는 TCP 킵얼라이브를 켜 두는 것만으로 충분할 거라 생각했습니다만... 그렇지 않았습니다. TCP_USER_TIMEOUT 이라는 새로운 소켓 옵션이 거의 비슷하게 중요하다는 것이었습니다. 게다가 이 기능은 일부 TCP 킵얼라이브 옵션과 연계가 되어 있습니다. 많은 사람들이 이 점을 제대로 이해하지 못하고 있습니다.

이 블로그 글에서는 이 옵션들이 어떻게 동작하는지 보여 주고자 합니다. TCP 소켓이 그 일생의 여러 단계에서 어떻게 타임아웃될 수 있는지와 TCP 킵얼라이브와 사용자 타임아웃이 어떤 영향을 주는지 알아볼 것입니다. TCP 연결의 내부를 잘 묘사하기 위해서 tcpdumpss -o 명령의 출력을 번갈아 사용하겠습니다. 이것은 TCP 연결에서 전송되는 패킷과 파라미터의 변화를 잘 보여 줍니다.

SYN-SENT

제일 간단한 경우부터 시작해 봅시다. 받은 SYN 패킷을 버리는 서버에 연결 시도를 하면 어떻게 될까요?

사용된 스크립트는 Github 에 올려 두었습니다.

$ sudo ./test-syn-sent.py
# 모든 패킷 버림
00:00.000 IP host.2 > host.1: Flags [S] # 첫번째 SYN

State    Recv-Q Send-Q Local:Port Peer:Port
SYN-SENT 0      1      host:2     host:1    timer:(on,940ms,0)

00:01.028 IP host.2 > host.1: Flags [S] # 첫번째 재시도
00:03.044 IP host.2 > host.1: Flags [S] # 두번째 재시도
00:07.236 IP host.2 > host.1: Flags [S] # 세번째 재시도
00:15.427 IP host.2 > host.1: Flags [S] # 네번째 재시도
00:31.560 IP host.2 > host.1: Flags [S] # 다섯번째 재시도
01:04.324 IP host.2 > host.1: Flags [S] # 여섯번째 재시도
02:10.000 connect ETIMEDOUT

네 이건 쉽군요. connect() 시스템 콜 이후 운영체제는 SYN패킷을 보냅니다. 서버에 응답을 보내지 않으면 기본적으로 6번 재시도 합니다. 이는 다음 sysctl 로 변경 가능 합니다:

$ sysctl net.ipv4.tcp_syn_retries
net.ipv4.tcp_syn_retries = 6

TCP_SYNCNT setsockopt() 옵션으로 소켓 당 지정하는 것도 가능 합니다:

setsockopt(sd, IPPROTO_TCP, TCP_SYNCNT, 6);

재시도는 1초, 3초, 7초, 15초, 31초, 63초 시점에서 일어 나는데 (내부의 재시도 타이머는 2초에 시작해서 반복시 두개가 됩니다). 커널이 ETIMEDOUT errno 값을 돌려 주고 연결을 포기할 때 까지 기본적으로 전체 프로세스는 130 초가 소요 됩니다. TCP 연결의 이 시점 에서는 SO_KEEPALIVE 설정은 무시 되지만 TCP_USER_TIMEOUT은 그렇지 않습니다. 만약 이 값을 5000ms 로 지정 한다면 다음과 같은 일이 일어 납니다:

$ sudo ./test-syn-sent.py 5000
# 모든 패킷 버림
00:00.000 IP host.2 > host.1: Flags [S] # 첫번째 SYN

State    Recv-Q Send-Q Local:Port Peer:Port
SYN-SENT 0      1      host:2     host:1    timer:(on,996ms,0)

00:01.016 IP host.2 > host.1: Flags [S] # 첫번째 재시도
00:03.032 IP host.2 > host.1: Flags [S] # 두번째 재시도
00:05.016 IP host.2 > host.1: Flags [S] # 이건 뭘까요?
00:05.024 IP host.2 > host.1: Flags [S] # 이건 뭘까요?
00:05.036 IP host.2 > host.1: Flags [S] # 이건 뭘까요?
00:05.044 IP host.2 > host.1: Flags [S] # 이건 뭘까요?
00:05.050 연결 ETIMEDOUT

사용자 타임아웃을 5초 로 지정 했지만 5번의 SYN 재시도가 일어나는 것을 볼 수 있습니다. 이러한 동작은 아마도 버그일 것입니다 (5.2 커널에서 테스트한 것입니다). 원래는 두개의 재시도가 각각 1초와 3초 시점에서 발송 되고 소켓은 5초 시점에서 만료되어야 합니다. 그런데 실제로는 5초 시점에서 4개의 SYN이 재전송되고 있는데 이것은 말이 되지 않습니다. 어쨌든 한가지는 배울 수 있습니다 - TCP_USER_TIMEOUT은 connect()의 동작에 영향을 줍니다.

SYN-RECV

SYN-RECV 소켓은 어플리케이션 안에 숨겨져 있으며 SYN 큐 안에 미니 소켓으로 존재 합니다. 이전에 SYN와 Accept 큐에 대해서 적은 글이 있습니다. 때때로 SYN 쿠키 기능을 켜게 되면 소켓은 SYN-RECV 상태를 뛰어 넘을 수 있습니다.

SYN-RECV 상태에서 소켓은 SYN+ACK를 지정된 대로 5번까지 재전송할 수 있습니다:

$ sysctl net.ipv4.tcp_synack_retries
net.ipv4.tcp_synack_retries = 5

패킷은 다음과 같이 보입니다:

$ sudo ./test-syn-recv.py
00:00.000 IP host.2 > host.1: Flags [S]
# 이후 패킷은 모두 버림
00:00.000 IP host.1 > host.2: Flags [S.] # 첫번째 SYN+ACK

State    Recv-Q Send-Q Local:Port Peer:Port
SYN-RECV 0      0      host:1     host:2    timer:(on,996ms,0)

00:01.033 IP host.1 > host.2: Flags [S.] # 첫번째 재시도
00:03.045 IP host.1 > host.2: Flags [S.] # 두번째 재시도
00:07.301 IP host.1 > host.2: Flags [S.] # 세번째 재시도
00:15.493 IP host.1 > host.2: Flags [S.] # 네번째 재시도
00:31.621 IP host.1 > host.2: Flags [S.] # 다섯번째 재시도
01:04:610 SYN-RECV 사라짐

기본 설정으로 SYN+ACK은 1초, 3초, 7초, 15초, 31초 시점에서 재전송되며 SYN-RECV 소켓은 64초 시점에서 사라 집니다.SO_KEEPALIVE와 TCP_USER_TIMEOUT 모두 SYN-RECV 소켓에는 영향을 미치지 않습니다.

핸드셰이크 마지막의 ACK

TCP 핸드셰이크에서 두번째 패킷 - SYN+ACK - 을 받은 다음에 클라이언트 소켓은 ESTABLISHED 상태로 이행 합니다. 서버 소켓은 최종 ACK 패킷을 받을 때 까지 SYN-RECV 상태로 남아 있습니다.

ACK를 받지 못한다고 해서 달라지는 건 없습니다 - 서버 소켓은 잠시 후에 SYN-RECV에서 ESTAB로 바뀝니다. 다음과 같습니다:

00:00.000 IP host.2 > host.1: Flags [S]
00:00.000 IP host.1 > host.2: Flags [S.]
00:00.000 IP host.2 > host.1: Flags [.] # 첫번째 ACK, 분실

State    Recv-Q Send-Q Local:Port  Peer:Port
SYN-RECV 0      0      host:1      host:2 timer:(on,1sec,0)
ESTAB    0      0      host:2      host:1

00:01.014 IP host.1 > host.2: Flags [S.]
00:01.014 IP host.2 > host.1: Flags [.]  # ACK 재시도, 분실

State    Recv-Q Send-Q Local:Port Peer:Port
SYN-RECV 0      0      host:1     host:2    timer:(on,1.012ms,1)
ESTAB    0      0      host:2     host:1

여기서 볼 수 있듯이 SYN-RECV는 이전 예와 동일하게  "on" 타이머가 동작 중임을 알 수 있습니다. 여러분은 이 최종 ACK이 정말 중요한 것인지에 대해 논의할 수 있을 것입니다. 이러한 생각은 TCP_DEFER_ACCEPT 기능의 개발로 이어 졌습니다 - 기본적으로 세번째 ACK를 보지 않는 것입니다. 이 플래그가 켜져 있으면 소켓은 실제 데이터가 들어 있는 첫번째 패킷을 받을 때 까지 SYN-RECV 상태로 남아 있게 됩니다:

$ sudo ./test-syn-ack.py
00:00.000 IP host.2 > host.1: Flags [S]
00:00.000 IP host.1 > host.2: Flags [S.]
00:00.000 IP host.2 > host.1: Flags [.] # 전달 되었으니 소켓은 SYN-RECV 상태로 남아 있음

State    Recv-Q Send-Q Local:Port Peer:Port
SYN-RECV 0      0      host:1     host:2    timer:(on,7.192ms,0)
ESTAB    0      0      host:2     host:1

00:08.020 IP host.2 > host.1: Flags [P.], length 11  # 실제 데이터를 읽은 뒤 ESTAB로 이행

State Recv-Q Send-Q Local:Port Peer:Port
ESTAB 11     0      host:1     host:2
ESTAB 0      0      host:2     host:1

서버 소켓은 TCP 핸드셰이크의 마지막 ACK를 받은 이후에도 SYN-RECV 상태에 남아 있습니다. 재시도 0회에 머물러 있는 흥미로운 "on" 타이머 상태를 볼 수 있는데, 클라이언트가 데이터 패킷을 보내거나 TCP_DEFER_ACCEPT 타이머가 만료된 후에 이 소켓은  ESTAB 상태로 변화 하고 SYN 큐에서 Accept 큐로 이동 합니다. 기본적으로 DEFER_ACCEPT 기능이 켜져 있으면 SYN-RECV 미니 소켓은 데이터가 없는 수신 ACK 패킷을 버립니다.

유휴 상태 ESTAB은 영원하다

이제 이미 끊어진 서버에 연결된 소켓에 대해서 이야기 해 봅니다. 핸드쉐이크 완료 이후에 소켓은 서버 클라이언트 양쪽 모두 ESTABLISHED 상태로 이행 합니다:

State Recv-Q Send-Q Local:Port Peer:Port
ESTAB 0      0      host:2     host:1
ESTAB 0      0      host:1     host:2

이 소켓에는 기본적으로 타이머가 존재하지 않습니다. 실제 연결이 끊어졌다고 해도 계속 이 상태에 남아 있습니다. TCP 스택은 어떤 것을 보내려 할 때만 문제를 감지합니다. 그렇다면 의문이 생깁니다 - 만약 이 연결에 어떤 데이터도 보내지 않는다면 어떻게 될까요? 데이터를 보내 보지 않고 유휴 연결이 정상인지 어떻게 확인 할까요?

여기서 TCP 킵얼라이브가 사용 됩니다. 이 예제 에서는 다음 설정을 사용 합니다:

  • SO_KEEPALIVE = 1 - 킵얼라이브를 사용
  • TCP_KEEPIDLE = 5 - 5초 쉰 뒤에 첫번째 킵얼라이브 탐색 패킷을 보냄
  • TCP_KEEPINTVL = 3 - 3초 뒤에 두번째 킵얼라이브 탐색 패킷을 보냄
  • TCP_KEEPCNT = 3 - 세번의 탐색 패킷이 실패하면 타임 아웃
$ sudo ./test-idle.py
00:00.000 IP host.2 > host.1: Flags [S]
00:00.000 IP host.1 > host.2: Flags [S.]
00:00.000 IP host.2 > host.1: Flags [.]

State Recv-Q Send-Q Local:Port Peer:Port
ESTAB 0      0      host:1     host:2
ESTAB 0      0      host:2     host:1  timer:(keepalive,2.992ms,0)

# 이후 패킷은 모두 버림
00:05.083 IP host.2 > host.1: Flags [.], ack 1 # 첫번째 킵얼라이브 탐색 패킷
00:08.155 IP host.2 > host.1: Flags [.], ack 1 # 두번째 킵얼라이브 탐색 패킷
00:11.231 IP host.2 > host.1: Flags [.], ack 1 # 세번째 킵얼라이브 탐색 패킷
00:14.299 IP host.2 > host.1: Flags [R.], seq 1, ack 1

‌‌그렇습니다! 5초 시점에서 첫번째 탐색 패킷을 보낸 것을 볼 수 있고 나머지 두개는 3초씩 떨어져 있습니다 - 앞에서 정의한 그대로입니다. 3개의 탐색 패킷을 보낸 뒤에 다시 3초 뒤에 연결은 ETIMEDOUT으로 종료 되고 마지막으로 RST가 전송 됩니다.

킵얼라이브가 동작하기 위해서는 송신 버퍼가 비어 있어야 합니다. "timer:(keepalive)" 행에서 킵얼라이브 타이머가 동작 중임을 알 수 있습니다.

TCP_USER_TIMEOUT와 같이 킵얼라이브를 쓰면 혼동스럽다

TCP_USER_TIMEOUT 옵션에 대해서 언급한 적 있습니다. 이 옵션은 커널이 연결을 강제로 끊기 전에 데이터가 승인 받지 않은 상태로 남아 있을 최대의 시간을 지정 합니다. 유휴 연결의 경우에는 큰 영향이 없습니다. 소켓은 연결이 끊어져도 ESTABLISHED 상태로 남아 있을 것입니다. 하지만 이 소켓 옵션은 TCP 킵얼라이브의 동작 방식을 변경 합니다. tcp(7) 매뉴얼 페이지는 다소 애매합니다:

또한 TCP 킵얼라이브 (SO_KEEPALIVE) 옵션과 같이 사용하게 되면 TCP_USER_TIMEOUT은 킵얼라이브 실패로 인한 연결 종료 시에 킵얼라이브 값을 덮어쓴다.

원래의 커밋 메시지가 좀 더 자세합니다:

동작 방식을 이해하기 위해 커널 코드 linux/net/ipv4/tcp_timer.c:693 를 봅시다:

if ((icsk->icsk_user_timeout != 0 &&
    elapsed >= msecs_to_jiffies(icsk->icsk_user_timeout) &&
    icsk->icsk_probes_out > 0) ||

사용자 타임아웃이 효과를 발휘하기 위해서는 icsk_probes_out 가 0이면 안됩니다. 사용자 타임아웃 검사는 첫번째 탐색 패킷을 발송한 뒤에만 이루어집니다. 한번 살펴 봅시다. 다음의 연결 설정을 사용합니다:

  • TCP_USER_TIMEOUT = 5*1000 - 5 초
  • SO_KEEPALIVE = 1 - 킵얼라이브를 사용
  • TCP_KEEPIDLE = 1 - 1초의 쉬는 시간 뒤에 첫번째 탐색 패킷을 바로 보냄
  • TCP_KEEPINTVL = 11 - 이후 탐색 패킷은 매 11초 마다 보냄
  • TCP_KEEPCNT = 3 - 타임아웃 되기 전에 3개의 탐색 패킷을 보냄
00:00.000 IP host.2 > host.1: Flags [S]
00:00.000 IP host.1 > host.2: Flags [S.]
00:00.000 IP host.2 > host.1: Flags [.]

# 이후 패킷은 모두 버림
00:01.001 IP host.2 > host.1: Flags [.], ack 1 # 첫번재 탐색 패킷
00:12.233 IP host.2 > host.1: Flags [R.] # 두번째 탐색 패킷 타이머가 종료되지만 TCP_USER_TIMEOUT으로 인해 소켓 종료

이제 어떻게 되었나요? 이 연결에서 첫번째 킵얼라이브 탐색 패킷은 1초 시점에서 보냈습니다. TCP 스택에서 응답이 없다 11초 뒤에 두번째 탐색 패킷을 보냈습니다. 이 시점에서 USER_TIMEOUT 체크가 이루어 지고 따라서 연결이 바로 종료 됩니다.

TCP_USER_TIMEOUT을 더 큰 값, 즉 두번째와 세번째 탐색 패킷 사이 정도로 높인다면 어떻게 될까요? 이 경우 연결은 세번째 탐색 패킷 타이머에서 종료 됩니다. TCP_USER_TIMEOUT을 12.5초로 지정하면 다음과 같습니다:

00:01.022 IP host.2 > host.1: Flags [.] # 첫번째 탐색 패킷
00:12.094 IP host.2 > host.1: Flags [.] # 두번째 탐색 패킷
00:23.102 IP host.2 > host.1: Flags [R.] # 세번째 탐색 패킷 타이머가 종료되지만 TCP_USER_TIMEOUT으로 인해 소켓 종료

이제 작은 값에서 TCP_USER_TIIMEOUT이 어떻게 동작하는지 보았습니다. 마지막 경우는 TCP_USER_TIMEOUT이 매우 큰 값일 때입니다. 30초로 해 봅시다:

00:01.027 IP host.2 > host.1: Flags [.], ack 1 # 첫번째 탐색 패킷
00:12.195 IP host.2 > host.1: Flags [.], ack 1 # 두번째 탐색 패킷
00:23.207 IP host.2 > host.1: Flags [.], ack 1 # 세번째 탐색 패킷
00:34.211 IP host.2 > host.1: Flags [.], ack 1 # 네번째 탐색 패킷! 하지만 TCP_KEEPCNT는 3입니다!
00:45.219 IP host.2 > host.1: Flags [.], ack 1 # 다섯번째 탐색 패킷!
00:56.227 IP host.2 > host.1: Flags [.], ack 1 # 여섯번째 탐색 패킷!
01:07.235 IP host.2 > host.1: Flags [R.], seq 1 # TCP_USER_TIMEOUT은 일곱번째 탐색 패킷 타이머에서 연결을 종료

킵얼라이브 탐색 패킷이 6개 발송된 것을 볼 수 있습니다! TCP_USER_TIMEOUT이 지정되면 TCP_KEEPCNT은 무시 됩니다. TCP_KEEPCNT가 의미를 갖도록 하자면 TCP_USER_TIMEOUT값은 다음 값보다 약간 작아야 합니다:

TCP_KEEPIDLE + TCP_KEEPINTVL * TCP_KEEPCNT‌

바쁜 ESTAB 소켓은 영원하지 않다

지금까지 연결이 유휴 상태일 때에 대해 살펴 보았습니다. 연결이 송신 버퍼에 승인받지 않은 데이터가 남아 있을 때에는 다른 규칙이 적용 됩니다.

다른 실험을 하나 더 해 봅시다 - 3방향 핸드셰이크 뒤에 모든 패킷을 버리는 방화벽을 설정 합시다. 그리고 나서 한쪽에서는 패킷을 send()로 보내서 송신 도중에 없어지도록 합니다. 이 실험에서는 송신 소켓이 약 16분 뒤에 종료 됩니다:

00:00.000 IP host.2 > host.1: Flags [S]
00:00.000 IP host.1 > host.2: Flags [S.]
00:00.000 IP host.2 > host.1: Flags [.]

# 이후 패킷은 모두 버림
00:00.206 IP host.2 > host.1: Flags [P.], length 11 # 첫번째 데이터 패킷
00:00.412 IP host.2 > host.1: Flags [P.], length 11 # 이른 재전송(early retransmit) 재시도에 포함 안됨
00:00.620 IP host.2 > host.1: Flags [P.], length 11 # 첫번째 재전송
00:01.048 IP host.2 > host.1: Flags [P.], length 11 # 두번째 재전송
00:01.880 IP host.2 > host.1: Flags [P.], length 11 # 세번째 재전송

State Recv-Q Send-Q Local:Port Peer:Port
ESTAB 0      0      host:1     host:2
ESTAB 0      11     host:2     host:1    timer:(on,1.304ms,3)

00:03.543 IP host.2 > host.1: Flags [P.], length 11 # 네번째
00:07.000 IP host.2 > host.1: Flags [P.], length 11 # 다섯번째
00:13.656 IP host.2 > host.1: Flags [P.], length 11 # 여섯번째
00:26.968 IP host.2 > host.1: Flags [P.], length 11 # 일곱번째
00:54.616 IP host.2 > host.1: Flags [P.], length 11 # 여덟번째
01:47.868 IP host.2 > host.1: Flags [P.], length 11 # 아홉번째
03:34.360 IP host.2 > host.1: Flags [P.], length 11 # 열번째
05:35.192 IP host.2 > host.1: Flags [P.], length 11 # 열한번째
07:36.024 IP host.2 > host.1: Flags [P.], length 11 # 열두번째
09:36.855 IP host.2 > host.1: Flags [P.], length 11 # 열세번째
11:37.692 IP host.2 > host.1: Flags [P.], length 11 # 열네번째
13:38.524 IP host.2 > host.1: Flags [P.], length 11 # 열다섯번째
15:39.500 연결 ETIMEDOUT

데이터 패킷은 아래에 설정된 바와 같이 15회 재전송 됩니다.

ip-sysctl.txt 문서에 의하면 다음과 같습니다:

기본값 15는 이론상 약 924.6초의 타임아웃을 의미하며 실효적인 타임아웃의 하한값이 된다. TCP는 이 이론적인 타임아웃 값을 초과하는 첫번째 RTO에서 타임아웃 된다.

이 연결은 약 940초에서 종료 되었습니다. 소켓에 "on" 타이머가 동작하고 있다는 점에 유의하기 바랍니다. SO_KEEPALIVE를 지정하였는지는 중요하지 않습니다 - "on" 타이머가 동작 중이면 킵얼라이브는 유효하지 않습니다.

이 경우에도 TCP_USER_TIMEOUT 은 계속 동작 합니다. 연결은 마지막에 받은 패킷 이후 사용자 타임아웃 시간 뒤에 바로 종료 됩니다. 사용자 타임아웃이 설정 되면 tcp_retries2 값은 무시 됩니다.

0 윈도우 ESTAB은... 영원할까요?

살펴볼 만한 마지막 경우 입니다. 송신 데이터가 많이 있고 수신측은 느린 경우 TCP 흐름 제어가 동작 합니다. 일정 시점에서 수신자는 송신자에게 신규 데이터를 보내지 말 것을 요청 합니다. 이는 앞에서 본 것 보다는 약간 다른 경우 입니다.

이 경우 흐름 제어가 동작 중이고 송신중이나 승인받은 데이터가 없으면 수신자는 송신자에게 "0 윈도우" 알림을 보냅니다. 그리고 나서 송신자는 이 조건이 계속 유효 한지 주기적으로 "윈도우 탐색 패킷"을 보냅니다. 이 실험에서는 단순하게 가능하도록 수신 버퍼 크기를 줄였습니다. 패킷은 다음과 같습니다:

00:00.000 IP host.2 > host.1: Flags [S]
00:00.000 IP host.1 > host.2: Flags [S.], win 1152
00:00.000 IP host.2 > host.1: Flags [.]

00:00.202 IP host.2 > host.1: Flags [.], length 576 # 첫번째 데이터 패킷
00:00.202 IP host.1 > host.2: Flags [.], ack 577, win 576
00:00.202 IP host.2 > host.1: Flags [P.], length 576 # 두번째 데이터 패킷
00:00.244 IP host.1 > host.2: Flags [.], ack 1153, win 0 # 0 윈도우로 송신 제한!

00:00.456 IP host.2 > host.1: Flags [.], ack 1 # 0 윈도우 탐색 패킷
00:00.456 IP host.1 > host.2: Flags [.], ack 1153, win 0 # 아직 0 윈도우임

State Recv-Q Send-Q Local:Port Peer:Port
ESTAB 1152   0      host:1     host:2
ESTAB 0      129920 host:2     host:1  timer:(persist,048ms,0)

패킷 캡처에서 몇가지를 살펴 볼 수 있습니다. 먼저 576 바이트 크기의 데이터 패킷 두개를 볼 수 있고 바로 승인 되었습니다. 두번째 ACK는 "win 0" 알림이 붙어 있습니다. 송신자에게 더 이상 데이터를 보내지 말라고 하는 것입니다.

하지만 송신자는 데이터를 더 보내고 싶어 합니다! 마지막 두 패킷은 첫번째 "윈도우 탐색 패킷"을 보여 줍니다. 송신자는 주기적으로 데이터 없는 "ack" 패킷을 보내어서 윈도우 크기가 변경되었는지를 검사 합니다. 수신자가 계속 응답하는 한 송신자는 이러한 탐색 패킷을 계속 보냅니다.

소켓 정보는 3가지 중요한 정보를 보여 줍니다:

  • 수신자의 읽기 버퍼는 꽉 찼습니다 - 따라서 "0 윈도우" 흐름 제어는 예측대로
  • 송신자의 쓰기 버퍼는 꽉 찼습니다 - 보낼 데이터가 더 있음
  • 송신자에는 다음번 "윈도우 탐색 패킷"까지의 시간을 나타내는 "persist" 타이머가 설정되어 있음

이 글에서는 타임아웃에 관심이 있습니다. 윈도우 탐색 패킷을 잃어 버린다면 어떻게 될까요? 송신자가 알아 차릴까요?

기본적으로 윈도우 탐색 패킷은 15번 재시도 됩니다 - 앞서의 tcp_retries2 설정을 따릅니다.

TCP 타이머는 persist 상태에 있으므로 TCP 킵얼라이브는 동작중이 아닙니다. 윈도우 탐색 중 일 때에는 SO_KEEPALIVE 설정은 의미를 갖지 않습니다.

기대한 대로 TCP_USER_TIMEOUT은 동작 중임을 알 수 있습니다. 사용자 타임 아웃이 킵얼라이브에 미치는 영향과 비슷하게 재전송 타이머가 다 되었을 때만 동작 합니다. 이러한 경우 마지막에 보낸 정상 패킷 이후 사용자 타임아웃에 지정된 시간 이후에 연결은 종료 됩니다.

어플리케이션 지정 타임아웃에 관하여

예전에 흥미로운 이야기를 공유한 적이 있습니다.

우리의 HTTP 서버는 어플리케이션이 관리하는 타임아웃이 초과된 뒤에 연결을 끊었습니다. 이것은 버그였습니다 - 느린 연결은 천천히 제대로 송신 버퍼를 비워야 하고 있었을 텐데 어플리케이션 서버가 그것을 알지 못한 것입니다.

의도하던 것이 아닐 지라도 우리는 느린 다운로드를 강제로 중단 하였습니다. 우리는 클라이언트 연결이 아직 살아 있는지를 확인하고 싶었을 뿐입니다. 이 경우 어플리케이션이 관리하는 타임아웃에 의존하기 보다는 TCP_USER_TIMEOUT을 쓰는 것이 더 나았을 것입니다.

하지만 이것으로 충분하지 않습니다. 클라이언트 스트림은 유효 하지만 전송이 안되어서 연결을 종료할 수 없는 상황에 대해서도 대비책이 있어야 합니다. 이에 대한 유일한 방법은 송신 버퍼의 보내지 않은 데이터를 주기적으로 체크 해서 원하는 속도로 줄어 드는지 보는 것입니다.

인터넷으로 데이터를 보내는 일반적인 어플리케이션의 경우 다음을 추천 합니다:

  1. TCP 킵얼라이브를 설정. 연결이 유휴 상황에서도 데이터 흐름을 유지하기 위해 필요함.
  2. TCP_USER_TIMEOUT을 TCP_KEEPIDLE + TCP_KEEPINTVL * TCP_KEEPCNT 로 설정
  3. 어플리케이션이 관리하는 타임아웃을 사용할 때에는 주의할것. TCP 연결 실패를 알기 위해서는 TCP 킵얼라이브와 사용자 타임아웃을 사용할것. 자원의 여유가 필요하고 소켓이 너무 오래 남아 있기를 바라지 않는다면 소켓이 원하는 전송율로 데이터를 보내고 있는지 주기적으로 검사하는 것을 고려할것. ioctl(TIOCOUTQ) 를 이용할 수 있지만, 이 경우 버퍼에 있는 데이터 (미송신)과 전송중 (미승인) 데이터를 모두 포함. 더 나은 방법은 미송신 데이터 크기만을 알려주는 TCP_INFO tcpi_notsent_bytes 인수를 사용하는 것임.

‌‌미송신 전송율을 확인하기 위한 예제는 다음과 같습니다:‌‌

while True:
    notsent1 = get_tcp_info(c).tcpi_notsent_bytes
    notsent1_ts = time.time()
    ...
    poll.poll(POLL_PERIOD)
    ...
    notsent2 = get_tcp_info(c).tcpi_notsent_bytes
    notsent2_ts = time.time()
    pace_in_bytes_per_second = (notsent1 - notsent2) / (notsent2_ts - notsent1_ts)
    if pace_in_bytes_per_second > 12000:
        # 전송속도 96Kbps 이상, ok!
    else:
        # 소켓이 너무 느림...

이 로직을 개선할 방법이 있을 것입니다. TCP_NOTSENT_LOWAT을 사용할 수 있겠지만 송신 버퍼가 상대적으로 비어 있을 경우에만 일반적으로 유용 합니다. 그렇다면 데이터가 송신 되었을 때를 알려 주는 SO_TIMESTAMPING 인터페이스를 사용할 수도 있을 것입니다. 마지막으로 소켓에 데이터를 다 보냈다면 그냥 close()를 호출해서 남은 소켓 처리를 운영체제로 넘길 수도 있습니다. 이런 소켓은 완전히 비워질 때 까지 FIN-WAIT-1 이나 LAST-ACK 상태로 남아 있을 것입니다.

끝으로

이 글에서는 TCP 연결에서 상대방이 없어졌는지를 알아보는 다섯가지 방법에 대해서 이야기 하였습니다:

  • SYN-SENT: 이 상태의 유지 시간은 TCP_SYNCNTtcp_syn_retries에 의해 제어
  • SYN-RECV: 애플리케이션에는 노출되어 있지 않음. tcp_synack_retries로 제어
  • 유휴 ESTABLISHED 연결에서는 연결이 끊어졌는지 알 수 없음. TCP 킵얼라이브를 사용해야 함
  • 바쁜 ESTABLISHED 연결은 tcp_retries2 설정값을 사용하고 TCP 킵얼라이브는 무시됨
  • 0 윈도우 ESTABLISHED 연결은 tcp_retries2 설정값을 사용하고 TCP 킵얼리브는 무시됨

특히 마지막 두개의 ESTABLISHED 연결은 TCP_USER_TIMEOUT 설정이 가능하지만 이 설정은 다른 경우에 영향을 미칩니다. 일반적으로 말하자면 TCP_USER_TIMEOUT는 마지막 정상 패킷 이후 몇 초 뒤에 연결을 중지할지에 대한 힌트를 제공하는 것입니다. 약간 위험한 설정이기도 한데, TCP 킵얼라이브와 같이 사용한다면 이 값은 TCP_KEEPIDLE + TCP_KEEPINTVL * TCP_KEEPCNT 보다 약간 작아야 합니다. 그렇지 않으면 TCP_KEEPCNT 값을 무시하게 만들 가능성이 있습니다.

이 글에서는 여러가지 네트워크 상태에서 타임아웃 관련된 소켓 옵션의 효과를 보여 주는 스크립트를 제공 하였습니다. tcpdump 패킷 캡처와 ss -o 출력을 같이 놓고 보는 것은 네트워킹 스택을 이해 하는 좋은 방법입니다. 실제 타이머가 "on", "keepalive", "persist" 상태로 보여 지는 반복 가능한 테스트 케이스를 만들어 낼 수 있었습니다. 이것은 추가적인 실험을 위한 매우 유용한 프레임워크입니다.

마지막으로, 원격 호스트가 실제로 살아 있는지 알기 위해 TCP 연결을 튜닝하는 것은 의외로 어렵습니다. 디버깅하는 동안 송신 버퍼 크기와 현재 동작중인 TCP 타이머를 살펴보는 것은 소켓이 실제로 살아 있는지 확인하기 위해 매우 유용하다는 점을 알았습니다. 우리의 Spectrum 어플리케이션의 버그는 결국 잘못된 TCP_USER_TIMEOUT 설정에 있었습니다 - 이것을 설정하지 않으면 큰 소켓 버퍼가 의도하던 것 보다 훨씬 오래 남아 있던 것이었습니다.

이 기사에서 사용된 스크립트는 Github 에서 찾아볼 수 있습니다.

위와 같은 것을 알아내는 것은 여러곳의 클라우드플레어 사무실과의 협력이 있어서 가능 했습니다. 산호세의 Hiren Panchasara, 오스틴의 Warren Nelson 과 바르샤바의 Jakub Sitnicki 이 도와 주었습니다. 같이 일해 보면 어떨까요? 지원 하세요!