필자가 작성한 이 게시물은 네이버의 TCP/IP 네트워크 스택 이해하기 (김형엽) 게시물을 읽고 그 내용을 다시 정리한 것이다. 이 모든 내용의 저작권은 모두 네이버에 있다. 필자는 단지 읽던 도중에 생긴 의문점 을 추가로 집어 넣어 정리했다.
Reference: https://d2.naver.com/helloworld/47667
두 개의 터미널(Local, Remote) 사이에 연결을 먼저 맺고 데이터를 주고 받는다. TCP 연결 식별자
는 두 엔드 포인트의 주소를 합친 것으로, <Local IP, Local Port, Remote IP, Remote Port> 형태이다. (아마 File Descriptor
와는 다른 개념으로 보인다.)
양방향 데이터 통신을 하고, 바이트 스트림을 사용한다.
송신자(sender) 가 보낸 순서대로 수신자(receiver) 가 데이터를 받는다. 이를 위해 데이터의 순서
가 필요하다. 순서를 표시하기 위해 32-bit 정수 자료형
을 사용한다.
일단 용어를 좀 엄밀히 할 필요가 있겠다. 여기서 말하는 32-bit 정수 자료형
은 TCP sequence numbers
라고 부르며, sequence number 가 runs out 된다면 loops back to zero 한다.
Reference: https://en.wikipedia.org/wiki/Transmission_Control_Protocol
데이터를 송신하고 수신자로부터 ACK(데이터 받았음)를 받지 않으면, 송신자 TCP 가 데이터를 재전송한다. 따라서 송신자 TCP 는 수신자로부터 ACK 를 받지 않은 데이터를 보관한다 (buffer unacknowledged data)
ACK
가 이미 날라가는 중이었다면? 간발의 차이로 놓쳤다면?sequence number
가 부여되어 있을 것이기 때문에 그냥 병합하면 된다.송신자는 수신자가 받을 수 있는 만큼 데이터를 전송한다. 수신자가 자신이 받을 수 있는 바이트 수(사용하지 않은 버퍼 크기, receive window) 를 송신자에게 전달한다. 송신자는 수신자 receive window 가 허용하는 바이트 수만큼 데이터를 전송한다.
연결을 Reset 하거나, 패킷을 떨궈서 송신자의 재전송을 요구한다.
Reference: https://networkengineering.stackexchange.com/questions/84589/what-happens-when-the-sender-sends-more-data-then-the-advertised-window-by-the-r
네트워크 정체를 방지하기 위해 receive window 와 별도로 congestion window 를 사용하는데 이는 네트워크에 유입되는 데이터 양을 제한하기 위해서이다. Receive Window 와 마찬가지로 Congestion Window 가 허용하는 바이트 수만큼 데이터를 전송하며 여기에는 TCP Vegas, Westwood, BIC, CUBIC 등 다양한 알고리즘이 있다. Flow Control 과 달리 송신자가 단독으로 구현한다.
필자가 유추하기를 아마 버퍼링을 한다는 것 같다... 고 생각했으나 그보다 더 복잡하다. 라우터 버퍼의 오버 플로우로 인한 데이터 유실이나 전송 속도를 받쳐주지 못하는 경우에 대해 어떻게 최적의 전송 속도를 찾아갈 수 있느냐에 대한 논의... 라고 추론하고 있는데 상당히 복잡한 개념으로 보인다.
Reference: https://movefast.tistory.com/38
네트워크 스택에는 여러 레이어가 있는데 크게 세 가지로 구분한다: User
, Kernel
, Device
. User
+ Kernel
영역을 합쳐 Host
라 부르며 이 영역의 작업은 CPU
에서 이뤄지고, Device
는 패킷의 송수신 처리를 수행하며 이는 NIC(Network Interface Card)
라 불리는 장비에서 이뤄진다.
User
to Kernel
애플리케이션(User
) 은 전송할 데이터를 생성하고 write
시스템 콜을 호출해서 데이터를 보낸다. 시스템 콜을 호출하면 커널 영역으로 전환된다.
커널 소켓은 두 개의 버퍼를 가지고 있다. 송신용으로 준비한 send socket buffer
, 수신용으로 준비한 receive socket buffer
(이걸 receive window
로 추론) 이다. write 시스템 콜을 호출하면 유저 영역의 데이터가 커널 메모리로 복사되고 send socket buffer
의 뒷 부분에 추가된다. (그래야 In-order delivery
가 가능하기 때문이라고 생각 중)
소켓(file descriptor
)과 연결된 TCP Control Block(TCB; Task Control Block 아님!)
구조체가 있다. TCB 에는 TCP 연결 처리에 필요한 정보가 있다. (connection state, receive window, congestion window, sequence number, retransmission timer, etc.)
현재 TCP 상태가 데이터 전송을 허용하면 새로운 TCP segment
, 즉 패킷
을 생성한다. Flow Control
같은 이유로 데이터 전송이 불가능하면 시스템 콜은 여기서 끝나고, 유저 모드로 돌아간다. (Q: Flow Control
에서의 전송 가능함
이란 비동기적인 이벤트 아닌가? 임의 시점에 확인해도 언젠가 다시 바뀔 수 있지 않을까?)
Socket
to IP
TCP segment
에는 TCP header
와 payload
가 있다. payload
에는 ACK
를 받지 않은 send socket buffer
에 있는 데이터가 담겨 있다. 페이로드의 최대 길이는 receive window
, congestion window
, MSS(Maximum Segment Size)
중 최대 값이다. (Q: Payload
가 가질 수 있는 최대 길이 를 말하는 것이라 판단. 보내는 순간의 최대 길이 라면 아무리 봐도 뭔가 이상함. 서술의 문제)
그리고 TCP checksum
을 계산하는데 이 checksum
계산에는 pseudo 헤더 정보
(IP 주소들, segment 길이, 프로토콜 번호) 를 포함시킨다. 여기서 TCP 상태에 따라 패킷을 한 개 이상 전송할 수 있다. (Q: 위에서 확인한 전송 가능함
과는 조금 다른 것들을 확인하는 건가?) 최근에는 checksum offload
기술을 사용하기 때문에, 커널이 직접 TCP checksum
을 계산하지 않고 NIC
가 대신 checksum
을 계산한다.
생성된 TCP segment
는 IP Layer
로 내려간다.
IP
to Ethernet
IP Layer
에서는 TCP segment
에 IP header
를 추가하고, IP routing
을 한다. IP routing
이란 destionation IP
로 가기 위한 다음 장비의 next hop IP
를 찾는 과정을 말한다. IP header checksum
을 계산하여 덧붙인 후, Ethernet Layer
로 데이터를 보낸다. (위에서 생성한 pseudo header ip
는 아마 터미널의 주소이고 IP header
의 주소는 next hop IP
아닐까?)
Ethernet Layer
는 ARP (Address Resolution Protocol)
을 사용해서 next hop IP
의 MAC
주소를 찾는다. 그리고 Ethernet
헤더를 패킷에 추가한다. Ethernet
헤더까지 붙으면 호스트의 패킷은 완성이다.
IP routing
을 하면 그 결과물로 next hop IP
와 해당 IP
로 패킷 전송할 Eo 사용하는 인터페이스(transmit interface
혹은 NIC
) 를 알게 된다. 따라서 transmit NIC
의 드라이버를 호출한다.
만일 tcpdump
나 Wireshark
같은 패킷 캡처 프로그램이 작동 중이면 커널은 패킷 데이터를 프로그램이 사용하는 메모리 버퍼에 복사한다. 수신도 마찬가지로 드라이버 바로 위에서 패킷을 캡쳐한다.
NIC
NIC
는 패킷 전송 요청을 받고, 메인 메모리에 있는 패킷을 자신의 메모리로 복사하고, 네트워크 선으로 전송한다. 이때 Ethernet 표준
에 따라 IFG(Inter-Frame Gap)
, preamble
그리고 CRC
를 패킷에 추가한다. IFG
, preamble
은 패킷의 시작을 판단하기 위해 사용하고(네트워킹 용어로는 framing
), CRC
는 데이터 보호를 위해 사용한다 (checksum
과 같은 용도)
패킷 전송은 Ethernet
의 물리적 속도, 그리고 Ethernet flow control
에 따라 전송할 수 있는 상활일 때 시작된다. 회의장에서 발언권을 얻고 말하는 것과 비슷하다.
NIC
가 패킷을 전송할 때 NIC
는 Host CPU
에 interrupt
를 발생시킨다. 모든 인터럽트에는 인터럽트 번호가 있으며, 운영체제는 이 번호를 이용하여 이 인터럽트를 처리할 수 있는 적합한 드라이버를 찾는다. 들아ㅣ버는 인터럽트를 처리할 수 있는 함수(인터럽트 핸들러)를 드라이버가 가동되었을 때 운영체제에 등록해둔다. 운영체제가 핸들러를 호출하고, 핸들러는 전송된 패킷을 운영체제에 반환한다.
또한 application
이 쓰기 요청을 직접적으로 하지 않더라도 TCP
를 호출해서 패킷을 전송하는 경우가 있다. 예를 들어 ACK
를 받아서 receive window
가 늘어나면 socket buffer
에 남아있는 데이터를 포함한 TCP segment
를 생성하여 상대편에 전송한다. (Q: send
함수는 이미 몇 바이트를 전송했는지를 정수형으로 반환해줬을텐데, 이를 TCB
에 저장해놓는다고 해도 물리적 장치의 파손이 발생해서 실제론 전송을 못하면 어떻게 되는거지?)
위에서 설명한 내용은 네트워크 스택 레이어가 하는 가장 기본적인 기능이다. 최신 네트워크 스택은 이보다 더 많은 기능을 가지고 있으며 구현체의 복잡성도 증가했다.
Netfilter
(방화벽, NAT, etc.), traffic control
같으 ㄴ기능이다. 기본 처리 흐름에 사용자가 제어할 수 있는 코드를 삽입해서 사용자 설정에 따라 다양한 효과를 낸다.
주어진 네트워크 환경에서 TCP
프로토콜이 달성할 수 있는 throughput
, latency
, stability
등의 개선을 목표로 한다.
한 장비가 패킷을 처리하는데 소요되는 CPU cycle
, 메모리 사용량, 메모리 접근 수 등을 줄여서 초당 처리할 수 있는 최대 패킷 수를 개선하는 것을 목표로 한다. 장비 내부에서의 latency 를 줄이는 것을 포함한 여러 시도가 있었다. 스택 병렬처리
, header prediction
, zero-copy
, single-copy
, checksum offload
, TSO
, LRO
, RSS
, 등 여러 가지가 있다.
일단 이러한 주제들이 논의가 되었다는 점을 상기하고 있자. 최근에 Discord
에서 한차례 이야기가 나왔던 XDP
도 이들과 궤를 같이하는 듯하다. (XDP 알아보기 아주 좋은 글: https://pak-j.tistory.com/62)
애플리케이션이 시스템 콜을 호출하여 TCP 를 수행(사용)하는 경우이다. 예를 들어, read 시스템 콜과 write 시스템 콜을 호출하고 TCP 를 수행한다. 하지만 패킷 전송은 없다. (Q: 이게 대체 뭔 소리지??? file descriptor
에 연결된 주소를 받아오는 등의 시스템 콜이면 몰라도 read
, write
호출한 뒤에 패킷 전송이 없다? 실패한건가? half-close fd 인가? 아무런 설명이 없어서 이해가 안됨.)
TCP 수행 결과 패킷 전송이 필요한 경우다. 패킷을 생성해서 드라이버로 패킷을 내려 보낸다. 드라이버 앞 부분에는 queue
가 있다. 패킷은 우선 큐에 들어가고, 큐 구현체가 패킷이 드라이버로 전달되는 시점을 결정한다. Linux
의 qdisc
(Queue discipline) 가 이것이다. Linux traffic control
기능은 qdisc
를 조작해서 이뤄진다.
TCP 가 사용하는 타이머가 만료된 경우이다. 예를 들어, TIME_WAIT
타이머가 만료되면 TCP 를 호출해서 연결을 삭제한다.
TCP 가 사용하는 타이머가 만료된 경우인데 TCP 수행 결과, 패킷 전송이 필요한 경우이다. 예를 들어 Retransmit timer
가 만료되어 ACK
를 받지 못한 패킷을 전송한다.
transmit queue
에 패킷이 적체된 경우이다. 드라이버가 softirq
를 요청하고 softirq
핸들러가 transmit queue
를 실행해서 적체된 패킷을 드라이버로 보낸다. (Q: 왜 transmit queue
가 적체 됐는데 드라이버가 softirq
요청을 보내는걸까?)
NIC 드라이버가 인터럽트를 받고 새로 수신된 패킷을 발견하면 softirq
를 요청한다. 수신 패킷을 처리하는 softirq
가 드라이버를 호출해서 수신된 패킷을 상위 레이어로 전달한다. 이와 같은 수신 패킷 처리 방법은 NAPI(new API)
라고 부른다. 드라이버가 상위 레이어로 직접 전달하지 않고, 상위 레이어가 직접 패킷을 가져가기 때문에 polling
과 유사하다. 실제 코드는 NAPI poll
혹은 poll
이라 부른다.
추가 패킷 전송이 필요한 경우이다.
CPU
는 현재 유저 프로그램 실행 중에 있음.NIC
가 CPU 에 interrupt
를 발생시킴. CPU
는 커널 인터럽트(흔히 irq 라 부른다) 핸들러를 실행시킴.napi_schedule()
함수를 호출한다. 이 함수가 softirq
를 요청한다.softirq
에 대한 인터럽트 핸들러를 실행한다.interrupt context
가 실행되었으니 softirq context
가 실행될 차례이다. interrupt context
와 softirq context
가 실행되는 스레드는 같다. 하지만 스택이 서로 다르다. 그리고 interrupt context
는 하드웨어 인터럽트를 차단하지만, softirq context
는 하드웨어 인터럽트를 허용한다.CPU
가 수신 패킷을 처음부터 끝까지 처리한다.ARM 의 인터럽트 핸들링과 비슷한 듯 보임. 용어도 비슷하고.
패킷 수신을 많이 하는 서버 CPU 사용률을 보면 한 CPU만 열심히 softirq 를 실행하는 현상을 종종 확인할 수 있다. 지금까지 설명한 수신 패킷 처리 방식 때문에 발생하는 현상이다. 이 문제를 풀기 위해 multi-queue NIC
, RSS
, RPS
가 나왔다.
sk_buff
구조체패킷을 의미하는 sk_buff
구조체 혹은 skb
구조체가 있다. 기능이 발전되면서 이보다 더 복잡해졌지만 기본적으로 필요한 기능은 누구나 생각할 수 있는 것들이다.
패킷 데이터를 구조체가 직접 포함하고 있거나, 포인터를 사용해서 참조하고 있다.
네트워크 스택의 각 레이어를 왔다갔다하며 헤더를 추가, 삭제한다. 효율적으로 처리하기 위해 포인터들을 사용한다. 예를 들어, Ethernet 헤더를 제거하려면, head 포인터를 증가하면 된다. (Q: NULL 로 초기화하지 않고? 얼마나 어떻게?)
socket buffer
에 패킷 페이로드 데이터를 추가, 삭제, 또는 패킷 체인 같은 작업을 효율적으로 수행하기 위해 linked list
를 사용한다.
패킷을 생성할 때마다 구조체를 할당하기 때문에 빠른 allocator 를 사용한다. 예를 들어, 10Gigabit Ethernet 속도로 데이터를 전송하면 초당 1백만 패킷 이상을 생성, 제거해야 한다.
TCP control block
UNIX-like
운영체제에서는 socket
, device
, 일반 file
을 모두 file
로 추상화한다. 따라서 file
구조체에는 최소한의 정보만을 포함하며 socket
의 경우 별도 socket
구조체가 소켓 관련 정보를 저장하고, file
은 socket
을 포인터로 참조한다. socket
은 다시 tcp_sock
을 참조한다. tcp_sock
은 sock
, inet_sock
으로 다시 세분화된다.
file
-> socket
-> tcp_sock
-> sock
or inet_sock
tcp_sock
tcp_sock
에는 TCP 프로토콜이 사용하는 모든 상태 정보를 저장한다. sequence number
, receive window
, congestion control
, retransmit timer
, etc.
send socket buffer
와 receive socket buffer
는 sk_buff list
이며 tcp_sock
을 포함한다. ip routing
결과물인 dst_entry
도 참조하여 매번 routing
하지 않도록 한다. dst_entry
를 사용해서 ARP
결과, 즉 목적지 MAC
주소도 쉽게 찾는다. NIC
는 net_device
구조체로 표현한다.
이들 구조체의 크기가 TCP
연결 하나가 사용하는 메모리의 양이다. 메모리의 양은 수 KB 정도(패킷 데이터 제외)다. 메모리 사용량도 기능이 추가되면서 꾸준히 증가했다.
수신된 패킷이 속하는 TCP
연결을 찾는데 사용하기 위한 hash table
인 TCP 연결 lookup table
이 있다. 해시 값은 패킷의 <source ip, target ip, source port, target port>
를 입력 데이터로 하며 해시 함수는 해시 테이블 공격에 대한 방어를 고려해서 선택했다고 한다.
코드를 분석하는 파트도 있는데 현재 커널과 괴리가 너무 커서 일단 생략. 달라도 너무 다르다.
드라이버와 NIC
는 비동기 방식으로 통신한다. 먼저 드라이버가 패킷 전송을 요청하고(호출), CPU 는 응답을 기다리지 않고 다른 작업을 수행한다. 이후 NIC
가 패킷을 전송하고 CPU 에 이 사실을 알리면 드라이버가 전송된 패킷을 반환한다. (결과 리턴) 수신도 이와 같이 비동기 방식으로 이루어진다.
따라서 요청, 응답을 저장하는 장소가 필요하다. 대개 NIC 는 ring
구조체를 사용한다. 이들 엔트리들은 차례대로 돌아가며 사용한다. 돌아가며 고정된 엔트리들을 재사용하기 때문에 흔히 링이란 이름을 사용한다.
NIC
가 이해하는 전송 요청(send descriptor) 을 생성한다. send descriptor
에는 기본적으로 패킷 크기, 메모리 주소를 포함하도록 한다. NIC
는 메모리에 접근할 때 필요한 물리적 주소가 필요하다. 따라서 드라이버가 패킷의 가상 주소를 물리적 주소로 변경한다. 그리고 send descriptor
를 TX ring
에 추가한다. TX ring
이 전송 요청 링이다.NIC
에 새로운 요청이 있다고 알린다. 이를 위해 NIC 메모리 주소에 드라이버가 직접 데이터를 쓰는데 이와 같은 방식을 PIO(Programmed I/O)
라고 한다.NIC
는 TX ring
의 send descriptor
를 호스트 메모리에서 가져온다. CPU
의 개입 없이 디바이스가 직접 메모리에 접근하기 때문에, 이와 같은 접근을 DMA(Direct Memery Access)
라고 부른다.Send descriptor
를 가져와서 패킷 주소와 크기를 판단하고, 실제 패킷을 호스트 메모리에서 가져온다. checksum offload
방식을 사용하면 메모리에서 패킷 데이터를 가져올 때 checksum
을 NIC
가 계산하도록 한다.전체적으로 정리해봤는데 방금 커널의 코드(v6.10) 보고 오니 너무 괴리가 커서 어짜피 다시 한번 분석을 하긴 해야 할 것 같다.