본문 바로가기

CS/Network

TCP/IP 네트워크 스택 이해하기

TCP/IP의 중요한 성질

Connection oriented

먼저 두 개의 엔드포인트(local, remote) 사이에 연결을 맺은 후 데이터를 주고받는다. 이때 TCP 연결 식별자는 <local IP 주소, local 포트 번호, remote IP 주소, remote 포트 번호> 형태이다.

 

Bidirectional byte stream

양방향 데이터 통신을 하고, 바이트 스트림을 사용한다. 이 바이트 스트림은 여러 패킷으로 분할되어 전송되지만, 수신측의 TCP에서 이러한 패킷을 일련의 바이트 스트림으로 재조립하여 응용 계층에서 처리할 수 있게 한다.

 

In-order delivery

송신자가 보낸 순서대로 수신자가 데이터를 받는다. 이를 위해서는 데이터 순서가 필요한데 순서를 표시하기 위해 TCP 세그먼트 필드에 32비트의 sequence number 필드가 있다.

 

Reliability through ACK

데이터를 송신하고 수신자로부터 ACK을 받지 못하면, 송신자 TCP가 데이터를 재전송한다. 따라서 송신자는 전송한 데이터를 일정 기간 동안 버퍼에 보관한다. 수신자로부터 ACK을 받으면 해당 데이터가 성공적으로 전송되었음이 확인되어 보관된 데이터를 제거한다. 만약 일정 시간(타이머) 내에 ACK을 받지 못하면, 데이터를 재전송한다. 이때 재전송 타이머는 RTT(Round Trip Time)와 같은 네트워크 지연 시간을 기반으로 설정된다. 

 

Flow control

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

 

Congestion control

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

데이터 전송

네트워크 스택에는 여러 레이어가 있다. 크게 유저(user) 영역, 커널(kernel) 영역, 디바이스(device) 영역으로 나눌 수 있다. 유저 영역과 커널 영역에서의 작업은 CPU가 수행한다. 그리고 이 유저 영역과 커널 영역은 디바이스 영역과 구별하기 위해 호스트(host)라고 부른다. 여기서 디바이스는 패킷을 송수신하는 NIC(Network Interface Card)이다. 

 

Application

애플리케이션이 전송할 데이터를 생성하고(User data), write 시스템 콜을 호출해서 데이터를 보낸다. 시스템 콜을 호출하면 유저 모드에서 커널 모드로 전환된다.

File

Linux나 Unix를 포함한 POSIX 계열 운영체제에서 소켓은 파일의 한 종류로 취급된다. 따라서 소켓은 파일 디스크립터로 애플리케이션에 노출된다. 파일 레이어에서는 단순한 검사만 하고 파일 구조체에 연결된 소켓 구조체를 사용해서 소켓 함수를 호출한다.

Sockets

커널 소켓은 두 개의 버퍼를 갖고있다. 송신용의 send socket buffer와 수신용의 receive socket buffer이다. write 시스템 콜을 호출하면 유저 영역의 데이터가 커널 메모리로 복사되고, 순서대로 전송하기 위해 send socket buffer의 뒷부분에 추가된다.

TCP

소켓과 연결된 TCP Control Block(TCB) 구조체가 있다. TCB에는 다음과 같은 TCP 연결 처리에 대한 정보들이 있다. 참고로 TCP는 운영체제 커널 메모리에 위치한다.

  • connection state(LISTEN, ESTABLISHED, TIME_WAIT 등)
  • receive window
  • congestion window
  • sequence 번호
  • 재전송 타이머
  • ...

Flow control 같은 이유로 데이터 전송이 불가능하면 시스템 콜은 여기서 끝나고, 유저 모드로 돌아간다(즉, 애플리케이션으로 제어권이 넘어간다). 현재 TCP 상태가 데이터 전송을 허용하면 새로운 TCP 세그먼트를 생성한다. 

 

TCP 세그먼트에는 TCP 헤더와 페이로드가 있다. 페이로드에는 ACK를 받지 않은 send socket buffer에 있는 데이터가 담겨 있다. 페이로드의 최대 길이는 receive window, congestion window, MSS(Maximum Segment Size) 중 최소 값이다.

 

그리고 TCP 헤더에는 checksum 필드가 존재한다. TCP는 체크섬을 계산하여 해당 필드를 채운다.

