본문 바로 가기

[Linux] Ring Buffer 에서 NAPI: 리눅스 커널 네트워크 패킷 수신 여정 #2

들어가며

지난 글에서는 NIC로 들어온 패킷이 CPU와 직접 통신하지 않고 Ring Buffer라는 중간 저장소로 먼저 전송되는 과정에 대해 이야기했습니다. 이번 글에서는 Ring Buffer에 도착한 패킷이 어떤 과정을 거쳐 리눅스 커널의 네트워크 스택으로 전달되는지 그 여정을 함께 따라가 보겠습니다.

리눅스 커널 코드를 직접 살펴보며 그 흐름을 파악할 것이지만, 방대한 코드의 모든 세부 사항을 다루기보다는 핵심적인 흐름을 이해하는 데 초점을 맞출 예정입니다. 따라서 많은 생략과 추상화가 포함되어 있음을 미리 알려드립니다. 글을 읽으시면서 더 깊은 의문이나 궁금증이 생긴다면, 직접 커널 코드를 추적 해보시는 것을 강력히 추천합니다!

환경

- NIC : Intel X710 시리즈
- 커널 : Linux 5.15

 

TL;DR

✅ NAPI: 네트워크 패킷 처리 효율을 높이기 위해 인터럽트와 폴링을 결합한 방식. 첫 패킷은 인터럽트로 알리고, 이후 패킷들은                             SoftIRQ 컨텍스트에서 폴링으로 처리.
✅ SoftIRQ 처리: sk_buff 생성, GRO 나 rx_list 를 이용한 최적화 작업이 이루어지고 커널 프로토콜 스택으로 전달된다.

 

👻 NAPI란 무엇인가?

NAPI(New API)는 리눅스 커널에서 네트워크 장치로부터 들어오는 패킷을 효율적으로 처리하기 위해 고안된 이벤트 핸들링 메커니즘입니다.

 

운영체제(OS)는 패킷 도착을 어떻게 알 수 있을까요? 가장 단순한 방법은 패킷이 도착할 때마다 CPU에 인터럽트(interrupt)를 발생시키는 것입니다. 하지만 이 방식은 네트워크 트래픽이 폭주하는 상황, 즉 초당 수많은 패킷이 밀려들어올 때 치명적인 단점을 드러냅니다. CPU는 인터럽트 처리에 모든 자원을 소모하게 되어 다른 중요한 작업을 처리하지 못하고 시스템 전체 성능이 저하될 수 있습니다 (interrupt storm).

 

NAPI는 이러한 문제를 해결하기 위해 인터럽트 방식과 폴링(polling) 방식을 혼합하여 사용합니다.

동작 방식은 다음과 같습니다:

  1. 첫 번째 패킷이 NIC의 Ring Buffer에 도착하면, NIC는 CPU에 하드웨어 인터럽트(Hard IRQ)를 발생시킵니다.
  2. CPU는 이 인터럽트를 받고, 해당 NIC에 대한 NAPI 폴링(polling) 작업을 스케줄링합니다. 이 시점부터 해당 NIC에 대한 하드웨어 인터럽트는 잠시 비활성화됩니다.
  3. 이후, 소프트웨어 인터럽트 컨텍스트에서 주기적으로 또는 필요에 따라 Ring Buffer 를 폴링하여 쌓여있는 패킷들을 커널 네트워크 스택으로 가져와 처리합니다.
  4. Ring Buffer의 모든 패킷을 처리했거나, 할당된 작업량(budget)을 모두 소진하면 폴링을 멈추고 다시 하드웨어 인터럽트를 활성화하여 새로운 패킷 도착을 기다립니다.

NAPI의 동작은 napi_struct라는 NAPI 인스턴스를 통해 관리됩니다. 일반적으로 하나의 수신 Ring Buffer(또는 수신 큐)마다 하나의 NAPI 인스턴스가 1:1로 매핑됩니다. 이 글에서도 이러한 1:1 관계를 기준으로 설명하겠습니다.

😁 napi_schedule: 폴링의 시작을 알리다

dev.c
이제 리눅스 커널 코드를 통해 실제 동작을 확인해 보겠습니다.

 

