[OSSCA] Linux Kernel Networking Stack #1 네트워크 스택의 이해

문연수·2024년 7월 16일
1

OSSCA

목록 보기
2/6
post-custom-banner

0. 이 게시물은...

필자가 작성한 이 게시물은 네이버의 TCP/IP 네트워크 스택 이해하기 (김형엽) 게시물을 읽고 그 내용을 다시 정리한 것이다. 이 모든 내용의 저작권은 모두 네이버에 있다. 필자는 단지 읽던 도중에 생긴 의문점 을 추가로 집어 넣어 정리했다.

Reference: https://d2.naver.com/helloworld/47667

1. TCP/IP 의 특성

- 1. Connection oriented (연결 지향)

두 개의 터미널(Local, Remote) 사이에 연결을 먼저 맺고 데이터를 주고 받는다. TCP 연결 식별자 는 두 엔드 포인트의 주소를 합친 것으로, <Local IP, Local Port, Remote IP, Remote Port> 형태이다. (아마 File Descriptor 와는 다른 개념으로 보인다.)

- 2. Bidirectional byte stream (양방향 바이트 통신)

양방향 데이터 통신을 하고, 바이트 스트림을 사용한다.

- 3. In-order delivery (순차적 전송)

송신자(sender) 가 보낸 순서대로 수신자(receiver) 가 데이터를 받는다. 이를 위해 데이터의 순서 가 필요하다. 순서를 표시하기 위해 32-bit 정수 자료형 을 사용한다.


Q: 장시간 동안 연결이 이뤄져서 32-bit 로 표현 가능한 숫자를 모두 다 사용한다면 어떻게?

A: 상관 없다. 다시 0 으로 돌아가서 시작한다.

일단 용어를 좀 엄밀히 할 필요가 있겠다. 여기서 말하는 32-bit 정수 자료형TCP sequence numbers 라고 부르며, sequence number 가 runs out 된다면 loops back to zero 한다.
Reference: https://en.wikipedia.org/wiki/Transmission_Control_Protocol

- 4. Reliability through ACK (ACK 를 통한 신뢰성)

데이터를 송신하고 수신자로부터 ACK(데이터 받았음)를 받지 않으면, 송신자 TCP 가 데이터를 재전송한다. 따라서 송신자 TCP 는 수신자로부터 ACK 를 받지 않은 데이터를 보관한다 (buffer unacknowledged data)


Q: 데이터를 송신한 뒤에 수신자의 ACK 를 기다린다. 만일 ACK 가 도착하지 않아 데이터를 재전송했는데 운 나쁘게 ACK 가 이미 날라가는 중이었다면? 간발의 차이로 놓쳤다면?

A: 상관없다. 어짜피 sequence number 가 부여되어 있을 것이기 때문에 그냥 병합하면 된다.

Reference: https://stackoverflow.com/questions/25610096/how-does-tcp-deal-with-acks-received-after-the-timeout-period-has-expired

- 5. Flow Control (흐름 제어)

송신자는 수신자가 받을 수 있는 만큼 데이터를 전송한다. 수신자가 자신이 받을 수 있는 바이트 수(사용하지 않은 버퍼 크기, receive window) 를 송신자에게 전달한다. 송신자는 수신자 receive window 가 허용하는 바이트 수만큼 데이터를 전송한다.


Q: 만일 어떤 이상한 유저가 억지로 receive window 이상의 데이터를 보낸다면?

A: 구현에 따라 다르다 (Depending on the host implementation).

연결을 Reset 하거나, 패킷을 떨궈서 송신자의 재전송을 요구한다.
Reference: https://networkengineering.stackexchange.com/questions/84589/what-happens-when-the-sender-sends-more-data-then-the-advertised-window-by-the-r

- 6. Congestion control (혼잡 제어)

네트워크 정체를 방지하기 위해 receive window 와 별도로 congestion window 를 사용하는데 이는 네트워크에 유입되는 데이터 양을 제한하기 위해서이다. Receive Window 와 마찬가지로 Congestion Window 가 허용하는 바이트 수만큼 데이터를 전송하며 여기에는 TCP Vegas, Westwood, BIC, CUBIC 등 다양한 알고리즘이 있다. Flow Control 과 달리 송신자가 단독으로 구현한다.


필자가 유추하기를 아마 버퍼링을 한다는 것 같다... 고 생각했으나 그보다 더 복잡하다. 라우터 버퍼의 오버 플로우로 인한 데이터 유실이나 전송 속도를 받쳐주지 못하는 경우에 대해 어떻게 최적의 전송 속도를 찾아갈 수 있느냐에 대한 논의... 라고 추론하고 있는데 상당히 복잡한 개념으로 보인다.