checksum offload

사실 요즘의 네트워크 스택에서는 checksum offload 기술을 사용하기 때문에, 커널이 직접 TCP checksum을 계산하지 않고 대신 NIC가 checksum을 계산한다. 체크섬 계산은 CPU 자원을 많이 소모할 수 있다. 체크섬 오프로드는 이 작업을 NIC(Network Interface Controller)로 넘겨 CPU의 부하를 줄인다. NIC는 하드웨어 수준에서 체크섬을 빠르게 계산할 수 있다. 이는 성능을 향상시킨다. 따라서, 현대의 네트워크 스택에서는 일반적으로 커널이 직접 체크섬을 계산하지 않고, NIC가 이 작업을 대신한다. 하지만 여전히 커널은 NIC가 이 기능을 지원하지 않는 경우를 대비해 소프트웨어로 체크섬을 계산할 수 있는 능력을 유지하고 있다.

checksum

TCP는 세그먼트 송신 중에 발생할 수 있는 비트 오류를 검출하기 위해 체크섬을 활용한다. 송신자는 체크섬 알고리즘에 따라 계산한 체크섬을 TCP 헤더의 checksum 필드에 삽입하여 송신한다. 수신자는 수신받은 데이터를 동일 알고리즘으로 검사함으로써 오류 여부를 파악한다. 만약 체크섬이 올바르지 않으면 해당 세그먼트를 버린다. TCP는 pseudo 헤더라는 것을 만들어 checksum 계산에 활용한다.

 

체크섬 계산 과정은 다음과 같다.

  1. pseudo 헤더를 생성한다. pseudo 헤더는 총 12바이트로, 일부 다음 데이터들을 기반으로 만들어진다.
    1. source IP(4), destination IP(4)
    2. reserved(1): 예약된 필드를 의미하며, 항상 0이다.
    3. 프로토콜(1): IP 헤더에서 알아낸 프로토콜 필드 값으로, TCP 프로토콜은 6이다.
    4. TCP 세그먼트 길이(2): TCP 헤더와 페이로드의 총 길이이다.
  2. 만들어진 pseudo 헤더 및 TCP 세그먼트의 16비트 단위 합을 구한다. (warp around 적용)
    1. Pseudo 헤더의 합: pseudo 헤더는 총 12 바이트 = 96비트이다. 이를 16비트씩 나누어  16진수로 표현한 다음, 그 합을 계산하여 16으로 나눈 나머지가 checksum이 된다.
    2. TCP 세그먼트의 합: TCP 헤더와 페이로드에 대한 16비트 단위 합을 구한다. 이때 TCP 헤더의 체크섬 부분은 0으로 계산한다.
  3. 두 결과를 합하고 1의 보수를 적용한다.

IP

생선된 TCP 세그먼트는 IP 레이어로 내려간다. IP 레이어에서는 TCP 세그먼트에 IP 헤더를 추가하고 IP 라우팅을 한다. IP 라우팅이란, 목적지 IP 주소로 가기 위한 다음 장비의 IP 주소를 찾는 과정이다.

 

IP 헤더에도 checksum 필드가 있다. IP 레이어에서 IP 체크섬을 계산하여 헤더에 덧붙인 후, Ethernet 레이어로 데이터를 보낸다.

  • IP 체크섬은 IP 페이로드는 포함하지 않고 헤더만을 대상으로 하며, IP 헤더의 무결성을 확인하기 위해 사용된다. 
  • TCP 체크섬은 TCP 헤더와 페이로드를 포함한 전체 세그먼트를 대상으로 하며, TCP 헤더와 페이로드의 무결성을 확인하기 위해 사용된다. 그리고 체크섬 계산에 pseudo 헤더가 포함된다. 

Ethernet

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

Driver & NIC

운영체제의 네트워크 스택은 준비된 패킷을 NIC 드라이버로 전달한다. NIC 드라이버는 패킷을 NIC으로 전달하고 패킷 전송을 위한 명령을 내린다. 드라이버는 NIC 제조사가 정의한 드라이버-NIC 통신 규약에 따라 패킷 전송을 요청한다.

 

