TCP, 소켓, 파일 디스크립터는 어떻게 연관되어 있을까?
- 카테고리 없음
- 2024. 8. 31.
들어가기 전
이전 글에서 파일 디스크립터에 대해 알게 되어 공부하던 중 한 가지 의문점이 생겼다.
- TCP 커넥션은 어느 레벨에서 처리가 되는걸까?
- 서버쪽 소켓은 하나만 열리는데, 수백 대의 클라이언트가 요청을 보내면 서버는 하나의 소켓만으로 모든 읽기 / 쓰기 작업을 처리하는 것일까?
- 소켓은 어느 레벨에서 관리되는 것일까?
위 질문에 대해서 알고 싶어서 전반적인 내용을 공부하고, 코드로 작성하고 디버깅하면서 실제로 그런지를 확인해보려고 한다.
클라이언트 - 서버 TCP 연결
- 클라이언트가 데이터를 전송하기 위해 send(), write()를 호출하면 데이터는 클라이언트의 소켓에서 네트워크를 통해 서버로 전달된다. (IP만으로 찾아감)
- 서버의 네트워크 인터페이스(NIC)는 받은 데이터를 서버 운영체제에게 전달한다.
- 서버 운영체제는 수신된 TCP 패킷을 검사한다. 패킷에는 출발지 IP/Port, 도착지 IP/Port 등이 포함되어있고, TCP 헤더에는 SYN / ACK 같은 Flag가 있다.
- 서버 운영체제는 SYN Flag가 있는 것을 보고 서버의 Port 쪽으로 패킷을 전달한다.
- 서버의 Port에는 수신 소켓이 listen을 하고 있다. 수신 소켓은 클라이언트의 소켓과 3 Way Handshake를 시작한다.
- 서버의 수신 소켓이 3 Way Handshake를 완료하면, 서버는 accept() 시스템 콜을 호출해서 연결된 소켓(Connected Socket)을 만든다. 이후 클라이언트가 보내는 요청은 연결된 소켓에 전달되고, 서버는 연결된 소켓을 통해 클라이언트로부터 요청을 받고 응답한다.
- 클라이언트가 메세지를 보내면 클라이언트의 소켓이 네트워크를 통해 다시 한번 서버에게 메세지를 전달한다. 서버의 네트워크 인터페이스(NIC)는 다시 한번 메세지를 서버의 운영체제에게 전달한다.
- 서버의 운영체제는 TCP 패킷을 확인해서 출발지 + 도착지 IP / Port를 확인한다. 그리고 운영체제는 해당 출발지 IP/Port + 도착지 IP/Port로 관리되고 있는 Connected Socket이 있는지 확인한다.
- 운영체제는 해당 Connected Socket의 수신 버퍼에 메세지를 전달한다.
- 서버 어플리케이션은 수신 버퍼로부터 메세지를 읽는다. 이 때 recv(fd, buffer, size, flags) 같은 시스템 콜을 사용해서 특정 파일 디스크립터에 연결된 소켓의 버퍼로부터 데이터를 읽는다. 그리고 읽어온 데이터의 HTTP / HTTPS 프로토콜을 분석한다.
- 그리고 응답을 하기 위해 send(), write() 시스템 콜을 이용해 메세지를 전달한다. 이 때, 어플리케이션은 write(fd, "Hello Client", size, flags) 시스템 콜을 호출해서 파일 디스크립터에 연결된 Connected Socket에 적절하게 메세지를 작성한다.
- 그 메세지는 Connected Socket의 송신 버퍼에 저장되고, 송신 버퍼를 통해 클라이언트의 네트워크 인터페이스로 전달된다.
조사한 바로는 위의 형태로 동작한다. TCP Handshake는 OS 레벨에서 수행되고, 수신 소켓은 특정 Port에 바인딩 되어 들어오는 요청을 기다렸다가 TCP Handshake 이후 Connected Socket을 만들어(새로운 파일 디스크립터도)서 특정 클라이언트에게서 오는 요청을 분리한다는 것이다.
이 때, 네트워크 인터페이스 -> 커널로 요청을 올려줬을 때, 커널은 처음에 TCP 패킷을 검사한다. TCP 헤더에 SYN / ACK가 있으면 새로운 요청이구나!로 판단하고 TCP Handshake를 한다. 그러나 헤더에 SYN / ACK가 없는 경우라면, SRC / DEST Port + IP를 검사해서 이전에 관리되고 있는 소켓이 있는지를 찾아낸다. 그리고 소켓이 있으면 그쪽으로 Network Interface가 전달해준 메세지를 라우팅 하는 형식으로 동작한다고 한다.
소켓 생성과 TCP Connection, Buffer 디버깅 해보기
위에 서술한 내용을 전부 눈으로 확인할 수는 없지만 파이썬에서 제공하는 소켓과 리눅스에서 제공하는 툴을 이용해 확인할 수 있는 부분까지는 확인을 해보려고 한다.
필요한 녀석들
- python > 3.x
- netstat
- lsof
- tcpdump
M1 맥북 기준으로 다음 도구들이 필요하다.
전체 코드
import socket
import os
# 소켓 생성
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('0.0.0.0', 8080))
s.listen(1) # Backlog 1개 허용
# 소켓의 파일 디스크립터 가져오기
fd = s.fileno() # 수신소켓의 파일디스크립터
# 직접 파일 디스크립터를 사용하여 수신 대기
connection, address = s.accept()
client_fd = connection.fileno() # Connected Socket의 파일 디스크립터
print(f"Listen Socket's fd: {fd}, Connected socket's fd: {client_fd}")
# 파일 디스크립터로부터 직접 읽기
read_byte = 0
message = ''
while ((m := os.read(client_fd, 1)) != b'') and read_byte < 10:
read_byte += 1
print(f'read 1 byte from buffer. message: {m.decode()}')
message += m.decode()
print(f"Received message: {message}")
# 파일 디스크립터로부터 직접 쓰기
os.write(client_fd, b"Hello Client")
# 커넥션 끊기
connection.shutdown(socket.SHUT_RDWR)
# 소켓 닫기
for socket_fd in [client_fd, fd]:
os.close(socket_fd)
전체 코드의 흐름은 다음과 같다.
- 수신 소켓을 하나 생성해서 8080에 바인딩하고 accept()를 호출해 요청이 들어올 때까지 기다린다.
- 클라이언트의 요청이 수신 소켓으로 들어오면, accept()가 완료되어 connection이 생성된다. 생성된 Connection은 새로운 소켓(Connected Socket)으로 전달되어 관리된다.
- Connected Socket의 Receive Buffer로 들어오는 메세지를 10바이트까지 읽은 후 출력한다
- 이후 클라이언트에게 Hello Client를 응답하도록, Connected Socket의 Writer Buffer에 쓰기 작업을 한다.
- Connection.shutdown()을 호출해 클라이언트에게 커넥션을 끊을 것을 알린다.
- os.close(fd)를 호출해 서버 쪽의 수신 소켓, Connected 소켓을 닫는다.
아래에서는 위 파이썬 코드에 디버깅 포인트를 주면서, 특정 시점에 어떤 일이 일어났는지를 Step By Step으로 알아가보고자 한다.
STEP #1. 수신 소켓 생성, TCP Handshake 확인
import socket
import os
# 소켓 생성
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('0.0.0.0', 8080))
s.listen(1) # Backlog 1개 허용
# 소켓의 파일 디스크립터 가져오기
fd = s.fileno() # 수신소켓의 파일디스크립터
# 직접 파일 디스크립터를 사용하여 수신 대기
connection, address = s.accept()
- 수신 소켓을 하나 생성해서 8080포트를 listen 하도록 한다.
- Backlog는 TCP 커넥션이 맺어지는 과정(서버가 SYN-ACK한 상태)이거나 맺어졌으나 accept() 되지 않은 커넥션이 대기할 수 있는 대기열의 사이즈를 의미한다.
- 수신 소켓은 생성되었기 때문에 지금 시점에서 수신 소켓의 파일 디스크립터를 확인할 수 있다.
- 수신 소켓에 accpet()를 호출해서 TCP Handshake가 완료된 커넥션을 얻어올 수 있을 때까지 Blocking 된다.
전체 코드에서 이 코드까지 실행했을 때, 커널과 네트워크 레벨에서는 어떻게 되어있을까?
$ sudo lsof -i :8080
>>>
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3.1 39667 user 4u IPv4 0x231be0f09b129adf 0t0 TCP *:http-alt (LISTEN)
$ netstat -tn | grep 8080
>>>
- lsof에서는 수신 소켓이 8080 포트를 LISTEN 하고 있는 것을 볼 수 있다. 그리고 이 때, FD(File Descriptor)가 4번 인 것도 확인할 수 있다.
- netstat에서는 어떤 소켓도 존재하지 않는 것을 알 수 있다.
STEP #2 : 클라이언트(TELNET) 연결 + TCP Handshake
$ sudo tcpdump -i lo0 port 8080
클라이언트 연결을 하기 전에 tcpdump를 실행해 8080번 포트에 어떤 패킷이 오가는지를 확인한다. 우선 현재까지는 아무런 트래픽도 오지 않은 것을 알 수 있다.
$ telnet 127.0.0.1 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
클라이언트 (telnet)을 이용해 127.0.0.1:8080 포트에 접속 요청을 시도한다.
$ sudo tcpdump -i lo0 port 8080
>>>
[TIME] IP localhost.61824 > localhost.http-alt: Flags [S], seq 1459141837, win 65535, ..., length 0
[TIME] IP localhost.http-alt > localhost.61824: Flags [S.], seq 3772530693, ack 1459141838, win 65535, ..., length 0
[TIME] IP localhost.61824 > localhost.http-alt: Flags [.], ack 1, win 6379, ..., length 0
[TIME] IP localhost.http-alt > localhost.61824: Flags [.], ack 1, win 6379, ..., length 0
telnet이 실행되면 아까 실행해두었던 tcpdump에는 4개의 트래픽을 캡쳐링 한 것을 알 수 있다.
- 'S'는 Syn을 의미한다. 즉, 클라이언트 -> 서버로 TCP Handshake의 SYN을 보냈고, sequence number로 1459141837을 보냈다.
- 'S.'는 Syn+Ack를 의미한다. 서버 -> 클라이언트로 1459141837 + 1 = 1459141838을 ACK 한 것을 알 수 있다.
- '.'는 ACK를 의미한다. 클라이언트 -> 서버로 서버의 SYN에 대한 ACK를 한 것을 확인할 수 있다.
즉, 클라이언트가 접속 요청을 보냈을 때 OS 레벨에서 TCP Handshake가 완료된 것을 로그를 통해서 확인할 수 있었다.
...
# 직접 파일 디스크립터를 사용하여 수신 대기
connection, address = s.accept()
client_fd = connection.fileno() # Connected Socket의 파일 디스크립터
print(f"Listen Socket's fd: {fd}, Connected socket's fd: {client_fd}")
...
>>>
Listen Socket's fd: 4, Connected socket's fd: 5
TCP Handshake가 완료되면, s.accept()에서 대기하던 파이썬 코드가 다시 실행된다.
- Connection이 생성된 것을 확인할 수 있다.
- Connection에서 fileno()을 호출해서 File Descriptor를 얻어올 수 있다.
앞에서 이야기 했던 것처럼 수신 소켓은 자신이 listen하는 포트로 들어오는 요청에 대해 TCP Handshake를 완료하고, TCP Handshake가 완료되면 accept() 시스템 콜을 호출해서 새로운 Connected Socket을 만들어준다. 파이썬 로그에서 볼 수 있듯이 수신 소켓의 FD는 4, 새롭게 만들어진 Connected Socket의 FD는 5가 된다.
$ sudo lsof -i :8080
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3.1 39667 user 4u IPv4 0x231be0f09b129adf 0t0 TCP *:http-alt (LISTEN)
python3.1 39667 user 5u IPv4 0x231be0f09b131047 0t0 TCP localhost:http-alt->localhost:52428 (ESTABLISHED)
telnet 39750 user 3u IPv4 0x231be0f0981cbaa7 0t0 TCP localhost:52428->localhost:http-alt (ESTABLISHED)
$ netstat -tn | grep 8080
Active Internet connections
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 127.0.0.1.8080 127.0.0.1.52428 ESTABLISHED
tcp4 0 0 127.0.0.1.52428 127.0.0.1.8080 ESTABLISHED
한편 lsof, netstat을 살펴보자.
- lsof에서는 python에 2개의 소켓이 있고, telent에 1개의 소켓이 있는 것을 확인할 수 있다.
- python의 FD 4u (listen 소켓), FD 5u (Connected Socket)을 확인할 수 있고, Name에서 볼 수 있듯이 5u는 서버 -> telnet 클라이언트로 가능 요청을 보여준다.
- telent이 가진 소켓의 fd 3u인 것을 확인할 수 있다.
- netstat에서도 2개의 커넥션을 확인할 수 있다.
- 127.0.0.1:8080 -> 127.0.0.1:52428 : 서버 -> 클라이언트 방향 소켓
- 127.0.0.1:52428 -> 127.0.0.1:8080 : 클라이언트 -> 서버 방향 소켓
lsof에서 확인한 FD는 위 파이썬 코드에서 print()로 출력했던 FD와 동일하다는 것을 확인할 수 있다.
이 스텝을 정리해보면 다음과 같다.
- 클라이언트가 접속 요청을 하면, 서버의 OS 레벨에서 TCP handshake가 실행된다.
- TCP Handshake가 완료된 후에는 Connected Socket이 생기고, Connected Socket과 Listen Socket은 서로 다른 FD로 관리된다.
STEP #3 : 클라이언ㅌ TELNET으로 메세지 전송
$ telnet 127.0.0.1 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
abcdefghijklmnopqrstu
이제 클라이언트(telnet)에서 서버로 'abcdefghijklmnopqrtu' 메세지를 전송해보자.
$ sudo tcpdump -i lo0 port 8080
>>>
[TIME] IP localhost.61824 > localhost.http-alt: Flags [S], seq 1459141837, win 65535, ..., length 0
[TIME] IP localhost.http-alt > localhost.61824: Flags [S.], seq 3772530693, ack 1459141838, win 65535, ..., length 0
[TIME] IP localhost.61824 > localhost.http-alt: Flags [.], ack 1, win 6379, ..., length 0
[TIME] IP localhost.http-alt > localhost.61824: Flags [.], ack 1, win 6379, ..., length 0
# 추가된 로그
[TIME] IP localhost.61824 > localhost.http-alt: Flags [P.], seq 1:23, ack 1, win 6379, ..., length 22: HTTP
[TIME] IP localhost.http-alt > localhost.61824: Flags [.], ack 23, win 6379, ..., length 0
tcpdump에서는 로그가 2줄 추가된 것을 알 수 있다. 로그를 살펴보면 다음과 같다.
- 클라이언트 -> 서버로 Push(P.) 요청을 보냈다. 길이수는 총 23자다. (seq 1:23, length 23)
- 서버는 클라이언트에게 Ack(.) 한다. 이 때 Ack는 23으로 보냈다. (length는 0) Ack가 23인 이유는 클라이언트가 보낸 마지막 메세지인 23번까지 다 받았다는 것을 의미한다.
여기서 win 6379라는 값도 로그에 찍히는데, 이것은 Recv Buffer의 용량이 얼마나 남았는지를 상대방에게 전달할 때 쓴다. 예를 들어 서버 -> 클라이언트의 TCP 트래픽에 win 6379라고 되어있으면, 서버의 Recv Buffer가 6379 Byte를 더 받을 수 있음을 의미한다. 클라이언트는 이 win 6379라는 값을 참고해 얼마만큼의 데이터를 보낼지를 결정한다.
$ netstat -tn | grep 8080
Active Internet connections
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 23 0 127.0.0.1.8080 127.0.0.1.52428 ESTABLISHED
tcp4 0 0 127.0.0.1.52428 127.0.0.1.8080 ESTABLISHED
한편 netstat으로 상태가 어떤지 확인해보자.
- 서버 -> 클라이언트 소켓의 Recv-Q에 23 Byte가 추가된 것을 볼 수 있다.
tcpdump에 23 Byte의 메세지가 전송되었다는 로그를 확인할 수 있었는데, 그 23 Byte의 메세지는 아직까지 파이썬 코드에서 읽어간 상태는 아니다. 따라서 서버 -> 클라이언트 소켓의 Recv Buffer에 고스란히 23 Byte가 저장되어 있는 것이다. 그리고 이 Buffer는 서버가 read() 할 때까지 유지될 것이다.
여기서 생각해볼만한 부분은 클라이언트 -> 서버로 보내는 메세지를 서버에서 빠르게 읽어가지 못하면, 소켓의 Buffer가 가득차게 될 것이다. 이 때 TCP Flow Control, Congestion Control과 관련된 일련의 메카니즘이 일어나게 될 것이다.
STEP #4: 1바이트만 소켓 버퍼에서 읽기
...
# 파일 디스크립터로부터 직접 읽기
read_byte = 0
message = ''
while ((m := os.read(client_fd, 1)) != b'') and read_byte < 10:
read_byte += 1
print(f'read 1 byte from buffer. message: {m.decode()}')
message += m.decode()
...
>>>
read 1 byte from buffer. message: a
이제 다음 파이썬 코드를 실행시켜보자.
- while 문에서는 os.read(clinet_fd, 1) 코드를 호출해 서버 소켓의 Buffer에서 1바이트의 메세지만 읽어온다.
- 읽어온 메세지를 출력해본다.
클라이언트는 'abcdefghijklmnopqrtu' 메세지를 보냈는데, 1바이트만 읽었으니 가장 앞에 있는 'a'라는 메세지만 읽어온 것을 확인할 수 있다. 그렇다면 정말로 Receive Buffer의 숫자는 줄어들었을까?
# 읽기 전
$ netstat -tn | grep 8080
Active Internet connections
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 23 0 127.0.0.1.8080 127.0.0.1.52501 ESTABLISHED
tcp4 0 0 127.0.0.1.52501 127.0.0.1.8080 ESTABLISHED
# 읽은 후
$ netstat -tn | grep 8080
Active Internet connections
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 22 0 127.0.0.1.8080 127.0.0.1.52501 ESTABLISHED
tcp4 0 0 127.0.0.1.52501 127.0.0.1.8080 ESTABLISHED
읽기 전에는 버퍼에 23 Byte가 있었으나, 1 Byte를 읽은 후에는 22 Byte만 버퍼에 있는 것을 확인할 수 있다.
STEP #5. 10바이트만 읽은 후의 Read Buffer 상태.
...
# 파일 디스크립터로부터 직접 읽기
read_byte = 0
message = ''
while ((m := os.read(client_fd, 1)) != b'') and read_byte < 10:
read_byte += 1
print(f'read 1 byte from buffer. message: {m.decode()}')
message += m.decode()
print(f"Received message: {message}")
# 파일 디스크립터로부터 직접 쓰기
os.write(client_fd, b"Hello Client")
...
>>>
read 1 byte from buffer. message: a
read 1 byte from buffer. message: b
read 1 byte from buffer. message: c
read 1 byte from buffer. message: d
read 1 byte from buffer. message: e
read 1 byte from buffer. message: f
read 1 byte from buffer. message: g
read 1 byte from buffer. message: h
read 1 byte from buffer. message: i
read 1 byte from buffer. message: j
Received message: abcdefghij
파이썬 코드에서는 10 Byte만 읽은 후, 더 이상 소켓 버퍼에 있는 값을 읽지 않도록 코드가 작성되어있다. 실행 결과를 살펴보면 'abcdefghij' 라는 문자열만 딱 읽힌 것을 알 수 있고, 클라이언트가 보낸 총 23 바이트의 데이터 중 서버는 총 10바이트의 데이터만 읽었다.
$ netstat -tn | grep 8080
Active Internet connections
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 12 0 127.0.0.1.8080 127.0.0.1.52731 ESTABLISHED
tcp4 0 0 127.0.0.1.52731 127.0.0.1.8080 ESTABLISHED
Recv Q 버퍼에서도 읽은 Byte 수만큼 버퍼가 비워진 것을 확인할 수 있다.
STEP #6. 서버가 응답 후의 상황
...
# 파일 디스크립터에게 직접 쓰기
os.write(client_fd, b"Hello Client")
...
이제 서버가 클라이언트에게 응답하는 파이썬 코드를 실행해보자. 서버는 클라이언트에게 'Hello Client'라고 응답한다. 이 때, 서버는 서버 -> 클라이언트 소켓의 Send Buffer에 'Hello Client' 라는 데이터를 쓴다. 그러나 아쉽게도 커널 영역은 브레이크 포인트를 잡을 수 없기 때문에 netstat을 통해 Send-Q를 확인해 볼 수는 없었다.
$ sudo tcpdump -i lo0 port 8080
>>>
# TCP Handshake
[TIME] IP localhost.61824 > localhost.http-alt: Flags [S], seq 1459141837, win 65535, ..., length 0
[TIME] IP localhost.http-alt > localhost.61824: Flags [S.], seq 3772530693, ack 1459141838, win 65535, ..., length 0
[TIME] IP localhost.61824 > localhost.http-alt: Flags [.], ack 1, win 6379, ..., length 0
[TIME] IP localhost.http-alt > localhost.61824: Flags [.], ack 1, win 6379, ..., length 0
# 클라이언트 -> 서버로 메세지 보냈을 때
[TIME] IP localhost.61824 > localhost.http-alt: Flags [P.], seq 1:23, ack 1, win 6379, ..., length 22: HTTP
[TIME] IP localhost.http-alt > localhost.61824: Flags [.], ack 23, win 6379, ..., length 0
# 추가된 로그
[TIME] IP localhost.http-alt > localhost.61824: Flags [P.], seq 1:13, ack 23, win 6379, ..., length 12: HTTP
[TIME] IP localhost.61824 > localhost.http-alt: Flags [.], ack 13, win 6379, ..., length 0
서버가 응답하는 코드를 실행한 뒤 tcpdump에는 로그 3개가 추가되었다.
- 서버 -> 클라이언트로 Push + Ack(P.)를 보낸다. 이 때 메세지의 길이는 총 13(seq 1:13, lenght 12) 이고, 클라이언트 -> 서버로 보냈던 마지막 메세지의 길이 23을 ack로 응답한다. (클라이언트는 이 값을 받고 23이라는 값을 확인해서 정상 메세지라고 확신할 것이다)
- 클라이언트 -> 서버로 Ack(.)를 보낸다. 이 때 Ack는 13이 되고, 서버가 클라이언트에게 보낸 13자리의 메세지를 잘 받았다는 것을 의미한다.
STEP #7. 서버 - Connected Socket 종료
...
connection.shutdown(socket.SHUT_RDWR)
...
서버는 이제 클라이언트와 연결을 끊기 위해 Connected Socekt을 Shutdown한다.
$ sudo tcpdump -i lo0 port 8080
>>>
# TCP Handshake
[TIME] IP localhost.61824 > localhost.http-alt: Flags [S], seq 1459141837, win 65535, ..., length 0
[TIME] IP localhost.http-alt > localhost.61824: Flags [S.], seq 3772530693, ack 1459141838, win 65535, ..., length 0
[TIME] IP localhost.61824 > localhost.http-alt: Flags [.], ack 1, win 6379, ..., length 0
[TIME] IP localhost.http-alt > localhost.61824: Flags [.], ack 1, win 6379, ..., length 0
# 클라이언트 -> 서버로 메세지 보냈을 때
[TIME] IP localhost.61824 > localhost.http-alt: Flags [P.], seq 1:23, ack 1, win 6379, ..., length 22: HTTP
[TIME] IP localhost.http-alt > localhost.61824: Flags [.], ack 23, win 6379, ..., length 0
# 추가된 로그
[TIME] IP localhost.http-alt > localhost.61824: Flags [P.], seq 1:13, ack 23, win 6379, ..., length 12: HTTP
[TIME] IP localhost.61824 > localhost.http-alt: Flags [.], ack 13, win 6379, ..., length 0
[TIME] IP localhost.http-alt > localhost.61824: Flags [R.], seq 13, ack 23, win 6379, length 0
# 커넥션 종료 했을 때 로그
[TIME] IP localhost.http-alt > localhost.61982: Flags [F.], seq 13, ack 24, win 6379, ..., length 0
[TIME] IP localhost.61982 > localhost.http-alt: Flags [.], ack 14, win 6379, ..., length 0
[TIME] IP localhost.61982 > localhost.http-alt: Flags [F.], seq 24, ack 14, win 6379, ..., length 0
[TIME] IP localhost.http-alt > localhost.61982: Flags [.], ack 25, win 6379, ..., length 0
이 때, tcpdump에는 로그 4개가 추가된다.
- 서버 -> 클라이언트로 Fin + Ack (F.)를 보낸다.
- 클라이언트 -> 서버는 Fin에 대해 Ack(.) 한다.
- 클라이언트 -> 서버로 Fin + Ack (F.)를 보낸다.
- 서버 -> 클라이언트로 Ack (.)를 보낸다.
# connection.shutdown() 호출하자마자
$ lsof -i :8080
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3.1 32534 user 4u IPv4 0x231be0f092509adf 0t0 TCP *:http-alt (LISTEN)
python3.1 32534 user 5u IPv4 0x231be0f0981d1047 0t0 TCP localhost:http-alt->localhost:65532 (TIME_WAIT)
$ netstat -tn | grep 8080
Active Internet connections
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 127.0.0.1.8080 127.0.0.1.65532 TIME_WAIT
# 약간의 시간이 지난 후
$ lsof -i :8080
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3.1 32534 user 4u IPv4 0x231be0f092509adf 0t0 TCP *:http-alt (LISTEN)
python3.1 32534 user 5u IPv4 0x231be0f0981d1047 0t0 TCP localhost:http-alt->localhost:65532 (CLOSED)
$ netstat -tn | grep 8080
이 때의 lsof, netstat을 살펴보자.
- lsof에서 클라이언트의 소켓은 닫혔고, 서버 소켓은 ESTABLISH -> TIME_WAIT로 바뀐 것을 확인할 수 있다. 단, 이 때 listen 소켓은 여전히 listen하고 있다. (Connected Socket만 닫았으니)
- netstat에서 마찬가지로 서버 소켓은 TIME_WAIT 상태가 되었고, 클라이언트 소켓은 닫혔다.
- 이 상태로 약간의 시간(수십 초 정도)이 지난 후에 listen 소켓은 여전히 listen 중이지만, 서버 소켓은 CLOSED 되었고 nestat에서는 이제 검색조차 되지 않게 된다.
여기서 Connected Socket을 닫는 것이 listen 소켓에는 영향을 주지 않는다는 것을 알 수 있다.
STEP #8 File Descriptor 종료하기
...
# 소켓 닫기
for socket_fd in [client_fd, fd]:
os.close(socket_fd)
이제 File Descriptor를 모두 종료할 단계다. 위 파이썬 코드를 실행하면 결과가 실행된다.
$ lsof -i :8080
>
$ netstat -tn | grep 8080
>
어떠한 TCP dump 로그도 남지 않으며 netstat, lsof에서도 모두 없어진다. 즉, 파일 디스크립터를 닫으면서 listen 소켓까지 닫히게 된다.
마무리
이 글은 처음에는 TCP Handshake, 파일 디스크립터, OS가 어떤 관계인지를 정리했다. 그리고 어느 레벨에서 TCP Handshake가 일어나고, 그리고 리눅스 파일 디스크립터와 TCP Handshake가 어떤 관계가 있는지를 로그 기반으로 살펴볼 수 있었다. 코드를 적고 실행하며 하나씩 살펴보며 나는 이 사실들을 눈으로 확인할 수 있게 되었다.
- TCP Handshake는 커널 레벨에서 이루어진다.
- 서버에서는 수신 소켓을 열고 특정 포트를 listen한다. 그리고 이 수신 소켓은 파일 디스크립터에 의해 관리된다.
- 수신 소켓은 Backlog에 TCP Handshake 중이거나 완료되었으나 Accept 되지 않은 커넥션들을 관리하고 있다.
- 수신 소켓은 TCP Handshake가 완료되고 accept 되면, 그 Connection을 관리할 새로운 Connected Socket(SRC/DEST IP+Port 조합)을 만들고 새로운 파일 디스크립터를 부여한다.
- 이후 클라이언트가 보내는 메세지는 모두 Connected Socket의 Recv Buffer에 저장된다. 서버가 클라이언트에 응답하는 경우 Connected Socket의 Send Buffer -> Network -> Client Recv Buffer 순서로 전달된다.
- 어플리케이션에서 시스템콜 read(fd_no, 읽을 바이트 수)를 호출하면, 해당 file descriptor로 관리되는 Connected Socket의 Recv Buffer에서 데이터를 읽어온다.
- 클라이언트 - 서버가 메세지를 주고 받을 때, TCP 수준에는 현재 자신의 Recv Buffer에 남은 용량을 win이라는 TCP 헤더를 통해 알려준다.
- Recv Buffer에서 읽어가는 속도보다 채워지는 속도가 빠른 경우, Congestion Control / Flow Control 등이 일어날 수도 있다.
- lsof 명령어를 이용하면 각 프로세스마다 관리하고 있는 Socket File Descriptor를 알 수 있다. (리눅스는 프로세스마다 각자 Socket File Descriptor를 관리함.)
- tcpdump에서 플래그를 통해서 TCP Header를 확인할 수 있다.
- S : Syn
- . : Ack
- S. : Syn + Ack
- P. : Push + Ack
- R. : Rst + Ack
이전 글
- 만들면서 인히는 TCP 터널링 : https://ojt90902.tistory.com/1804