Reference: https://movefast.tistory.com/38

2. 데이터 전송

네트워크 스택에는 여러 레이어가 있는데 크게 세 가지로 구분한다: User, Kernel, Device. User + Kernel 영역을 합쳐 Host 라 부르며 이 영역의 작업은 CPU 에서 이뤄지고, Device 는 패킷의 송수신 처리를 수행하며 이는 NIC(Network Interface Card) 라 불리는 장비에서 이뤄진다.

- 1. 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 에서의 전송 가능함 이란 비동기적인 이벤트 아닌가? 임의 시점에 확인해도 언젠가 다시 바뀔 수 있지 않을까?)

- 2. Socket to IP

TCP segment 에는 TCP headerpayload 가 있다. 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 segmentIP Layer 로 내려간다.

- 3. IP to Ethernet

IP Layer 에서는 TCP segmentIP header 를 추가하고, IP routing 을 한다. IP routing 이란 destionation IP 로 가기 위한 다음 장비의 next hop IP 를 찾는 과정을 말한다. IP header checksum 을 계산하여 덧붙인 후, Ethernet Layer 로 데이터를 보낸다. (위에서 생성한 pseudo header ip 는 아마 터미널의 주소이고 IP header 의 주소는 next hop IP 아닐까?)

Ethernet LayerARP (Address Resolution Protocol) 을 사용해서 next hop IPMAC 주소를 찾는다. 그리고 Ethernet 헤더를 패킷에 추가한다. Ethernet 헤더까지 붙으면 호스트의 패킷은 완성이다.

IP routing 을 하면 그 결과물로 next hop IP 와 해당 IP 로 패킷 전송할 Eo 사용하는 인터페이스(transmit interface 혹은 NIC) 를 알게 된다. 따라서 transmit NIC 의 드라이버를 호출한다.


 만일 tcpdumpWireshark 같은 패킷 캡처 프로그램이 작동 중이면 커널은 패킷 데이터를 프로그램이 사용하는 메모리 버퍼에 복사한다. 수신도 마찬가지로 드라이버 바로 위에서 패킷을 캡쳐한다.

- 4. NIC

NIC 는 패킷 전송 요청을 받고, 메인 메모리에 있는 패킷을 자신의 메모리로 복사하고, 네트워크 선으로 전송한다. 이때 Ethernet 표준 에 따라 IFG(Inter-Frame Gap), preamble 그리고 CRC 를 패킷에 추가한다. IFG, preamble 은 패킷의 시작을 판단하기 위해 사용하고(네트워킹 용어로는 framing), CRC 는 데이터 보호를 위해 사용한다 (checksum 과 같은 용도)

 패킷 전송은 Ethernet 의 물리적 속도, 그리고 Ethernet flow control 에 따라 전송할 수 있는 상활일 때 시작된다. 회의장에서 발언권을 얻고 말하는 것과 비슷하다.

NIC 가 패킷을 전송할 때 NICHost CPUinterrupt 를 발생시킨다. 모든 인터럽트에는 인터럽트 번호가 있으며, 운영체제는 이 번호를 이용하여 이 인터럽트를 처리할 수 있는 적합한 드라이버를 찾는다. 들아ㅣ버는 인터럽트를 처리할 수 있는 함수(인터럽트 핸들러)를 드라이버가 가동되었을 때 운영체제에 등록해둔다. 운영체제가 핸들러를 호출하고, 핸들러는 전송된 패킷을 운영체제에 반환한다.


 또한 application 이 쓰기 요청을 직접적으로 하지 않더라도 TCP 를 호출해서 패킷을 전송하는 경우가 있다. 예를 들어 ACK 를 받아서 receive window 가 늘어나면 socket buffer 에 남아있는 데이터를 포함한 TCP segment 를 생성하여 상대편에 전송한다. (Q: send 함수는 이미 몇 바이트를 전송했는지를 정수형으로 반환해줬을텐데, 이를 TCB 에 저장해놓는다고 해도 물리적 장치의 파손이 발생해서 실제론 전송을 못하면 어떻게 되는거지?)

3. 네트워크 스택 발전 방향

위에서 설명한 내용은 네트워크 스택 레이어가 하는 가장 기본적인 기능이다. 최신 네트워크 스택은 이보다 더 많은 기능을 가지고 있으며 구현체의 복잡성도 증가했다.