NIC는 드라이버로부터 패킷을 자신의 메모리(송신 버퍼)로 복사하고, 이를 네트워크로 전송한다. 이때 Ethernet 표준에 따라 IFG(Inter-Frame Gap), preamble, 그리고 CRC를 패킷에 추가한다. IFG, preamble은 패킷의 시작을 판단하기 위해 사용하고(네트워킹 용어로는 framing), CRC는 데이터 보호를 위해 사용한다(TCP, IP checksum과 같은 용도이다). 패킷 전송은 Ethernet의 물리적 속도, 그리고 Ethernet flow control에 따라 전송할 수 있는 상황일 때 시작된다. 회의장에서 발언권을 얻고 말하는 것과 비슷하다.

 

NIC가 패킷 전송을 완료하면 NIC는 송신 작업이 완료되었음을 알리기 위해 호스트 CPU에 인터럽트를 발생시킨다. 각 인터럽트는 고유 번호를 가지며, 이 번호는 CPU가 어떤 장치 또는 이벤트가 인터럽트를 발생시켰는지 식별하는 데 사용된다. 그리고 운영체제는 이 인터럽트 번호를 기반으로, 이 인터럽트를 처리할 수 있는 인터럽트 핸들러를 찾아 호출하고, 핸들러는 전송한 패킷을 운영체제에 반환한다. 

 

드라이버는 인터럽트를 처리할 수 있는 함수(인터럽트 핸들러)를 드라이브가 가동되었을 때 운영체제에 등록해둔다. 운영체제가 핸들러를 찾아 호출한다. 이 핸들러는 패킷 전송 완료와 관련된 작업들을 수행한다.

데이터 수신

NIC & Driver

우선 NIC가 패킷을 자신의 메모리에 기록한다. CRC 검사로 패킷이 올바른지 검사하고, 호스트의 메모리 버퍼로 전송한다. 이 버퍼는 드라이버가 커널에 요청하여 패킷 수신용으로 미리 할당한 메모리이고, 할당을 받은 후 드라이버는 NIC에 메모리 주소와 크기를 알려준다. NIC가 패킷을 받았는데, 드라이버가 미리 할당해 놓은 호스트의 메모리 버퍼가 없으면 NIC가 패킷을 버릴 수 있다(drop). 

 

패킷을 호스트 메모리로 전송한 후, NIC가 호스트 운영체제에게 인터럽트를 보낸다. NIC는 특정 하드웨어 인터럽트 요청 라인에 연결되어 있다. 이 라인은 CPU와 NIC 간 신호 통로 역할을 하며, NIC가 인터럽트를 발생시키면 이 라인을 통해 CPU에 신호를 전달한다.

Ethernet

Ethernet 레이어에서도 패킷이 올바른지 검사하고, 상위 프로토콜(네트워크 프로토콜)을 찾는다(de-multiplex). 이때 Ethernet 헤더의 ethertype 값을 사용한다. IPv4 ethertype은 0x0800이다. Ethernet 헤더를 제거하고 IP 레이어로 패킷을 전달한다.

 IP

IP 레이어에서도 패킷이 올바른지 검사한다. IP 헤더 checksum을 확인하는 것이다. 논리적으로 여기서 IP routing을 해서 패킷을 로컬 장비가 처리해야 하는지, 아니면 다른 장비로 전달해야 하는지 판단한다. 로컬 장비가 처리해야 하는 패킷이면 IP 헤더의 proto 값을 보고 상위 프로토콜(트랜스포트 프로토콜)을 찾는다. TCP proto 값은 6이다. IP 헤더를 제거하고 TCP 레이어로 패킷을 전달한다.

TCP

하위 레이어에서와 마찬가지로 TCP 레이어에서도 패킷이 올바른지 검사한다. TCP checksum도 확인한다. 앞서 언급했듯이 요즘의 네트워크 스택에는 checksum offload 기술이 적용되어 있기 때문에 커널이 checksum을 직접 계산하지 않는다.

 

다음으로 패킷이 속하는 연결, 즉 TCP control block을 찾는다. 이때 패킷의 <소스 IP, 소스 port, 타깃 IP, 타깃 port>를 식별자로 사용한다. 연결을 찾으면 프로토콜을 수행해서 받은 패킷을 처리한다. 새로운 데이터를 받았다면, 데이터를 receive socket buffer에 추가한다. TCP 상태에 따라 새로운 TCP 패킷(예를 들어 ACK 패킷)을 전송할 수 있다. 여기까지 해서 TCP/IP 수신 패킷 처리 과정이 끝나게 된다.

 