첫 번째 패킷 도착으로 하드웨어 인터럽트가 발생하면, 인터럽트 핸들러는 NAPI 폴링을 스케줄링하기 위해 napi_schedule 계열의 함수를 호출합니다. 그 내부에서는 ____napi_schedule 함수가 중요한 역할을 합니다.

static inline void ____napi_schedule(struct softnet_data *sd,
                     struct napi_struct *napi)
{
    if (test_bit(NAPI_STATE_THREADED, &napi->state)) {
        //Threaded NAPI 기능
    }

    list_add_tail(&napi->poll_list, &sd->poll_list);
    __raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

Threaded NAPI란?
NAPI의 폴링 작업은 전통적으로 SoftIRQ 컨텍스트에서 실행됩니다. 하지만 매우 높은 PPS(Packets Per Second) 환경에서는 SoftIRQ가 특정 CPU 코어를 장시간 점유하여 다른 SoftIRQ나 사용자 프로세스의 실행을 방해할 수 있습니다.

 

Threaded NAPI는 이러한 상황을 개선하기 위해 NAPI 폴링 작업을 전용 커널 스레드(kthread)로 분리하여 실행하는 기능입니다. 이를 통해 폴링 작업이 다른 중요한 작업들과 CPU 시간을 보다 공정하게 나눠 쓸 수 있게 됩니다.

 

Threaded NAPI 모드가 아니라면,
list_add_tail(&napi->poll_list, &sd->poll_list) 코드를 통해 현재 NAPI 인스턴스(napi)를 해당 CPU의 poll_list의 맨 뒤에 추가합니다. poll_list는 해당 CPU에서 처리해야 할 NAPI 인스턴스들의 목록입니다. 이렇게 목록에 추가함으로써, 나중에 SoftIRQ가 실행될 때 이 NAPI 인스턴스에 대한 폴링 작업을 수행할 수 있게 됩니다.

 

__raise_softirq_irqoff(NET_RX_SOFTIRQ)NET_RX_SOFTIRQ 타입의 SoftIRQ를 발생시키도록 요청(marking)합니다. 실제 SoftIRQ는 커널이 인터럽트 처리나 시스템 콜에서 복귀하는 등 안전한 시점에 일괄적으로 처리됩니다.

🍸 net_rx_action: SoftIRQ, 패킷 처리를 시작하다

NET_RX_SOFTIRQ가 실제로 실행되면, 커널은 net_rx_action 함수를 호출하여 본격적인 패킷 폴링 및 처리 작업을 시작합니다.
net_rx_action

static __latent_entropy void net_rx_action(struct softirq_action *h)
{
    struct softnet_data *sd = this_cpu_ptr(&softnet_data);
    int budget = netdev_budget;

    for (;;) {
        struct napi_struct *n;

        //poll_list 에서 인스턴스 하나 꺼낸다.
        n = list_first_entry(&list, struct napi_struct, poll_list); 
        // napi_poll의 반환 값은 처리한 패킷 수 입니다.
        budget -= napi_poll(n, &repoll);

        //budget 을 다 쓰면 반복문 탈출
        if (budget <= 0 ) { 
            break;
        }
    }
}

net_rx_action 함수를 보면 budget이라는 변수가 등장합니다. 이는 SoftIRQ 컨텍스트에서 한 번의 실행 주기 동안 처리할 수 있는 패킷 수를 제한하는 역할을 합니다. 저도 처음에는 패킷이 들어오는 대로 무한정 폴링할 것이라 생각했는데, 그렇지 않았습니다. 만약 budget 제한이 없다면, 네트워크 트래픽이 많은 상황에서 SoftIRQ가 CPU를 독점하여 시스템 반응성을 심각하게 떨어뜨릴 수 있습니다.

 

net_rx_action은 루프를 돌면서 poll_list에 등록된 NAPI 인스턴스들을 하나씩 꺼내 napi_poll 함수를 호출하고, 이때 budget을 적절히 분배하여 전달합니다. napi_poll이 반환한 작업량(처리한 패킷 수)만큼 budget을 차감하고, budget이 모두 소진되거나 poll_list가 비면 해당 SoftIRQ 주기는 종료됩니다. 만약 처리할 패킷이 아직 남아있다면, 다음 SoftIRQ 실행 시점에 다시 처리될 것입니다.

🎣 __napi_poll: 드라이버의 poll 함수를 호출하다

net_rx_action 내부에서 호출되는 napi_poll은 실제로는 __napi_poll 함수를 호출하는 래퍼(wrapper)에 가깝습니다. __napi_poll의 핵심 로직을 살펴봅시다.
__napi_poll

static int __napi_poll(struct napi_struct *n, bool *repoll)
{
    int work, weight;

    weight = n->weight; // NAPI 인스턴스에 할당된 가중치 (처리할 패킷 수)
    work = 0;

    work = n->poll(n, weight); // 실제 폴링 작업 수행

    return work;
}

코드를 따라가 보면, __napi_poll 함수는 전달받은 NAPI 인스턴스(n)에 정의된 poll 함수 포인터를 호출하는 것을 알 수 있습니다. n->poll(n, weight) 부분이 바로 그것입니다.

🗂 napi_struct: 드라이버별 구현의 연결고리

그렇다면 napi_structpoll 함수는 어디에 구현되어 있을까요?
napi_struct

struct napi_struct {
    struct list_head    poll_list;
    int            (*poll)(struct napi_struct *, int); // 함수 포인터
    ...
};

napi_struct 구조체를 보면 poll 멤버는 함수 포인터로 선언되어 있을 뿐, 그 자체에 구현부가 없습니다. 이는 마치 자바의 인터페이스(Interface)나 C++의 순수 가상함수(pure virtual function)와 유사한 개념으로 볼 수 있습니다. 핵심 커널은 "NAPI 인스턴스는 poll이라는 기능을 제공해야 한다"고 약속만 정의하고, 실제 poll 기능의 구체적인 구현은 각 네트워크 카드(NIC) 드라이버에 위임합니다.

 

왜 이렇게 설계했을까요? Ring Buffer에서 패킷을 읽어오는 방식, 하드웨어의 특성 등은 NIC 제조사와 모델별로 천차만별입니다. 커널이 모든 NIC의 상세한 동작 방식을 알 수는 없죠. 따라서 Ring Buffer에서 실제로 패킷을 가져와 처리하는 가장 효율적인 방법은 해당 하드웨어를 가장 잘 아는 NIC 드라이버가 구현하는 것이 타당합니다. 이것이 n->poll이 드라이버별로 구현되는 이유입니다.

 