- 패킷 처리 과정 조작 가능

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)

4. 스택 내부 제어 흐름 (Control Flow)

  1. 애플리케이션이 시스템 콜을 호출하여 TCP 를 수행(사용)하는 경우이다. 예를 들어, read 시스템 콜과 write 시스템 콜을 호출하고 TCP 를 수행한다. 하지만 패킷 전송은 없다. (Q: 이게 대체 뭔 소리지??? file descriptor 에 연결된 주소를 받아오는 등의 시스템 콜이면 몰라도 read, write 호출한 뒤에 패킷 전송이 없다? 실패한건가? half-close fd 인가? 아무런 설명이 없어서 이해가 안됨.)

  2. TCP 수행 결과 패킷 전송이 필요한 경우다. 패킷을 생성해서 드라이버로 패킷을 내려 보낸다. 드라이버 앞 부분에는 queue 가 있다. 패킷은 우선 큐에 들어가고, 큐 구현체가 패킷이 드라이버로 전달되는 시점을 결정한다. Linuxqdisc(Queue discipline) 가 이것이다. Linux traffic control 기능은 qdisc 를 조작해서 이뤄진다.

  3. TCP 가 사용하는 타이머가 만료된 경우이다. 예를 들어, TIME_WAIT 타이머가 만료되면 TCP 를 호출해서 연결을 삭제한다.

  4. TCP 가 사용하는 타이머가 만료된 경우인데 TCP 수행 결과, 패킷 전송이 필요한 경우이다. 예를 들어 Retransmit timer 가 만료되어 ACK 를 받지 못한 패킷을 전송한다.

  • NIC 드라이버가 인터럽트(아마 반대편으로부터 받았다는 응답을 받았다는 거 아닐까?) 를 받으면 전송된 패킷을 반환(free) 한다. 대개 여기서 드라이버의 실행이 끝난다.
  1. transmit queue 에 패킷이 적체된 경우이다. 드라이버가 softirq 를 요청하고 softirq 핸들러가 transmit queue 를 실행해서 적체된 패킷을 드라이버로 보낸다. (Q: 왜 transmit queue 가 적체 됐는데 드라이버가 softirq 요청을 보내는걸까?)

  2. NIC 드라이버가 인터럽트를 받고 새로 수신된 패킷을 발견하면 softirq 를 요청한다. 수신 패킷을 처리하는 softirq 가 드라이버를 호출해서 수신된 패킷을 상위 레이어로 전달한다. 이와 같은 수신 패킷 처리 방법은 NAPI(new API) 라고 부른다. 드라이버가 상위 레이어로 직접 전달하지 않고, 상위 레이어가 직접 패킷을 가져가기 때문에 polling 과 유사하다. 실제 코드는 NAPI poll 혹은 poll 이라 부른다.

  3. 추가 패킷 전송이 필요한 경우이다.

5. 인터럽트와 수신 패킷 처리

  1. CPU 는 현재 유저 프로그램 실행 중에 있음.
  2. 패킷을 수신한 NIC 가 CPU 에 interrupt 를 발생시킴. CPU 는 커널 인터럽트(흔히 irq 라 부른다) 핸들러를 실행시킴.
  3. 이 핸들러가 인터럽트 번호를 보고 드라이버 인터럽트 핸들러를 호출
  4. 드라이버는 전송된 패킷은 반환하고, 수신된 패킷을 처리하기 위해 napi_schedule() 함수를 호출한다. 이 함수가 softirq 를 요청한다.
  5. 드라이버 인터럽트 핸들러의 실행이 종료되면 커널 핸들러로 제어권이 돌아간다. 커널 핸들러가 softirq 에 대한 인터럽트 핸들러를 실행한다.
  6. interrupt context 가 실행되었으니 softirq context 가 실행될 차례이다. interrupt contextsoftirq context 가 실행되는 스레드는 같다. 하지만 스택이 서로 다르다. 그리고 interrupt context 는 하드웨어 인터럽트를 차단하지만, softirq context 는 하드웨어 인터럽트를 허용한다.
  7. 따라서 인터럽트를 받은 CPU 가 수신 패킷을 처음부터 끝까지 처리한다.

ARM 의 인터럽트 핸들링과 비슷한 듯 보임. 용어도 비슷하고.

 패킷 수신을 많이 하는 서버 CPU 사용률을 보면 한 CPU만 열심히 softirq 를 실행하는 현상을 종종 확인할 수 있다. 지금까지 설명한 수신 패킷 처리 방식 때문에 발생하는 현상이다. 이 문제를 풀기 위해 multi-queue NIC, RSS, RPS 가 나왔다.