Receive socket buffer 크기가 결국은 TCP의 receive window이다. 어느 지점까지는 receive window가 크면 TCP throughput이 증가한다. 예전에는 socket buffer 크기를 애플리케이션이나 운영체제 설정에서 조절하고는 했다. 최신 네트워크 스택은 receive socket buffer 크기, 즉 receive window를 자동으로 조절하는 기능을 가지고 있다.

 

이후 애플리케이션이 read 시스템 콜을 호출하면 커널 영역으로 전환되고, socket buffer에 있는 데이터를 유저 공간의 메모리로 복사해 간다. 복사한 데이터는 socket buffer에서 제거한다. 그리고 TCP를 호출한다. TCP는 socket buffer에 새로운 공간이 생겼기 때문에 receive window를 증가시킨다. 그리고 ACK을 보내어 상대방에게 새로운 receive window를 알린다.

드라이버와 NIC 통신 과정

드라이버와 NIC은 비동기 방식으로 통신한다.

드라이버가 패킷 전송을 요청하고(호출), CPU는 그 응답을 기다리지 않고 다른 작업을 수행한다. 이후 NIC가 패킷을 전송한 후 인터럽트를 발생시켜 CPU에 전송 완료를 알리면, 드라이버가 전송된 패킷을 반환한다(결과 리턴). 

수신도 이와 같이 비동기 방식으로 이뤄진다. 먼저 드라이버가 수신 요청을 하고, CPU는 다른 작업을 수행한다(호출). 이후 NIC가 패킷을 받으면 CPU에 이 사실을 알리고, 드라이버가 받은 패킷을 처리한다(결과 리턴).

 

라서 요청, 응답을 저장하는 장소가 필요하다. 대개 NIC는 링(ring) 구조체를 사용한다.

패킷 전송 과정

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

패킷 수신 과정

  1. 우선 드라이버가 패킷 수신용 호스트 메모리 버퍼를 할당하고, receive descriptor를 생성한다. receive descriptor는 기본으로 버퍼의 크기, 주소를 포함한다. send descriptor와 같이 DMA가 사용하는 물리적 주소를 descriptor에 저장한다. 그리고 RX ring에 descriptor를 추가한다. 결국 이것이 수신 요청이고, RX ring은 수신 요청 링이다.
  2. 드라이버가 PIO를 통해서 NIC에 새로운 descriptor가 있다고 알린다.
  3. NIC는 RX ring의 새로운 descriptor를 가져온다. 그리고 descriptor에 포함된 버퍼의 크기, 위치를 NIC 메모리에 보관한다.
  4. 이후 패킷이 도착하면,
  5. NIC는 호스트 메모리 버퍼로 패킷을 전송한다. Checksum offload 기능이 있다면 NIC가 이때 checksum을 계산한다.
  6. 도착한 패킷의 실제 크기와 checksum 결과, 그 외 다른 정보는 별도의 링(receive return ring)에 기록한다. Receive return ring은 수신 요청 처리 결과, 즉 응답을 저장하는 링이다.
  7. 그리고 NIC가 인터럽트를 보낸다. 드라이버는 receive return ring에서 패킷 정보를 가져와서 받은 패킷을 처리한다. 필요에 따라 새로운 메모리 버퍼를 할당하고 (1)~(2) 단계를 반복한다.

스택 튜닝이라고 하면 흔히 ring, interrupt 설정을 조절해야 한다고 이야기한다. TX ring이 크면 한 번에 많은 수의 전송 요청을 할 수 있다. RX ring이 크면 한 번에 많은 수의 수신을 할 수 있다. 패킷 송신, 수신 burst가 많은 워크로드에는 큰 링이 도움이 된다. 그리고 CPU가 인터럽트를 처리하는 오버헤드가 크기 때문에, 대개 NIC은 인터럽트 회수를 줄이기 위해 타이머를 사용한다. 패킷을 전송하고 수신할 때 매번 인터럽트를 보내지 않고 주기적으로 모아서 보낸다(interrupt coalescing).

스택 내부 버퍼와 제어 흐름