 저는 "Ring Buffer 이후의 과정은 완전히 OS가 제어할 것"이라고 막연히 생각했었는데, 실제로는 OS 커널의 일반적인 프레임워크와 하드웨어 특수성을 반영한 드라이버 코드가 긴밀하게 협력하고 있었습니다.

 

그럼, 예시로 사용 중인 Intel X710 시리즈 NIC의 드라이버인 i40epoll 함수 구현체를 살펴보겠습니다.

🎤 i40e_napi_poll: Intel 드라이버의 패킷 처리

i40e 드라이버에서 napi_structpoll 함수 포인터는 i40e_napi_poll 함수를 가리키도록 초기화됩니다.
i40e_napi_poll

int i40e_napi_poll(struct napi_struct *napi, int budget)
{
    struct i40e_q_vector *q_vector =
                   container_of(napi, struct i40e_q_vector, napi);
    struct i40e_ring *ring;
    int work_done = 0;


    i40e_for_each_ring(ring, q_vector->rx) { 
        int cleaned = ring->xsk_pool ?
                  i40e_clean_rx_irq_zc(ring, budget_per_ring) :
                  i40e_clean_rx_irq(ring, budget_per_ring);

        work_done += cleaned;
        // 해당 링에서 budget만큼 다 처리했다면 아직 패킷이 더 있을 수 있음
        if (cleaned >= budget_per_ring) 
            clean_complete = false;
    }


    return min(work_done, budget - 1); 
}

i40e_napi_poll 함수는 인자로 받은 budget 내에서 최대한 많은 패킷을 처리하려고 시도합니다.

핵심은 다음 코드 줄입니다:

int cleaned = ring->xsk_pool ?
                  i40e_clean_rx_irq_zc(ring, budget_per_ring) :
                  i40e_clean_rx_irq(ring, budget_per_ring);

이 코드는 해당 Ring Buffer가 AF_XDP (Address Family - eXpress Data Path) 소켓과 연결되어 Zero-Copy 모드로 동작하는지 여부를 확인합니다.