6. 데이터 구조체

- 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 구조체가 소켓 관련 정보를 저장하고, filesocket 을 포인터로 참조한다. socket 은 다시 tcp_sock 을 참조한다. tcp_socksock, inet_sock 으로 다시 세분화된다.

file -> socket -> tcp_sock -> sock or inet_sock

* tcp_sock

tcp_sock 에는 TCP 프로토콜이 사용하는 모든 상태 정보를 저장한다. sequence number, receive window, congestion control, retransmit timer, etc.

* socket buffer

send socket bufferreceive socket buffersk_buff list 이며 tcp_sock 을 포함한다. ip routing 결과물인 dst_entry 도 참조하여 매번 routing 하지 않도록 한다. dst_entry 를 사용해서 ARP 결과, 즉 목적지 MAC 주소도 쉽게 찾는다. NICnet_device 구조체로 표현한다.

 이들 구조체의 크기가 TCP 연결 하나가 사용하는 메모리의 양이다. 메모리의 양은 수 KB 정도(패킷 데이터 제외)다. 메모리 사용량도 기능이 추가되면서 꾸준히 증가했다.

* lookup table

 수신된 패킷이 속하는 TCP 연결을 찾는데 사용하기 위한 hash table 인 TCP 연결 lookup table 이 있다. 해시 값은 패킷의 <source ip, target ip, source port, target port> 를 입력 데이터로 하며 해시 함수는 해시 테이블 공격에 대한 방어를 고려해서 선택했다고 한다.


코드를 분석하는 파트도 있는데 현재 커널과 괴리가 너무 커서 일단 생략. 달라도 너무 다르다.

7. 드라이버와 NIC 의 통신

 드라이버와 NIC 는 비동기 방식으로 통신한다. 먼저 드라이버가 패킷 전송을 요청하고(호출), CPU 는 응답을 기다리지 않고 다른 작업을 수행한다. 이후 NIC 가 패킷을 전송하고 CPU 에 이 사실을 알리면 드라이버가 전송된 패킷을 반환한다. (결과 리턴) 수신도 이와 같이 비동기 방식으로 이루어진다.

 따라서 요청, 응답을 저장하는 장소가 필요하다. 대개 NIC 는 ring 구조체를 사용한다. 이들 엔트리들은 차례대로 돌아가며 사용한다. 돌아가며 고정된 엔트리들을 재사용하기 때문에 흔히 링이란 이름을 사용한다.

  1. 드라이버가 상위 레이어로부터 패킷을 받고, NIC 가 이해하는 전송 요청(send descriptor) 을 생성한다. send descriptor 에는 기본적으로 패킷 크기, 메모리 주소를 포함하도록 한다. NIC 는 메모리에 접근할 때 필요한 물리적 주소가 필요하다. 따라서 드라이버가 패킷의 가상 주소를 물리적 주소로 변경한다. 그리고 send descriptorTX ring 에 추가한다. TX ring 이 전송 요청 링이다.
  2. NIC 에 새로운 요청이 있다고 알린다. 이를 위해 NIC 메모리 주소에 드라이버가 직접 데이터를 쓰는데 이와 같은 방식을 PIO(Programmed I/O) 라고 한다.
  3. 연락을 받은 NICTX ringsend descriptor 를 호스트 메모리에서 가져온다. CPU 의 개입 없이 디바이스가 직접 메모리에 접근하기 때문에, 이와 같은 접근을 DMA(Direct Memery Access) 라고 부른다.
  4. Send descriptor 를 가져와서 패킷 주소와 크기를 판단하고, 실제 패킷을 호스트 메모리에서 가져온다. checksum offload 방식을 사용하면 메모리에서 패킷 데이터를 가져올 때 checksumNIC 가 계산하도록 한다.
  5. NIC 가 패킷을 전송하고
  6. 패킷을 몇 개 전송했는지 호스트의 메모리에 기록한다.
  7. 그리고 인터럽트를 보낸다. 드라이버는 전송된 패킷 수를 읽어 와서 현재까지 전송된 패킷을 반환한다.

8. 스택 내부 버퍼와 제어 흐름(flow control)


전체적으로 정리해봤는데 방금 커널의 코드(v6.10) 보고 오니 너무 괴리가 커서 어짜피 다시 한번 분석을 하긴 해야 할 것 같다.

profile
2000.11.30
post-custom-banner

0개의 댓글