스택 내부의 여러 단에서 flow control을 수행한다.

패킷 전송 시 사용하는 버퍼 과정

  • 우선, 애플리케이션이 데이터를 생성하고 send socket buffer에 추가한다. 공간이 없으면 시스템 콜이 실패하거나, 애플리케이션 스레드에 블로킹이 발생한다. 따라서 커널로 유입되는 애플리케이션 데이터의 속도는 socket buffer 크기 제한을 통해 제어하도록 한다.
  • TCP가 패킷을 생성해서 드라이버로 보낼 때는 transmit queue(qdisc)를 통하도록 하고 있다. 기본적인 FIFO 큐 형태이고, 큐의 최대 길이는 ifconfig 명령어를 실행할 때 확인할 수 있는 txqueuelen의 값이다. 보통 수 천 패킷 정도이다.
  • 드라이버와 NIC 사이에는 TX ring이 있다. 앞서 설명했듯, 전송 요청 큐로 보면 된다. 큐 공간이 없으면 전송 요청을 못하고 패킷은 transmit queue에 적제된다. 너무 많이 적제되면 패킷 드롭을 한다.
  • NIC는 내부 버퍼에 전송할 패킷을 저장한다. 이 버퍼에서 패킷이 빠져나가는 속도는 우선 물리적 속도에 영향을 받는다(예: 1 Gb/s NIC가 10 Gb/s 성능을 낼 수는 없다). 그리고 Ethernet flow control을 사용하면 수신 NIC 버퍼에 공간이 없을 때는 전송이 멈춘다.
  • 커널이 전송하는 패킷 속도가 NIC가 전송하는 속도보다 빠르면, 우선 NIC 내부 버퍼에 패킷이 적체된다. 버퍼에 공간이 없으면 TX ring의 전송 요청 처리를 멈춘다. TX ring에 점점 많은 요청이 적체되고, 결국은 큐 공간이 없어진다. 드라이버는 전송 요청을 못하고 패킷은 transmit queue에 적체된다. 이와 같이 여러 버퍼를 통해 backpressure가 밑에서 위로 올라간다.

패킷 수신 시 사용하는 버퍼 과정

  • 패킷은 NIC 내부 수신 버퍼에 저장된다. Flow control 관점에서 보면 드라이버와 NIC 사이의 RX ring를 패킷 버퍼로 생각하면 된다. RX ring에 들어간 패킷은 드라이버가 꺼내서 상위 레이어로 보낸다. 서버 장비가 사용하는 NIC 드라이버는 기본으로 NAPI를 사용하기 때문에 드라이버와 상위 레이어 사이에 버퍼는 없다. 상위 레이어가 RX ring에서 직접 패킷을 가져간다고 생각하면 된다. 그리고 패킷의 페이로드 데이터는 receive socket buffer에 들어간다. 이후 애플리케이션이 socket buffer에서 데이터를 가져간다.
  • 커널의 패킷 처리 속도가 NIC로 유입되는 패킷 속도보다 느리면 RX ring 공간이 없어진다. 그리고 NIC 내부 버퍼 공간도 없어진다. Ethernet flow control을 사용하면 NIC가 송신 NIC에 송신 정지 요청을 보내거나 패킷 드롭을 한다.

TCP는 end-to-end flow control을 지원하기 때문에, receive socket buffer 공간 부족으로 인한 패킷 드롭은 없다. 하지만 UDP는 flow control을 지원하지 않기 때문에, 애플리케이션 속도가 느리면 socket buffer 공간 부족으로 패킷 드롭이 발생한다.

드라이버가 사용하는 TX ring의 크기와 RX ring의 크기가 ethtool이 보여 주는 링의 크기다. 대개 throughput을 중요시하는 워크로드에는 링의 크기, socket buffer 크기를 늘리면 도움이 된다. 많은 패킷을 빠른 속도로 전송, 수신할 때 버퍼 공간 부족으로 인한 실패 확률이 줄어들기 때문이다.


Reference

'CS > Network' 카테고리의 다른 글

링크 계층  (0) 2024.03.30
네트워크 계층: IP  (0) 2024.03.28
전송 계층: UDP, TCP  (1) 2024.03.26
소켓(Socket)  (0) 2024.03.26
애플리케이션 계층: DNS  (1) 2024.03.26