  • AF_XDP Zero-Copy가 활성화된 상태면, i40e_clean_rx_irq_zc 함수가 호출됩니다. 이 경우 유저스페이스 애플리케이션이 Ring Buffer의 데이터에 직접 접근하여 커널과 유저스페이스 간 데이터 복사를 생략함으로써 성능을 극대화합니다.
  • 그렇지 않은 일반적인 경우에는 i40e_clean_rx_irq 함수가 호출됩니다.

대부분의 일반적인 네트워크 통신에서는 i40e_clean_rx_irq가 실행되므로, 이 함수를 더 따라가 보겠습니다.

🍃 i40e_clean_rx_irq: Ring Buffer에서 sk_buff로

i40e_clean_rx_irq

static int i40e_clean_rx_irq(struct i40e_ring *rx_ring, int budget)
{
    // 재활용 가능한 skb가 있다면 사용
    struct sk_buff *skb = rx_ring->skb; 
    int xdp_res = 0;


    while (likely(total_rx_packets < (unsigned int)budget)) {
        struct i40e_rx_buffer *rx_buffer;

        // Ring Buffer에서 다음 처리할 버퍼(패킷 데이터가 담긴)를 가져옴
        rx_buffer = i40e_get_rx_buffer(rx_ring, size, &rx_buffer_pgcnt); 

        /* xdp 쓰는지 확인 */
        if (!skb) { 
            xdp_res = i40e_run_xdp(rx_ring, &xdp); 
        }

        if (xdp_res) { 
            //xdp 처리.. (예: XDP_TX로 보내거나, XDP_DROP으로 버리거나)
        } else { 
            //XDP가 사용되지 않는 경우 skb 생성한다.
            skb = i40e_build_skb(rx_ring, rx_buffer, &xdp); 
        }

        //커널 네트워크 스택으로 전송할 준비
        napi_gro_receive(&rx_ring->q_vector->napi, skb);
        skb = NULL; 
        total_rx_packets++;
    }

    return failure ? budget : (int)total_rx_packets;
}

이 단계에서는 패킷을 sk_buff(Socket Buffer) 형태르 Wrapping 한다는 중요한 특징을 가지고 있습니다.

 

XDP (eXpress Data Path):
XDP는 커널 네트워크 스택에 도달하기 전, 드라이버 레벨에서 BPF 프로그램을 실행하여 패킷을 고속으로 처리(예: 필터링, 리다이렉션, 로드밸런싱)할 수 있는 강력한 기능입니다.

 

일반적인 경우에는,

skb = i40e_build_skb(rx_ring, rx_buffer, &xdp);
패킷 데이터를 커널 네트워킹 스택의 핵심 자료구조인 sk_buff 형태로 감쌉니다.

 

napi_gro_receive(&rx_ring->q_vector->napi, skb);
생성된 sk_buffnapi_gro_receive 함수로 전달되어 커널 네트워크 스택으로의 다음 단계를 준비합니다.

sk_buff: 커널 네트워크 데이터의 핵심

sk_buff
sk_buff는 리눅스 커널에서 네트워크 패킷을 표현하는 중심 자료구조입니다. 패킷 데이터 자체뿐만 아니라, 프로토콜 헤더 정보, 라우팅 정보, 소켓 정보 등 다양한 메타데이터를 담고 있습니다.

struct sk_buff {


    sk_buff_data_t        tail; // 데이터의 끝
    sk_buff_data_t        end;  // 버퍼의 끝
    unsigned char        *head, // 버퍼의 시작
                *data; // 데이터의 시작
    unsigned int        len,  // 총 패킷 길이
                data_len; // 프래그먼트된 데이터의 길이
    // ...
};

i40e_build_skb와 같은 함수가 sk_buff를 생성할 때, 일반적으로 Ring Buffer에 있는 패킷 데이터를 새로운 메모리 공간으로 복사하지 않습니다., sk_buff 구조체 내의 head, data, tail, end 등의 포인터들이 Ring Buffer 내의 실제 패킷 데이터가 저장된 메모리 영역을 가리키도록 설정합니다.

 

이렇게 함으로써 불필요한 메모리 복사를 최소화하여 성능을 향상시킵니다. 데이터는 나중에 상위 프로토콜 스택에서 필요할 때 복사되거나, 또는 끝까지 복사 없이 처리될 수도 있습니다.

⚾️ napi_gro_receive: GRO를 통한 최적화

napi_gro_receive
드라이버로부터 sk_buff를 전달받은 napi_gro_receive 함수는 다음 단계로 패킷을 넘깁니다.

gro_result_t napi_gro_receive(struct napi_struct *napi, struct sk_buff *skb)
{
    gro_result_t ret;

    // skb->dev->gro_receive 함수 포인터를 통해 실제 GRO 처리 함수 호출
    ret = napi_skb_finish(napi, skb, dev_gro_receive(napi, skb));
    return ret;
}

이 함수의 이름에서 알 수 있듯이, GRO (Generic Receive Offload) 라는 중요한 최적화 기법이 여기서 관여합니다. GRO는 연속적으로 수신되는 작은 크기의 패킷들(동일한 연결에 속하는 TCP 세그먼트 등)을 하나의 큰 가상 패킷으로 병합하는 기술입니다. 이렇게 하면 상위 네트워크 스택에서 처리해야 할 패킷의 수가 줄어들어 CPU 사용량을 낮추고 처리 효율을 높일 수 있습니다.

 

dev_gro_receive(napi, skb) 함수가 실제 GRO 처리를 담당합니다. 이 함수는 해당 sk_buff가 기존에 GRO 중이던 패킷 흐름에 병합될 수 있는지 확인하고, 가능하다면 병합합니다. 병합되지 않거나, GRO 대상이 아니거나, GRO가 완료된 패킷은 다음 단계로 넘어갑니다.

💎 gro_normal_one: 네트워크 스택으로의 최종 전달 준비

gro_normal_one
GRO 처리를 거친 (또는 GRO 대상이 아닌) 개별 패킷들은 최종적으로 커널 네트워크 프로토콜 스택으로 전달될 준비를 합니다.

static void gro_normal_one(struct napi_struct *napi, struct sk_buff *skb, int segs)
{
    // NAPI 인스턴스의 rx_list에 skb 추가
    list_add_tail(&skb->list, &napi->rx_list); 

    // rx_list에 쌓인 패킷 수가 임계치를 넘으면 일괄적으로 상위 스택으로 전달
    if (napi->rx_count >= gro_normal_batch) 
        gro_normal_list(napi);
}

여기서도 또 다른 최적화 기법이 사용됩니다. 패킷이 gro_normal_one 함수에 도달한다고 해서 즉시 프로토콜 스택(예: IP 계층)으로 전달되는 것은 아닙니다. 대신, sk_buff는 NAPI 인스턴스에 속한 rx_list라는 임시 리스트에 잠시 대기합니다.

 

일정 크기 이상이 되면, gro_normal_list 함수가 호출되어 rx_list에 쌓인 패킷들을 일괄적으로 커널의 프로토콜 스택으로 전달합니다.

마치며

지금까지 Ring Buffer에 도착한 패킷이 NAPI 메커니즘을 통해 폴링되고, NIC 드라이버에 의해 sk_buff로 변환된 후, GRO와 같은 최적화를 거쳐 커널의 핵심 네트워크 스택으로 전달되기 직전까지의 과정을 커널 코드와 함께 살펴보았습니다.

 

이 과정에는 하드웨어 인터럽트, SoftIRQ, NAPI, 드라이버별 구현, sk_buff 관리, 그리고 다양한 성능 최적화 기법들이 복잡하게 얽혀 있음을 알 수 있습니다. 다음 글에서는 이 패킷이 netif_receive_skb 이후 커널의 프로토콜 스택(IP, TCP/UDP 등)을 거쳐 최종적으로 사용자 애플리케이션의 소켓 버퍼까지 도달하는 여정을 계속해서 탐험해 볼 수 있을 것입니다.

 

긴 글 읽어주셔서 감사합니다!

Reference

https://docs.kernel.org/networking/napi.html
https://github.com/torvalds/linux/blob/v5.15/net/core/dev.c
https://github.com/torvalds/linux/blob/v5.15/include/linux/netdevice.h
https://github.com/torvalds/linux/blob/v5.15/include/linux/skbuff.h
https://github.com/torvalds/linux/blob/v5.15/drivers/net/ethernet/intel/i40e/i40e_txrx.c