네트워크 패킷 흐름 & 디버깅

Linux 네트워크 스택 고급 주제를 다룹니다. 멀티코어 NIC 환경에서 RSS/RPS/RFS/XPS/aRFS가 실제로 트래픽을 어떻게 분산하는지, GRO/GSO가 CPU 사용률과 레이턴시에 미치는 영향, conntrack과 Netfilter 경로의 오버헤드, 패킷 드롭·큐 적체·reorder 원인을 추적하는 방법, NUMA/IRQ affinity/busy-poll 기반 튜닝 전략까지 실전 중심으로 설명합니다.

전제 조건: 커널 아키텍처 문서를 먼저 읽으세요. CPU, 메모리, 인터럽트의 기본 흐름을 알고 있으면 본 문서를 더 빠르게 이해할 수 있습니다.
일상 비유: 이 개념은 작업 순서표를 따라 문제를 해결하는 과정과 비슷합니다. 핵심 용어를 먼저 잡고, 실제 동작 순서를 단계별로 확인하면 복잡한 커널 내부 동작을 안정적으로 이해할 수 있습니다.

핵심 요약

  • 핵심 객체 — 이 문서의 중심이 되는 자료구조/API를 먼저 파악합니다.
  • 실행 경로 — 요청이 들어와 처리되고 종료되는 흐름을 확인합니다.
  • 병목 지점 — 지연이나 처리량 저하가 발생하는 구간을 점검합니다.
  • 동기화 지점 — 경합과 경쟁 조건이 생길 수 있는 구간을 구분합니다.
  • 운영 포인트 — 관측 지표와 튜닝 항목을 함께 확인합니다.

단계별 이해

  1. 구성요소 확인
    핵심 자료구조와 주요 API를 먼저 식별합니다.
  2. 요청 흐름 추적
    입력부터 완료까지의 호출 경로를 순서대로 따라갑니다.
  3. 예외 경로 점검
    실패 처리, 재시도, 타임아웃 등 경계 조건을 확인합니다.
  4. 성능/안정성 점검
    잠금 경합, 큐 적체, 병목 지점을 측정하고 조정합니다.
기초 개념: 네트워크 스택에서 기본 개념(sk_buff, 소켓 계층, TCP)을 먼저 확인하세요.

NAPI 심화 — 성능 튜닝과 주의사항

NAPI 상태 전이와 Budget 관리

DISABLED SCHED (대기) POLLING COMPLETE enable softirq done<budget IRQ → napi_schedule() done==budget (계속 폴링)
매개변수기본값설명튜닝 가이드
budget (per-NAPI) 64 poll() 한 번 호출에서 처리할 최대 패킷 수 증가 시 처리량↑, 지연↑. NIC 드라이버에서 설정
netdev_budget 300 softirq 한 사이클에서 모든 NAPI의 총 처리량 10G+ 환경에서 600~1200으로 증가 고려
netdev_budget_usecs 2000 (2ms) softirq 한 사이클의 시간 제한 지연 민감 환경에서 감소, 처리량 중시에서 증가
busy_poll 0 (비활성) 소켓 busy polling 시간 (μs) 50~100μs 설정 시 지연 감소 (CPU 사용률 증가)
busy_read 0 (비활성) 소켓 읽기 busy polling 시간 (μs) busy_poll과 함께 설정

GRO (Generic Receive Offload)

GRO는 NAPI poll 내에서 수신된 여러 패킷을 하나의 대형 skb로 병합하여 상위 스택 호출 횟수를 줄입니다. LRO(Large Receive Offload)의 소프트웨어 대체로, 원본 헤더 정보를 보존하여 포워딩 환경에서도 안전합니다.

/* === GRO 수신 경로 ===
 *
 * NIC IRQ → napi_schedule()
 *  └→ NAPI poll()
 *      └→ napi_gro_receive(napi, skb)
 *          └→ dev_gro_receive()
 *              └→ inet_gro_receive()        (L3: IP)
 *                  └→ tcp4_gro_receive()    (L4: TCP)
 *                      ├→ 동일 flow 검색 (rxhash → gro_hash 버킷)
 *                      ├→ 병합 기준 검증:
 *                      │   - 동일 src/dst IP + port
 *                      │   - TCP seq 연속 (이전 끝 + 1)
 *                      │   - ACK만 설정 (SYN/FIN/RST → 거부)
 *                      │   - 윈도우 크기 동일
 *                      │   - TCP 타임스탬프 일관성
 *                      └→ 결과:
 *                          GRO_MERGED     : 기존 skb에 병합
 *                          GRO_HELD       : gro_list에 보관 (새 flow)
 *                          GRO_NORMAL     : 병합 불가 → 일반 경로
 */

/* NAPI poll 함수에서 GRO 사용 패턴 */
napi_gro_receive(napi, skb);   /* 일반적: 완전한 skb를 GRO 처리 */
napi_gro_frags(napi);          /* 페이지 기반 수신 시 (헤더/데이터 분리)
                                 * 고성능 NIC 드라이버에서 선호:
                                 *   napi->skb에 헤더(선형) + 페이로드(frag)
                                 *   → 메모리 복사 최소화 */

/* GRO 데이터 병합 방식 */
/* 1. frag 기반: skb_shinfo→frags[]에 페이지 추가 (MAX_SKB_FRAGS=17 제한)
 * 2. frag_list 기반: skb_shinfo→frag_list에 skb 체인 (제한 없음)
 *    → frag 공간 부족 시 자동 전환 */

/* GRO flush 조건:
 * 1. napi_complete_done() 호출 시 (budget 미만 처리)
 * 2. gro_hash 버킷에 MAX_GRO_SKBS(8)개 초과 시
 * 3. 비연속 패킷 수신 시 (seq 불연속, 다른 플래그)
 * 4. gro_flush_timeout 타이머 만료 시
 *    → sysctl net.core.gro_flush_timeout (기본 0 = 즉시 flush)
 *    → net.core.napi_defer_hard_irqs와 함께 사용하면 GRO 효율↑ */

/* GRO 성능 효과 예시 (1500 MTU, TCP):
 *   GRO OFF: 1M pps → 1M번 netif_receive_skb() 호출
 *   GRO ON:  1M pps → ~15K번 호출 (64KB super-packet 생성)
 *   → CPU 사용률 대폭 감소, 처리량 증가
 *
 * 포워딩 환경 (라우터, 브리지):
 *   GRO OFF: 43개 패킷 × routing/conntrack/NAT
 *   GRO ON:  1개 대형 skb × routing/conntrack/NAT → ~43배 효율 */

/* HW-GRO (커널 5.19+) — NIC가 GRO 수행하되 헤더 보존 */
/* # ethtool -K eth0 rx-gro-hw on
 * → LRO와 달리 원본 헤더 정보 유지 → 포워딩에도 안전
 * → NIC의 RSC(Receive Side Coalescing) 기능 활용 */

/* GRO 제어 및 확인 */
/* # ethtool -K eth0 gro on|off           # SW GRO 전환 */
/* # ethtool -K eth0 rx-gro-hw on|off     # HW GRO 전환 (5.19+) */
/* # ethtool -k eth0 | grep gro           # 상태 확인 */
/* # ethtool -S eth0 | grep gro           # GRO 통계 확인 */
GRO 상세 분석: 병합 기준, 프로토콜별 콜백 체인, flush 메커니즘, HW-GRO 등 심화 내용은 GSO/GRO — 심화 섹션을 참고하세요.
NAPI 폴링 루프, busy polling, 적응형 인터럽트 조절, GRO 통합 아키텍처, 멀티큐 스케일링 등 NAPI 전반에 대한 심화 내용은 NAPI 심화 문서를 참고하세요.

NAPI 드라이버 구현 주의사항

NAPI 구현 시 흔한 실수:
  1. budget 미준수 — poll 함수가 budget 이상 처리하면 안 됨. 정확히 budget만큼 처리했으면 budget 반환, 적게 처리하면 실제 수를 반환
  2. napi_complete_done 누락 — work_done < budget일 때 반드시 호출해야 다음 IRQ에서 재스케줄 가능
  3. IRQ 재활성화 순서napi_complete_done() 이후에 HW 인터럽트를 재활성화해야 함. 순서가 반대면 race condition
  4. 멀티큐 미고려 — RSS/멀티큐 NIC에서는 큐마다 별도의 NAPI 인스턴스 필요. CPU affinity 설정 중요
  5. RX 링 고갈 — poll에서 버퍼 refill을 하지 않으면 RX 링이 비어서 패킷 드롭 발생

RSS, RPS, RFS — 멀티코어 네트워크 분산

기법계층설명설정
RSS Hardware NIC가 flow hash로 큐 분배 (H/W 인터럽트 분산) ethtool -L eth0 combined 8
RPS Software 커널에서 패킷을 CPU로 분배 (RSS 미지원 NIC용) echo ff > /sys/class/net/eth0/queues/rx-0/rps_cpus
RFS Software 패킷을 해당 소켓을 처리하는 CPU로 전달 (캐시 친화) echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
XPS Software TX 큐를 CPU에 매핑 (TX 측 분산) echo 1 > /sys/class/net/eth0/queues/tx-0/xps_cpus
aRFS HW+SW NIC가 flow를 올바른 RX 큐로 직접 스티어링 NIC ntuple filter + RFS 조합

Toeplitz Hash — RSS 해시 알고리즘

RSS의 핵심은 NIC 하드웨어가 패킷 헤더로부터 해시 값을 계산하여 수신 큐를 결정하는 것이다. 대부분의 NIC는 Microsoft가 정의한 Toeplitz 해시를 사용한다. Toeplitz 해시는 XOR 기반의 비트 연산으로, 하드웨어 구현이 매우 단순하면서도 트래픽 분산 특성이 우수하다.

해시 입력 (Hash Input)

NIC는 패킷 유형에 따라 해시 입력 필드를 선택한다:

해시 타입입력 필드적용 대상
4-tuple src IP, dst IP, src port, dst port TCP, UDP, SCTP
2-tuple src IP, dst IP non-TCP/UDP IPv4, IPv6 (포트 없는 프로토콜)
확장 src IP, dst IP, SPI (Security Parameter Index) IPsec (ESP/AH)
참고: UDP의 경우, 단편화(fragmentation) 시 첫 번째 단편만 포트 정보를 포함하므로 후속 단편은 2-tuple로 해싱된다. 이로 인해 동일 플로우의 단편이 다른 큐로 분배될 수 있다. ethtool -N eth0 rx-flow-hash udp4 sd로 UDP를 2-tuple로 고정하면 이 문제를 완화할 수 있다.

Toeplitz 해시 알고리즘

Toeplitz 해시는 해시 키(Key)입력 데이터를 비트 단위로 XOR 누적하여 32비트 해시를 생성한다:

/*
 * Toeplitz Hash 의사코드
 *
 * input[]:  해시 입력 (예: src_ip + dst_ip + src_port + dst_port)
 *           IPv4 4-tuple = 12바이트 (96비트)
 * key[]:    해시 키 (40바이트 = 320비트, 네트워크 바이트 순서)
 * 결과:     32비트 해시 값
 */
uint32_t toeplitz_hash(uint8_t *input, int input_len, uint8_t *key)
{
    uint32_t result = 0;
    int i, j;

    for (i = 0; i < input_len; i++) {
        for (j = 0; j < 8; j++) {
            if (input[i] & (1 << (7 - j))) {
                /* key의 (i*8+j) 위치에서 시작하는 32비트를 XOR */
                result ^= get_unaligned_be32(key + i) << j
                        | (uint32_t)get_unaligned_be32(key + i + 4) >> (32 - j);
            }
        }
    }
    return result;
}
핵심 원리: 입력 데이터의 각 비트가 1이면, 해시 키의 해당 위치에서 시작하는 32비트 윈도우를 결과에 XOR한다. 입력 비트가 0이면 건너뛴다. 즉, 입력 비트가 키 윈도우를 선택(select)하는 구조로, 하드웨어에서 시프트 레지스터와 XOR 게이트만으로 구현 가능하다.

커널 내부의 소프트웨어 구현은 include/linux/netdevice.h의 인라인과 lib/toeplitz.c에 위치한다. RPS가 사용하는 소프트웨어 해시도 동일한 Toeplitz를 사용하며, net/core/flow_dissector.c__skb_get_hash()에서 호출된다:

/* include/linux/netdevice.h — 커널 소프트웨어 Toeplitz */
static inline __u32
__toeplitz_hash(const __u32 *key_cache, int nkeys,
                const __u32 *data, int ndata)
{
    __u32 hash = 0;
    int i;

    for (i = 0; i < ndata; i++)
        hash ^= toeplitz_byte(data[i], key_cache + i);
    return hash;
}

해시 키 (Hash Key)

Toeplitz 해시 키는 일반적으로 40바이트 (320비트)이다. NIC 드라이버가 초기화 시 기본 키를 설정하며, ethtool로 조회·변경할 수 있다:

# 현재 RSS 해시 키 조회
$ ethtool -x eth0
RX flow hash indirection table for eth0 with 4 RX ring(s):
    0:      0     1     2     3     0     1     2     3
    8:      0     1     2     3     0     1     2     3
  ...
RSS hash key:
6d:5a:56:da:25:5b:0e:c2:41:67:25:3d:43:a3:8f:b0:d0:ca:2b:cb:...

# 커스텀 해시 키 설정 (대칭 키 예시 — src/dst 교환 시 동일 해시)
$ ethtool -X eth0 hkey \
  6d:5a:56:da:25:5b:0e:c2:41:67:25:3d:43:a3:8f:b0:d0:ca:2b:cb:...

# 해시 함수 종류 확인 (toeplitz / xor / crc32)
$ ethtool -x eth0 | grep "RSS hash function"
RSS hash function:
    toeplitz: on
    xor: off
    crc32: off

# 해시 함수 변경 (NIC 지원 시)
$ ethtool -X eth0 hfunc toeplitz
대칭 해시 (Symmetric Hash): 기본 Toeplitz 키에서는 (A→B)(B→A) 트래픽이 다른 해시 값을 가져 서로 다른 큐로 분배될 수 있다. 연결 추적이나 양방향 플로우 모니터링이 필요한 경우, 대칭 키를 사용하면 src/dst를 교환해도 동일한 해시가 생성된다. 일부 NIC(Intel ixgbe 등)는 symmetric-xor 해시 함수를 별도로 지원한다.

해시 필드 설정 (ethtool -N)

프로토콜별로 해시에 사용할 필드를 세밀하게 제어할 수 있다:

# TCP4: 4-tuple 해시 (기본값)
$ ethtool -N eth0 rx-flow-hash tcp4 sdfn
# s=src IP, d=dst IP, f=src port, n=dst port

# UDP4: 2-tuple로 변경 (단편화 이슈 방지)
$ ethtool -N eth0 rx-flow-hash udp4 sd

# 현재 설정 확인
$ ethtool -n eth0 rx-flow-hash tcp4
TCP over IPV4 flows use these fields for computing Hash flow key:
IP SA
IP DA
L4 bytes 0 & 1 [TCP/UDP src port]
L4 bytes 2 & 3 [TCP/UDP dst port]

RETA — Redirection Table (인다이렉션 테이블)

Toeplitz 해시가 32비트 해시 값을 생성하면, NIC는 이 값의 하위 N비트를 인덱스로 사용하여 RETA(Redirection Table)를 참조한다. RETA의 각 엔트리는 실제 수신 큐 번호를 가리킨다.

수신 패킷 헤더 src/dst IP + port Toeplitz Hash + 40-byte Key 32-bit Hash 0x7A3B...F2 하위 N비트 추출 index = hash & (size-1) RETA (128 entries) [0]=Q0 [1]=Q1 [2]=Q2 [3]=Q3 [4]=Q0 [5]=Q1 [6]=Q2 [7]=Q3 ... ... ... ... [124]=Q0 [125]=Q1 [126]=Q2 [127]=Q3 RX Queue 0 RX Queue 1 RX Queue 2 → CPU 0 → CPU 1 → CPU 2

RETA 구조와 크기

NIC 계열RETA 크기인덱스 비트비고
Intel i350, 82576 128 엔트리 하위 7비트 GbE 서버용
Intel 82599 (ixgbe) 128 엔트리 하위 7비트 10GbE, SR-IOV 지원
Intel X710 (i40e) 512 엔트리 하위 9비트 더 세밀한 분배 가능
Intel E810 (ice) 2048 엔트리 하위 11비트 100GbE, ADQ 지원
Mellanox ConnectX-5/6 가변 (최대 4096) 가변 TIR (Transport Interface Receive) 기반
Broadcom BCM57xx 128 엔트리 하위 7비트 bnxt 드라이버

RETA의 각 엔트리는 0부터 시작하는 수신 큐 번호를 저장한다. 기본적으로 라운드 로빈(RETA[i] = i % num_queues)으로 초기화되며, 이렇게 하면 트래픽이 모든 큐에 균등하게 분배된다.

RETA 조회 및 설정

# RETA 인다이렉션 테이블 조회
$ ethtool -x eth0
RX flow hash indirection table for eth0 with 4 RX ring(s):
    0:      0     1     2     3     0     1     2     3
    8:      0     1     2     3     0     1     2     3
   16:      0     1     2     3     0     1     2     3
   24:      0     1     2     3     0     1     2     3
   ...

# 균등 분배 (기본값) — N개 큐에 라운드 로빈
$ ethtool -X eth0 equal 4
# RETA = [0,1,2,3,0,1,2,3,...] → 4개 큐 균등 분배

# 가중치 분배 — 큐별 비율 지정
$ ethtool -X eth0 weight 3 1 1 1
# Queue 0에 50%, Queue 1~3에 각 16.7%
# RETA = [0,0,0,1,0,0,0,2,...] 등으로 채워짐

# 특정 큐만 사용 (큐 0, 1만 활성)
$ ethtool -X eth0 weight 1 1 0 0
# Queue 2, 3은 RSS 트래픽 수신 안 함
NUMA 최적화: 큐를 특정 NUMA 노드의 CPU에만 매핑하면 캐시 효율이 향상된다. 예를 들어 8큐 NIC에서 NUMA 노드 0의 CPU 0~3만 사용하려면: ethtool -X eth0 weight 1 1 1 1 0 0 0 0으로 큐 0~3만 활성화하고, /proc/irq/<IRQ>/smp_affinity로 해당 큐의 IRQ를 같은 CPU에 고정한다.

커널 내부: RETA 설정 경로

RETA 설정은 ethtool_ops 콜백을 통해 드라이버로 전달된다:

/* include/linux/ethtool.h — 드라이버가 구현하는 콜백 */
struct ethtool_ops {
    /* RETA 인다이렉션 테이블 조회/설정 */
    int (*get_rxfh_indir_size)(struct net_device *);
    int (*get_rxfh)(struct net_device *,
                    struct ethtool_rxfh_param *);
    int (*set_rxfh)(struct net_device *,
                    struct ethtool_rxfh_param *,
                    struct netlink_ext_ack *);
    /* ... */
};

/* ethtool_rxfh_param — RETA + 해시 키 + 해시 함수를 한 번에 전달 */
struct ethtool_rxfh_param {
    u32 *indir;          /* RETA 테이블 (큐 번호 배열) */
    u8  *key;            /* Toeplitz 해시 키 */
    u8   hfunc;          /* 해시 함수 (ETH_RSS_HASH_*) */
    u32  indir_size;     /* RETA 엔트리 수 */
    u32  key_size;       /* 해시 키 바이트 수 */
};

예를 들어 Intel ixgbe 드라이버에서의 RETA 프로그래밍:

/* drivers/net/ethernet/intel/ixgbe/ixgbe_main.c
 * RETA를 하드웨어 레지스터에 기록 */
static void ixgbe_store_reta(struct ixgbe_adapter *adapter)
{
    u32 reta_entries = ixgbe_rss_indir_tbl_entries(adapter);  /* 128 */
    u32 i, reta = 0;

    for (i = 0; i < reta_entries; i++) {
        reta |= (u32)adapter->rss_indir_tbl[i] <<
                (i & 0x3) * 8;        /* 4개 엔트리를 32비트에 패킹 */
        if ((i & 3) == 3) {
            IXGBE_WRITE_REG(hw, IXGBE_RETA(i >> 2), reta);
            reta = 0;
        }
    }
}

RSS 전체 흐름 요약

단계위치동작
1. 패킷 수신 NIC H/W 패킷 헤더에서 해시 입력 필드 추출 (IP, port)
2. 해시 계산 NIC H/W Toeplitz(input, key) → 32비트 해시 값
3. RETA 참조 NIC H/W queue = RETA[hash & (reta_size - 1)]
4. DMA 전송 NIC → Memory 패킷을 해당 큐의 RX 링 버퍼에 DMA
5. 인터럽트 NIC → CPU 해당 큐에 바인딩된 CPU로 MSI-X 인터럽트 발생
6. NAPI poll 커널 해당 CPU에서 큐의 패킷 처리 (softirq)

RSS 디버깅 및 모니터링

# 큐별 패킷 수 확인 — 분배가 균등한지 검증
$ ethtool -S eth0 | grep rx_queue
     rx_queue_0_packets: 1523847
     rx_queue_1_packets: 1518293
     rx_queue_2_packets: 1521056
     rx_queue_3_packets: 1519834

# 큐별 IRQ 확인
$ grep eth0 /proc/interrupts
 128:   152384    0    0    0  IR-PCI-MSI eth0-TxRx-0
 129:        0  151829    0    0  IR-PCI-MSI eth0-TxRx-1
 130:        0    0  152105    0  IR-PCI-MSI eth0-TxRx-2
 131:        0    0    0  151983  IR-PCI-MSI eth0-TxRx-3

# IRQ affinity 설정 (큐 0 → CPU 0)
$ echo 1 > /proc/irq/128/smp_affinity

# 활성 큐 수 변경
$ ethtool -L eth0 combined 8     # combined RX+TX 8큐로
$ ethtool -l eth0                 # 현재 설정 확인
Channel parameters for eth0:
Pre-set maximums:
RX:     0
TX:     0
Other:  1
Combined:    63
Current hardware settings:
RX:     0
TX:     0
Other:  1
Combined:    8

# sk_buff의 해시 값 확인 (BPF로)
$ bpftrace -e 'kprobe:netif_receive_skb {
    printf("hash=0x%x queue=%d\n",
           ((struct sk_buff *)arg0)->hash,
           ((struct sk_buff *)arg0)->queue_mapping);
}'
큐 불균형 진단: ethtool -S에서 특정 큐에 트래픽이 집중되면: (1) 소수의 플로우가 대부분의 트래픽을 차지하는지 확인 (elephant flow), (2) 해시 키를 변경하여 분포 개선 시도, (3) Flow Director(ntuple filter)로 특정 플로우를 지정된 큐로 스티어링하는 것을 고려한다.

Flow Director — 정밀 플로우 스티어링

RSS의 해시 기반 분배로 충분하지 않을 때, Flow Director (Intel의 fdir / ntuple filter)로 특정 플로우를 원하는 큐에 직접 매핑할 수 있다. Flow Director 규칙은 RSS보다 높은 우선순위를 가진다:

# 특정 목적지 포트의 트래픽을 큐 3으로 스티어링
$ ethtool -N eth0 flow-type tcp4 dst-port 80 action 3

# 특정 5-tuple 매칭
$ ethtool -N eth0 flow-type tcp4 \
    src-ip 10.0.0.1 dst-ip 10.0.0.2 \
    src-port 12345 dst-port 443 action 2

# 현재 규칙 목록
$ ethtool -n eth0
4 RX rings available
Total 2 rules
Filter: 1023
    Rule Type: TCP over IPv4
    Src IP addr: 0.0.0.0 mask: 255.255.255.255
    Dest IP addr: 0.0.0.0 mask: 255.255.255.255
    TOS: 0x0 mask: 0xff
    Src port: 0 mask: 0xffff
    Dest port: 80 mask: 0x0
    Action: Direct to queue 3

# 규칙 삭제
$ ethtool -N eth0 delete 1023

RPS (Receive Packet Steering) — 소프트웨어 RSS

RPS는 커널 소프트웨어에서 수신 패킷을 여러 CPU로 분배하는 메커니즘이다. RSS를 지원하지 않는 NIC나, 큐 수가 CPU 수보다 적은 환경에서 유용하다. NIC의 하드웨어 큐에서 패킷을 받은 CPU가 해시를 계산하고, 그 결과에 따라 다른 CPU의 backlog 큐에 패킷을 넣어 처리를 분산시킨다.

RPS 아키텍처

NIC (single queue) CPU 0 (IRQ 처리) netif_receive_skb() → get_rps_cpu() Hash 계산 skb_get_hash() CPU 1 backlog process_backlog() CPU 2 backlog process_backlog() CPU 3 backlog process_backlog() IPI (Inter-Processor Interrupt) IP → TCP 프로토콜 처리 IP → TCP 프로토콜 처리 IP → TCP 프로토콜 처리

RPS 커널 구현

RPS의 핵심 로직은 net/core/dev.cget_rps_cpu() 함수에 있다. 이 함수는 패킷의 해시 값을 계산하고, rps_map을 참조하여 대상 CPU를 결정한다:

/* net/core/dev.c — RPS CPU 선택 핵심 로직 */
static int get_rps_cpu(struct net_device *dev,
                       struct sk_buff *skb,
                       struct rps_dev_flow **rflowp)
{
    struct netdev_rx_queue *rxqueue;
    struct rps_map *map;
    struct rps_sock_flow_table *sock_flow_table;
    int cpu = -1;
    u32 hash;

    rxqueue = dev->_rx + skb_get_rx_queue(skb);
    map = rcu_dereference(rxqueue->rps_map);
    if (!map)
        return -1;

    /* 패킷의 flow hash 계산 (Toeplitz 기반) */
    hash = skb_get_hash(skb);
    if (!hash)
        return -1;

    /* RFS (sock_flow_table)가 설정된 경우, 소켓을 처리하는 CPU 우선 */
    sock_flow_table = rcu_dereference(rps_sock_flow_table);
    if (sock_flow_table) {
        /* RFS 로직: 소켓의 마지막 처리 CPU를 참조 */
        /* ... (아래 RFS 섹션 참조) */
    }

    /* 해시 기반 CPU 선택: hash를 rps_map의 CPU 배열 인덱스로 변환 */
    cpu = map->cpus[reciprocal_scale(hash, map->len)];

    return cpu;
}

대상 CPU가 결정되면, enqueue_to_backlog()를 통해 해당 CPU의 per-CPU backlog 큐(softnet_data.input_pkt_queue)에 패킷을 삽입하고, IPI(Inter-Processor Interrupt)로 대상 CPU를 깨운다:

/* net/core/dev.c — 대상 CPU의 backlog에 패킷 삽입 */
static int enqueue_to_backlog(struct sk_buff *skb, int cpu,
                              unsigned int *qtail)
{
    struct softnet_data *sd = &per_cpu(softnet_data, cpu);

    rps_lock_irqsave(sd, &flags);
    if (skb_queue_len(&sd->input_pkt_queue) <= netdev_max_backlog) {
        __skb_queue_tail(&sd->input_pkt_queue, skb);
        rps_unlock_irq_restore(sd, &flags);

        /* 대상 CPU에 NET_RX_SOFTIRQ 스케줄링 (IPI 발생) */
        ____napi_schedule(sd, &sd->backlog);
        return NET_RX_SUCCESS;
    }

    /* backlog 큐 초과 → 패킷 드롭 */
    sd->dropped++;
    rps_unlock_irq_restore(sd, &flags);
    kfree_skb(skb);
    return NET_RX_DROP;
}

RPS 핵심 자료구조

/* include/linux/netdevice.h — rps_map: 큐별 대상 CPU 목록 */
struct rps_map {
    unsigned int   len;          /* 활성 CPU 수 */
    struct rcu_head rcu;
    u16            cpus[];      /* CPU 번호 배열 */
};

/* softnet_data: per-CPU 네트워크 처리 구조체 */
struct softnet_data {
    struct list_head     poll_list;       /* NAPI poll 리스트 */
    struct sk_buff_head  input_pkt_queue; /* RPS backlog 큐 */
    struct sk_buff_head  process_queue;   /* 처리 중인 큐 */
    struct napi_struct   backlog;         /* backlog NAPI */
    unsigned int         dropped;         /* 드롭 카운터 */
    /* ... */
};

RPS 설정 방법

# RPS 설정: 특정 RX 큐에서 어떤 CPU로 패킷을 분산할지 결정
# rps_cpus: CPU 비트맵 (16진수)

# 8-core 시스템에서 모든 CPU 활성화
$ echo ff > /sys/class/net/eth0/queues/rx-0/rps_cpus
# ff = 11111111(2) → CPU 0~7 모두 사용

# NUMA 노드 0의 CPU(0~3)만 사용
$ echo f > /sys/class/net/eth0/queues/rx-0/rps_cpus
# f = 00001111(2) → CPU 0~3만

# 32-core 시스템: 모든 CPU
$ echo ffffffff > /sys/class/net/eth0/queues/rx-0/rps_cpus

# backlog 큐 크기 조절 (기본 1000)
$ echo 5000 > /proc/sys/net/core/netdev_budget
$ echo 10000 > /proc/sys/net/core/netdev_max_backlog

# RPS flow hash 엔트리 수 (전역, RFS와 함께 사용)
$ echo 32768 > /proc/sys/net/core/rps_sock_flow_entries

# 큐별 flow 엔트리 수
$ echo 2048 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt

# 현재 설정 확인
$ cat /sys/class/net/eth0/queues/rx-0/rps_cpus
000000ff
$ cat /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
0
RPS 사용 시 주의사항:
  1. RSS가 가능하면 RSS를 먼저 사용 — RPS는 소프트웨어 처리이므로 IRQ 처리 CPU에 추가 부하가 발생한다. NIC가 RSS를 지원하면 하드웨어 분산이 더 효율적이다.
  2. IPI 오버헤드 — 패킷마다 IPI를 발생시키므로 cache line bouncing이 생길 수 있다. 대량 트래픽에서는 RSS 대비 성능이 낮다.
  3. IRQ CPU 제외 — IRQ를 처리하는 CPU를 rps_cpus 비트맵에서 제외하면, 해당 CPU의 부하를 줄이고 다른 CPU로만 분산시킬 수 있다.
  4. NUMA 경계 고려 — 원격 NUMA 노드의 CPU로 패킷을 보내면 메모리 접근 지연이 증가한다. NIC가 연결된 NUMA 노드의 CPU로 제한하는 것이 좋다.

RSS vs RPS 비교

특성RSS (Hardware)RPS (Software)
분산 시점 NIC 하드웨어에서 DMA 전 드라이버의 NAPI poll 후, 프로토콜 스택 진입 전
해시 계산 NIC 하드웨어 (Toeplitz) 커널 소프트웨어 (skb_get_hash)
CPU 오버헤드 없음 (H/W) 해시 계산 + IPI + backlog 큐잉
NIC 요구사항 멀티큐 + RSS 지원 필수 싱글큐 NIC도 가능
동적 재설정 ethtool (드라이버 리셋 가능) sysfs 즉시 반영 (무중단)
캐시 효율 높음 (DMA부터 같은 CPU) 보통 (IRQ CPU → 대상 CPU 이동)
주요 사용 사례 고성능 서버, 10G+ NIC 가상머신 (virtio), 싱글큐 NIC, 큐 < CPU 수

RFS (Receive Flow Steering) — 캐시 친화적 분배

RFS는 RPS를 확장하여 패킷을 해당 소켓을 마지막으로 처리한 CPU로 전달한다. RPS가 해시 기반으로 아무 CPU나 선택하는 것과 달리, RFS는 애플리케이션의 CPU 위치를 추적하여 L1/L2 캐시 히트율을 극대화한다.

RFS 동작 원리

RFS는 두 개의 해시 테이블을 사용한다:

테이블위치내용갱신 시점
rps_sock_flow_table 전역 (per-net) flow hash → 소켓을 마지막으로 처리한 CPU (desired CPU) recvmsg(), sendmsg() 등 소켓 시스템 콜 시
rps_dev_flow_table per-queue flow hash → 패킷이 마지막으로 전달된 CPU (current CPU) get_rps_cpu()에서 패킷 처리 시
/* include/linux/netdevice.h — RFS 자료구조 */
struct rps_sock_flow_table {
    u32   mask;             /* 엔트리 수 - 1 (power of 2) */
    u32   ents[];           /* flow hash → desired CPU */
};

struct rps_dev_flow {
    u16   cpu;              /* 패킷이 마지막으로 전달된 CPU */
    u16   filter;           /* aRFS에서 사용하는 필터 ID */
    unsigned int last_qtail; /* 마지막 삽입 시 큐 tail 위치 */
};

struct rps_dev_flow_table {
    unsigned int          mask;  /* 엔트리 수 - 1 */
    struct rcu_head       rcu;
    struct rps_dev_flow   flows[];
};

RFS CPU 선택 로직

RFS의 CPU 선택은 get_rps_cpu() 내부에서 다음 우선순위로 진행된다:

/* get_rps_cpu() 내부 RFS 로직 (단순화) */

/* 1. 전역 sock_flow_table에서 desired CPU 조회 */
desired_cpu = sock_flow_table->ents[hash & sock_flow_table->mask];

/* 2. per-queue dev_flow_table에서 current CPU 조회 */
rflow = &flow_table->flows[hash & flow_table->mask];
current_cpu = rflow->cpu;

/* 3. CPU 선택 결정 */
if (desired_cpu == current_cpu) {
    /* 동일 CPU → 그대로 사용 (최적) */
    cpu = desired_cpu;
} else if (current_cpu_unset || current_cpu_offline ||
         unlikely(qtail - rflow->last_qtail >= backlog_len)) {
    /* current CPU가 미설정/오프라인/backlog 소진됨
     * → desired CPU로 전환 (out-of-order 방지 후) */
    cpu = desired_cpu;
    rflow->cpu = cpu;
} else {
    /* current CPU의 backlog에 아직 이전 패킷이 있음
     * → 순서 보장을 위해 current CPU 유지 */
    cpu = current_cpu;
}
순서 보장 (Out-of-Order 방지): RFS가 CPU를 변경할 때, 이전 CPU의 backlog에 아직 처리되지 않은 패킷이 있으면 동일 플로우의 패킷 순서가 뒤바뀔 수 있다. 이를 방지하기 위해 last_qtail을 추적하여, 이전 CPU의 backlog가 해당 지점을 넘어서 처리될 때까지 CPU 전환을 지연시킨다.

RFS 설정 방법

# 1. 전역 sock_flow_table 크기 설정 (power of 2 권장)
# 활성 연결 수의 2배 이상으로 설정
$ echo 32768 > /proc/sys/net/core/rps_sock_flow_entries

# 2. 큐별 dev_flow_table 크기 설정
# rps_sock_flow_entries / N (N = RX 큐 수)
$ echo 2048 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
$ echo 2048 > /sys/class/net/eth0/queues/rx-1/rps_flow_cnt
# ... 모든 RX 큐에 대해 반복

# 3. RPS도 함께 활성화해야 동작함 (RFS는 RPS 위에서 동작)
$ echo ff > /sys/class/net/eth0/queues/rx-0/rps_cpus

# 한 번에 모든 큐 설정 (스크립트)
for rxq in /sys/class/net/eth0/queues/rx-*; do
    echo ff > $rxq/rps_cpus
    echo 2048 > $rxq/rps_flow_cnt
done

소켓 측 CPU 갱신

소켓의 desired CPU는 sock_rps_record_flow()를 통해 갱신된다. 이 함수는 recvmsg(), sendmsg(), tcp_v4_rcv() 등 소켓 처리 경로에서 호출된다:

/* include/net/sock.h — 소켓 처리 시 CPU 기록 */
static inline void sock_rps_record_flow(const struct sock *sk)
{
    struct rps_sock_flow_table *table;

    table = rcu_dereference(rps_sock_flow_table);
    if (table) {
        u32 hash = sk->sk_rxhash;
        if (hash) {
            u32 index = hash & table->mask;
            /* 현재 CPU를 desired CPU로 기록 */
            if (table->ents[index] != raw_smp_processor_id())
                table->ents[index] = raw_smp_processor_id();
        }
    }
}

XPS (Transmit Packet Steering) — 송신 측 CPU-큐 매핑

XPS는 송신(TX) 패킷을 보내는 CPU에 최적화된 TX 큐를 선택하는 메커니즘이다. 멀티큐 NIC에서 TX 큐를 CPU에 적절히 매핑하면 lock contention 감소캐시 효율 향상을 얻을 수 있다.

XPS가 해결하는 문제

XPS 없이 멀티큐 NIC에서 패킷을 전송하면, 커널은 skb_tx_hash()를 사용해 해시 기반으로 TX 큐를 선택한다. 이 경우 여러 CPU가 같은 TX 큐를 사용하여 TX 큐 락 경합이 발생할 수 있다:

/* XPS 미설정 시: 해시 기반 TX 큐 선택 */
static u16 skb_tx_hash(const struct net_device *dev,
                       const struct sk_buff *skb)
{
    /* 여러 CPU가 같은 큐를 선택할 수 있음 → lock contention */
    return reciprocal_scale(skb_get_hash(skb),
                           dev->real_num_tx_queues);
}

XPS 동작 원리

XPS를 설정하면, 각 TX 큐에 대해 어떤 CPU가 사용할 수 있는지를 매핑한다. 패킷 전송 시 현재 CPU에 매핑된 TX 큐 중 하나를 선택하여 lock contention을 최소화한다:

/* include/linux/netdevice.h — XPS 매핑 구조체 */
struct xps_map {
    unsigned int   len;          /* 큐 수 */
    unsigned int   alloc_len;
    struct rcu_head rcu;
    u16            queues[];    /* 이 CPU가 사용할 TX 큐 번호 배열 */
};

struct xps_dev_maps {
    struct rcu_head rcu;
    unsigned int   nr_ids;      /* CPU 수 또는 RX 큐 수 */
    s16            num_tc;      /* Traffic Class 수 */
    struct xps_map __rcu *attr_map[];  /* per-CPU 또는 per-RX-queue 매핑 */
};
/* net/core/dev.c — XPS가 활성화된 경우의 TX 큐 선택 */
static int __netdev_pick_tx(struct net_device *dev,
                           struct sk_buff *skb,
                           struct net_device *sb_dev)
{
    struct xps_dev_maps *dev_maps;
    struct xps_map *map;
    int queue_index = -1;

    /* 1. XPS RX-queue 매핑 시도 (수신 큐 → 송신 큐) */
    dev_maps = rcu_dereference(dev->xps_maps[XPS_RXQS]);
    if (dev_maps) {
        map = rcu_dereference(dev_maps->attr_map[skb_get_rx_queue(skb)]);
        if (map)
            queue_index = map->queues[reciprocal_scale(
                skb_get_hash(skb), map->len)];
    }

    /* 2. XPS CPU 매핑 시도 (현재 CPU → 송신 큐) */
    if (queue_index < 0) {
        dev_maps = rcu_dereference(dev->xps_maps[XPS_CPUS]);
        if (dev_maps) {
            map = rcu_dereference(
                dev_maps->attr_map[raw_smp_processor_id()]);
            if (map)
                queue_index = map->queues[
                    reciprocal_scale(skb_get_hash(skb), map->len)];
        }
    }

    /* 3. XPS 미설정 시 fallback: skb_tx_hash() */
    if (queue_index < 0)
        queue_index = skb_tx_hash(dev, skb);

    return queue_index;
}

XPS 두 가지 모드

모드매핑 기준설정 파일사용 사례
XPS (CPU) CPU → TX 큐 /sys/class/net/<dev>/queues/tx-<N>/xps_cpus CPU별 전용 TX 큐 할당으로 lock contention 제거
XPS (RXQ) RX 큐 → TX 큐 /sys/class/net/<dev>/queues/tx-<N>/xps_rxqs 수신-송신 큐 페어링, 같은 CPU에서 처리하여 캐시 효율 극대화

XPS 설정 방법

# === XPS CPU 모드: CPU → TX 큐 1:1 매핑 ===

# TX Queue 0 → CPU 0 전용
$ echo 1 > /sys/class/net/eth0/queues/tx-0/xps_cpus
# 1 = 00000001(2) → CPU 0만

# TX Queue 1 → CPU 1 전용
$ echo 2 > /sys/class/net/eth0/queues/tx-1/xps_cpus
# 2 = 00000010(2) → CPU 1만

# TX Queue 2 → CPU 2 전용
$ echo 4 > /sys/class/net/eth0/queues/tx-2/xps_cpus

# TX Queue 3 → CPU 3 전용
$ echo 8 > /sys/class/net/eth0/queues/tx-3/xps_cpus

# 8-queue NIC에서 CPU 1:1 매핑 스크립트
for i in $(seq 0 7); do
    printf '%x' $((1 << i)) > /sys/class/net/eth0/queues/tx-$i/xps_cpus
done

# NUMA 인식 매핑: NUMA 0 CPU(0~3) → TX Queue 0~3
#                 NUMA 1 CPU(4~7) → TX Queue 4~7
$ echo 0f > /sys/class/net/eth0/queues/tx-0/xps_cpus  # CPU 0~3
$ echo 0f > /sys/class/net/eth0/queues/tx-1/xps_cpus
$ echo 0f > /sys/class/net/eth0/queues/tx-2/xps_cpus
$ echo 0f > /sys/class/net/eth0/queues/tx-3/xps_cpus
$ echo f0 > /sys/class/net/eth0/queues/tx-4/xps_cpus  # CPU 4~7
$ echo f0 > /sys/class/net/eth0/queues/tx-5/xps_cpus
$ echo f0 > /sys/class/net/eth0/queues/tx-6/xps_cpus
$ echo f0 > /sys/class/net/eth0/queues/tx-7/xps_cpus

# === XPS RXQ 모드: RX 큐 → TX 큐 매핑 ===
# 커널 4.18+ 필요, RSS/RPS로 수신한 큐와 동일 TX 큐 사용

# TX Queue 0 → RX Queue 0에서 수신한 패킷의 응답 전송
$ echo 1 > /sys/class/net/eth0/queues/tx-0/xps_rxqs
# TX Queue 1 → RX Queue 1
$ echo 2 > /sys/class/net/eth0/queues/tx-1/xps_rxqs

# 현재 설정 확인
$ cat /sys/class/net/eth0/queues/tx-0/xps_cpus
00000001
$ cat /sys/class/net/eth0/queues/tx-0/xps_rxqs
0
XPS 최적 구성: CPU 1:1 매핑이 가장 효과적이다. 각 CPU가 전용 TX 큐를 가지면 qdisc 락 경합이 완전히 제거된다. CPU 수 > TX 큐 수인 경우, 같은 NUMA 노드의 CPU 그룹을 하나의 TX 큐에 매핑한다. xps_rxqs 모드는 TCP처럼 요청-응답 패턴에서 수신과 송신이 같은 CPU에서 처리되도록 하여 캐시 효율을 극대화한다.

XPS 모니터링

# TX 큐별 전송 통계
$ ethtool -S eth0 | grep tx_queue
     tx_queue_0_packets: 982341
     tx_queue_0_bytes: 587204160
     tx_queue_1_packets: 978892
     tx_queue_1_bytes: 585023408
     tx_queue_2_packets: 981204
     tx_queue_2_bytes: 586921600
     tx_queue_3_packets: 979563
     tx_queue_3_bytes: 585425376

# TX 큐 락 경합 확인 (perf로)
$ perf stat -e 'lock:contention_begin' -a -- sleep 5

# BPF로 TX 큐 선택 과정 추적
$ bpftrace -e 'kretprobe:__netdev_pick_tx {
    printf("cpu=%d txq=%d\n", cpu, retval);
}'

aRFS (Accelerated RFS) — 하드웨어 가속 RFS

aRFS는 RFS의 결정을 NIC 하드웨어에 반영하여, 패킷이 DMA 단계에서부터 올바른 CPU의 RX 큐로 전달되도록 한다. RFS가 소프트웨어로 패킷을 재분배하는 것과 달리, aRFS는 NIC의 ntuple filter (Flow Director)를 동적으로 프로그래밍하여 하드웨어 수준에서 스티어링한다.

aRFS 동작 흐름

Application recvmsg() on CPU 2 RFS 테이블 갱신 desired_cpu = 2 get_rps_cpu() desired(CPU2) != current(CPU0) → ndo_rx_flow_steer() 호출 NIC 드라이버 ndo_rx_flow_steer() ntuple filter 추가/갱신 NIC H/W Flow Director 규칙: flow X → RX Queue 2 (CPU 2에 바인딩된 큐) 이후 패킷 H/W가 직접 CPU 2의 큐로 DMA

aRFS 커널 API

aRFS를 지원하려면 NIC 드라이버가 ndo_rx_flow_steer 콜백을 구현해야 한다:

/* include/linux/netdevice.h — aRFS 드라이버 콜백 */
struct net_device_ops {
    /* ... */
    int (*ndo_rx_flow_steer)(struct net_device *dev,
                             const struct sk_buff *skb,
                             u16 rxq_index,
                             u32 flow_id);
    /* rxq_index: 대상 RX 큐 번호 */
    /* flow_id:   고유 플로우 식별자 */
    /* 반환값:     NIC에 설정된 필터 ID */
};

/* 예: Intel ixgbe 드라이버의 aRFS 구현 */
static int ixgbe_rx_flow_steer(struct net_device *dev,
                               const struct sk_buff *skb,
                               u16 rxq_index, u32 flow_id)
{
    struct ixgbe_adapter *adapter = netdev_priv(dev);
    struct ixgbe_fdir_filter *input;

    /* 패킷의 5-tuple로 Flow Director 필터 생성 */
    input = kzalloc(sizeof(*input), GFP_ATOMIC);
    /* skb에서 src/dst IP, port 추출 → ATR 필터 설정 */
    ixgbe_fdir_write_perfect_filter(adapter, input, rxq_index);

    return filter_id;
}

aRFS 설정

# aRFS 요구사항:
# 1. NIC가 ntuple filter (Flow Director) 지원
# 2. NIC 드라이버가 ndo_rx_flow_steer 구현
# 3. RFS가 활성화되어 있어야 함

# ntuple filter 활성화
$ ethtool -K eth0 ntuple on

# RFS 설정 (aRFS의 전제 조건)
$ echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
for rxq in /sys/class/net/eth0/queues/rx-*; do
    echo 2048 > $rxq/rps_flow_cnt
done

# aRFS 지원 여부 확인
$ ethtool -k eth0 | grep ntuple
ntuple-filters: on

# 현재 aRFS/Flow Director 규칙 수 확인
$ ethtool -S eth0 | grep fdir
     fdir_match: 28745
     fdir_miss: 312
     fdir_overflow: 0
aRFS 지원 NIC: Intel ixgbe (82599, X540), i40e (X710, XL710), ice (E810), Mellanox mlx4/mlx5 (ConnectX-3/4/5/6), Broadcom bnxt (BCM57xxx), Chelsio cxgb4 (T5/T6) 등이 aRFS를 지원한다. 확인 방법: grep ndo_rx_flow_steer drivers/net/ethernet/로 드라이버 소스에서 구현 여부를 검색한다.

ICE aRFS 운영 주의사항

Intel E810(ice) 드라이버의 aRFS는 다음과 같은 제약사항이 있으므로 운영 시 주의가 필요합니다.

항목ICE aRFS 제약
지원 프로토콜TCP/UDP over IPv4 및 IPv6만 지원 (SCTP, ICMP 등 미지원)
단편화 패킷IP 단편화(fragmented) 패킷은 aRFS 스티어링 대상에서 제외
ntuple 충돌ethtool -N으로 추가한 수동 ntuple 규칙과 aRFS 자동 규칙이 충돌 가능 — 같은 플로우에 대해 둘 다 설정하면 예측 불가
필터 수 제한FDIR 테이블 용량에 따라 동시 활성 규칙 수 제한
# ICE aRFS 활성화 (ntuple + RFS 동시 설정)
ethtool -K eth0 ntuple on
echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
for q in /sys/class/net/eth0/queues/rx-*; do
    echo 4096 > "$q/rps_flow_cnt"
done

# FDIR 통계로 aRFS 동작 확인
ethtool -S eth0 | grep fdir
# fdir_match: aRFS 규칙 매칭 횟수
# fdir_miss: 규칙 미매칭 (RSS 폴백)
# fdir_overflow: 테이블 초과 (규칙 수 부족)

멀티코어 네트워크 분산 전체 비교

기법계층방향분배 기준장점단점
RSS H/W RX 해시 → RETA → 큐 CPU 부하 없음, DMA부터 분산 NIC 지원 필요, 정적 매핑
RPS S/W RX 해시 → CPU backlog 어떤 NIC든 사용 가능 IPI 오버헤드, 캐시 비효율
RFS S/W RX 소켓의 CPU 추적 캐시 친화적, 앱-패킷 같은 CPU RPS 위에서만 동작, 테이블 메모리
aRFS H/W+S/W RX RFS 결정을 H/W 필터에 반영 RFS + H/W 가속, 최적 성능 ntuple 지원 NIC 필요, 필터 수 제한
XPS S/W TX CPU → TX 큐 매핑 TX 락 경합 제거, 설정 간단 송신 전용, 멀티큐 NIC 필요
Flow Dir. H/W RX 관리자가 수동 규칙 설정 정밀 제어, RSS보다 높은 우선순위. ICE는 Flex Byte(user-def) 지원 수동 관리, 규칙 수 제한
권장 구성 가이드:
  • 고성능 서버 (RSS NIC): RSS + IRQ affinity + XPS (CPU 1:1) + aRFS
  • 가상머신 (virtio): RPS + RFS (virtio는 RSS 미지원이 많으므로)
  • 큐 수 < CPU 수: RSS + RPS (S/W로 추가 분산) + RFS
  • 단순 구성: RSS + XPS만으로도 대부분의 경우 충분

RSS (Receive Side Scaling) 구현 상세

RSS는 NIC 하드웨어가 수신 패킷의 헤더 필드를 해싱하여 여러 RX 큐에 분배하는 기술이다. 위 개요에서 Toeplitz 해시와 RETA를 설명했으므로, 이 섹션에서는 NIC 벤더별 RSS 구현 차이, 해시 키 선택 전략, 그리고 하드웨어 큐 매핑의 실전 튜닝을 다룬다.

하드웨어 큐와 MSI-X 인터럽트 매핑

RSS의 핵심은 RX 큐 → MSI-X 인터럽트 벡터 → CPU 매핑이다. NIC 드라이버는 초기화 시 큐 수만큼 MSI-X 벡터를 할당하고, 각 벡터를 특정 CPU에 바인딩한다. 이 매핑이 올바르지 않으면 RSS의 분산 효과가 제대로 발휘되지 않는다.

/* 드라이버 초기화 시 MSI-X 벡터 할당 (일반적 패턴) */
/*
 * 1. pci_alloc_irq_vectors() → num_queues개 MSI-X 벡터 할당
 * 2. request_irq(vector[i], handler, 0, "eth0-TxRx-i", q[i])
 * 3. irq_set_affinity_hint(irq, cpu_mask)
 *    → 각 큐의 인터럽트를 특정 CPU에 affinity 설정
 */

/* 큐 수 결정 기준:
 * - 기본값: min(NIC 최대 큐 수, online CPU 수)
 * - ethtool -L eth0 combined N  으로 런타임 변경 가능
 * - NUMA 토폴로지 고려: NIC가 연결된 NUMA 노드의 CPU 수 이하 권장 */

/* 큐-CPU affinity 확인 */
/* # cat /proc/interrupts | grep eth0
 *  128:  152384    0    0    0  IR-PCI-MSI eth0-TxRx-0   → CPU 0
 *  129:       0  151829    0    0  IR-PCI-MSI eth0-TxRx-1   → CPU 1
 *  130:       0    0  152105    0  IR-PCI-MSI eth0-TxRx-2   → CPU 2
 *  131:       0    0    0  151983  IR-PCI-MSI eth0-TxRx-3   → CPU 3
 */

NIC 벤더별 RSS 지원

NIC / 드라이버최대 큐 수RETA 크기해시 함수특이 사항
Intel E810 (ice) 256 2048 Toeplitz, Symmetric Toeplitz, XOR ADQ(Application Device Queues)로 앱별 전용 큐 할당 가능. VLAN/tunneling 내부 해싱 지원
Intel X710 (i40e) 64 (PF) 512 Toeplitz, XOR PCTYPEs로 프로토콜별 세밀한 해시 필드 제어. ATR(Application Targeted Routing) 내장
Mellanox ConnectX-5/6 (mlx5) 256 최대 4096 Toeplitz, XOR TIR(Transport Interface Receive) 기반 유연한 분배. Inner header 해싱(VXLAN, GRE) 기본 지원
Broadcom BCM57xxx (bnxt) 128 128~512 Toeplitz RSS context 다중 지원으로 VF별 독립 RSS 설정 가능
Chelsio T6 (cxgb4) 128 2048 Toeplitz, CRC32 하드웨어 GRO/TOE 통합. RSS + 필터 우선순위 계층 구조

RSS 키 선택 전략

RSS 해시 키의 품질은 트래픽 분산 균등성에 직접 영향을 미친다. 잘못된 키는 특정 큐로 트래픽이 편중되는 해시 충돌(hash collision)을 일으킬 수 있다.

키 유형설명사용 사례
Microsoft 기본 키 Microsoft RSS 스펙에 정의된 40바이트 키. 대부분의 NIC 드라이버가 기본값으로 사용 범용. 특별한 요구사항이 없을 때
대칭 키 (Symmetric) (A->B)(B->A) 트래픽이 동일 해시를 생성하는 키 conntrack, 양방향 플로우 모니터링, IDS/IPS
랜덤 키 /dev/urandom에서 생성한 키. 공격자가 의도적으로 해시 충돌을 유도하기 어려움 보안이 중요한 환경, DDoS 방어
# 대칭 키 생성 및 적용 예시
# (src_ip XOR dst_ip, src_port XOR dst_port가 동일 해시를 만들도록 설계)
$ python3 -c "
import os
key = os.urandom(40)
# 대칭 속성을 위해 key[i] == key[i+20] 조건 적용
sym_key = key[:20] + key[:20]
print(':'.join(f'{b:02x}' for b in sym_key))
" | xargs -I{} ethtool -X eth0 hkey {}

# Intel NIC에서 symmetric-xor 해시 함수 사용 (키 변경 불필요)
$ ethtool -X eth0 hfunc symmetric-xor

# 랜덤 키 적용
$ KEY=$(python3 -c "import os; print(':'.join(f'{b:02x}' for b in os.urandom(40)))")
$ ethtool -X eth0 hkey "$KEY"

ethtool RSS 관리 명령 종합

# === RSS 상태 조회 ===

# RETA 테이블 + 해시 키 + 해시 함수 조회
$ ethtool -x eth0

# 큐 수 조회/변경
$ ethtool -l eth0             # 현재 및 최대 큐 수
$ ethtool -L eth0 combined 8  # combined 큐 8개로 변경

# 프로토콜별 해시 필드 조회
$ ethtool -n eth0 rx-flow-hash tcp4
$ ethtool -n eth0 rx-flow-hash udp4

# === RSS 설정 변경 ===

# RETA 균등 분배 (4큐)
$ ethtool -X eth0 equal 4

# RETA 가중치 분배 (큐 0에 50%, 나머지 균등)
$ ethtool -X eth0 weight 3 1 1 1

# 해시 함수 변경
$ ethtool -X eth0 hfunc toeplitz   # 또는 xor, crc32

# 해시 필드 변경
$ ethtool -N eth0 rx-flow-hash tcp4 sdfn  # 4-tuple
$ ethtool -N eth0 rx-flow-hash udp4 sd    # 2-tuple (단편화 방지)

# === 큐별 통계 확인 ===
$ ethtool -S eth0 | grep rx_queue
$ ethtool -S eth0 | grep tx_queue
Intel ice ADQ (Application Device Queues): ice 드라이버는 RSS를 넘어 tc(Traffic Control) 기반으로 애플리케이션별 전용 큐 세트를 할당할 수 있다. 예를 들어 웹 서버 트래픽(port 443)과 DB 트래픽(port 3306)을 별도의 큐 세트로 분리하여 상호 간섭 없이 처리할 수 있다: tc qdisc add dev eth0 root mqprio num_tc 2 map 0 0 0 0 1 1 1 1 queues 4@0 4@4 hw 1 mode channeltc filter add dev eth0 protocol ip parent 1: flower dst_port 443 hw_tc 1로 설정한다.

RPS (Receive Packet Steering) 구현 상세

RPS는 위 개요에서 설명한 것처럼 소프트웨어 기반 수신 분산이다. 이 섹션에서는 softirq 컨텍스트에서의 해시 계산, per-CPU backlog 큐 삽입 과정, 그리고 운영 환경에서의 최적 설정을 상세히 다룬다.

softirq 컨텍스트의 소프트웨어 해시

RPS에서 패킷 해시는 NIC가 제공한 하드웨어 해시를 우선 사용하고, 없으면 소프트웨어로 계산한다. 소프트웨어 해시는 __skb_get_hash()에서 Flow Dissector를 통해 패킷 헤더를 파싱하고, Toeplitz 또는 jhash를 사용하여 32비트 해시를 생성한다.

/* net/core/flow_dissector.c — RPS 소프트웨어 해시 계산 */

/* skb_get_hash() 호출 경로:
 *   netif_receive_skb()
 *     → __netif_receive_skb()
 *       → get_rps_cpu()
 *         → skb_get_hash(skb)
 *           → __skb_get_hash(skb)
 *             → ___skb_get_hash(skb, &keys, &flow_keys_dissector_symmetric)
 *               → __flow_hash_from_keys(&keys, &hashrnd)
 */

void __skb_get_hash(struct sk_buff *skb)
{
    struct flow_keys keys;
    u32 hash;

    /* NIC가 이미 해시를 계산했으면 그것을 사용 */
    if (skb->l4_hash || skb->sw_hash)
        return;

    /* Flow Dissector로 L3/L4 헤더 파싱 */
    if (!skb_flow_dissect_flow_keys(skb, &keys, 0))
        return;

    /* jhash2로 해시 계산 (소프트웨어 Toeplitz 대신 jhash 사용) */
    hash = __flow_hash_from_keys(&keys, &hashrnd);
    __skb_set_sw_hash(skb, hash, flow_keys_have_l4(&keys));
}

enqueue_to_backlog 상세

get_rps_cpu()가 대상 CPU를 결정하면, 패킷은 enqueue_to_backlog()을 통해 해당 CPU의 softnet_data.input_pkt_queue에 삽입된다. 이 과정에서 발생하는 동기화와 오버플로 처리를 살펴보자.

/* net/core/dev.c — enqueue_to_backlog 상세 분석 */
static int enqueue_to_backlog(struct sk_buff *skb, int cpu,
                              unsigned int *qtail)
{
    struct softnet_data *sd;
    unsigned long flags;

    sd = &per_cpu(softnet_data, cpu);

    /* per-CPU backlog 큐에 대한 스핀락 획득
     * (IRQ 비활성화 — 하드 인터럽트 안전) */
    rps_lock_irqsave(sd, &flags);

    /* 큐 길이 검사: netdev_max_backlog 초과 여부 */
    if (skb_queue_len(&sd->input_pkt_queue) <= netdev_max_backlog) {
        /* 큐에 여유가 있으면 삽입 */
        if (skb_queue_len(&sd->input_pkt_queue)) {
            /* 큐가 비어있지 않으면 — 이미 softirq 스케줄됨 */
            goto enqueue;
        }
        /* 큐가 비어있으면 — softirq 스케줄 필요 */
        if (!__test_and_set_bit(NAPI_STATE_SCHED,
                                &sd->backlog.state)) {
            /* backlog NAPI를 poll_list에 추가하고
             * NET_RX_SOFTIRQ를 raise (IPI 발생) */
            ____napi_schedule(sd, &sd->backlog);
        }
        goto enqueue;
    }

    /* === 큐 오버플로 ===
     * netdev_max_backlog 초과 → 패킷 드롭
     * /proc/net/softnet_stat의 두 번째 컬럼(dropped)이 증가 */
    sd->dropped++;
    rps_unlock_irq_restore(sd, &flags);
    kfree_skb_reason(skb, SKB_DROP_REASON_CPU_BACKLOG);
    return NET_RX_DROP;

enqueue:
    __skb_queue_tail(&sd->input_pkt_queue, skb);
    /* RFS용 qtail 기록 (순서 보장에 사용) */
    if (qtail)
        *qtail = sd->input_queue_head +
                 skb_queue_len(&sd->input_pkt_queue);
    rps_unlock_irq_restore(sd, &flags);
    return NET_RX_SUCCESS;
}

RPS sysfs 설정 상세

# === /sys/class/net//queues/rx-N/rps_cpus ===
# CPU 비트맵 (16진수). 각 비트가 하나의 CPU를 나타냄

# 예: 16코어 시스템, NUMA 노드 0 = CPU 0~7, NUMA 노드 1 = CPU 8~15
# NIC가 NUMA 0에 연결된 경우:

# 방법 1: IRQ CPU를 제외한 같은 NUMA 노드 CPU만 사용
# (큐 0의 IRQ가 CPU 0에 고정이면, CPU 1~7만 사용)
$ echo 00fe > /sys/class/net/eth0/queues/rx-0/rps_cpus
# 00fe = 11111110(2) → CPU 1~7

# 방법 2: 모든 CPU 사용 (NUMA 경계 무시 — 비추천)
$ echo ffff > /sys/class/net/eth0/queues/rx-0/rps_cpus

# === /proc/sys/net/core/netdev_max_backlog ===
# per-CPU backlog 큐의 최대 패킷 수 (기본: 1000)
$ echo 10000 > /proc/sys/net/core/netdev_max_backlog

# 오버플로 확인: softnet_stat의 두 번째 컬럼
$ cat /proc/net/softnet_stat
# 형식: processed  dropped  time_squeeze  ...  (16진수, per-CPU 행)
# 00a1b2c3 00000000 00000005 ...   ← CPU 0: 0 dropped
# 009f8d21 0000002a 00000003 ...   ← CPU 1: 42 dropped → 문제!
RPS 성능 트레이드오프: RPS는 IPI(Inter-Processor Interrupt) 비용이 핵심 병목이다. 패킷당 IPI 발생은 아니지만(backlog에 이미 패킷이 있으면 IPI 생략), 새 burst 시작마다 IPI가 발생한다. 10Gbps 이상 환경에서 RPS만으로는 RSS 대비 20~30% 낮은 처리량을 보인다. RPS는 RSS 미지원 NIC(virtio, 가상환경)이나 큐 수가 CPU 수보다 적은 환경에서 사용하고, RSS가 가능하면 RSS를 우선 사용하라.

RFS (Receive Flow Steering) 구현 상세

RFS는 단순한 해시 기반 분배를 넘어 애플리케이션 locality를 활용한다. 패킷을 해당 소켓을 마지막으로 처리한 CPU로 전달하여 L1/L2/L3 캐시 히트율을 극대화한다.

이중 테이블 메커니즘

RFS가 두 개의 테이블을 사용하는 이유는 패킷 순서 보장(out-of-order prevention) 때문이다. 단일 테이블로 CPU를 즉시 변경하면, 이전 CPU의 backlog에 아직 처리되지 않은 패킷이 있을 때 동일 플로우의 패킷 순서가 뒤바뀔 수 있다.

RFS 이중 테이블 동작 흐름 Application recvmsg() on CPU 3 → sock_rps_record_flow() rps_sock_flow_table (전역, per-net) ents[hash] = desired_cpu (3) 갱신: recvmsg/sendmsg/tcp_v4_rcv 패킷 수신 NIC IRQ → CPU 0 get_rps_cpu() 1. sock_flow → desired = CPU 3 2. dev_flow → current = CPU 1 3. backlog 소진 여부 확인 rps_dev_flow_table (per-queue) flows[hash].cpu = current_cpu flows[hash].last_qtail = N backlog 소진? Yes CPU 전환 → desired CPU 3으로 전달 No CPU 유지 → current CPU 1 계속 사용 last_qtail 추적으로 이전 CPU backlog 미처리 패킷이 있으면 CPU 전환을 지연 → 순서 보장

rps_may_expire_flow — 플로우 만료

RFS 테이블의 엔트리는 무한히 유지되지 않는다. rps_may_expire_flow()는 플로우가 만료되었는지 판단하여, 오래된 플로우 엔트리가 새 플로우에 의해 재사용될 수 있도록 한다.

/* net/core/dev.c — RFS 플로우 만료 판단 */
static bool rps_may_expire_flow(struct net_device *dev,
                               u16 rxq_index,
                               u32 flow_id, u16 filter_id)
{
    struct netdev_rx_queue *rxqueue;
    struct rps_dev_flow_table *flow_table;
    struct rps_dev_flow *rflow;
    bool expire = true;

    rxqueue = dev->_rx + rxq_index;
    flow_table = rcu_dereference(rxqueue->rps_flow_table);
    if (flow_table && flow_id <= flow_table->mask) {
        rflow = &flow_table->flows[flow_id];
        /* 필터 ID가 일치하고 CPU가 온라인이면 만료하지 않음 */
        if (rflow->filter == filter_id &&
            cpu_online(rflow->cpu))
            expire = false;
    }
    return expire;
}

/* aRFS가 이 함수를 사용하여 NIC의 ntuple 필터를
 * 정리할지 결정한다. 만료된 플로우의 하드웨어 필터는
 * 삭제되어 FDIR 테이블 공간을 확보한다. */

RFS 설정 상세 가이드

# === RFS 전역 설정 ===

# rps_sock_flow_entries: 전역 sock_flow_table 크기
# 권장: 예상 동시 활성 연결 수의 2배 (power of 2)
# 예: 16K 동시 연결 → 32768
$ echo 32768 > /proc/sys/net/core/rps_sock_flow_entries

# === RFS per-queue 설정 ===

# rps_flow_cnt: per-queue dev_flow_table 크기
# 권장: rps_sock_flow_entries / RX 큐 수
# 예: 32768 / 8 큐 = 4096
NUM_QUEUES=$(ls -d /sys/class/net/eth0/queues/rx-* | wc -l)
FLOW_CNT=$((32768 / NUM_QUEUES))
for rxq in /sys/class/net/eth0/queues/rx-*; do
    echo $FLOW_CNT > "$rxq/rps_flow_cnt"
done

# === RPS도 반드시 활성화해야 RFS가 동작 ===
for rxq in /sys/class/net/eth0/queues/rx-*; do
    echo ff > "$rxq/rps_cpus"
done

# === 설정 검증 ===
$ cat /proc/sys/net/core/rps_sock_flow_entries
32768
$ cat /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
4096
$ cat /sys/class/net/eth0/queues/rx-0/rps_cpus
000000ff
RFS 메모리 비용: rps_sock_flow_table은 엔트리당 4바이트(u32), rps_dev_flow_table은 엔트리당 8바이트(rps_dev_flow 구조체)를 사용한다. 32768 엔트리의 sock_flow_table은 약 128KB, 8큐 x 4096 엔트리의 dev_flow_table은 약 256KB로, 메모리 비용은 매우 적다.

XPS (Transmit Packet Steering) 구현 상세

위 개요에서 XPS의 기본 개념과 설정을 다루었다. 이 섹션에서는 NUMA 노드 기반 설정, TX 큐 선택의 내부 로직, 그리고 XPS가 qdisc 락 경합에 미치는 실질적 영향을 분석한다.

NUMA 노드 기반 XPS 설정

NUMA 시스템에서 XPS의 핵심은 NIC가 연결된 PCIe 슬롯의 NUMA 노드TX 큐를 사용하는 CPU의 NUMA 노드를 일치시키는 것이다. NUMA 노드가 일치하지 않으면 DMA 전송 시 원격 메모리 접근이 발생하여 레이턴시가 증가한다.

# NIC의 NUMA 노드 확인
$ cat /sys/class/net/eth0/device/numa_node
0

# NUMA 노드별 CPU 목록 확인
$ lscpu | grep NUMA
NUMA node0 CPU(s):  0-7
NUMA node1 CPU(s):  8-15

# NUMA 인지 XPS 설정 스크립트
# NIC가 NUMA 0에 연결 → CPU 0~7만 사용
NIC_NUMA=$(cat /sys/class/net/eth0/device/numa_node)
CPUS=$(lscpu -p=CPU,NODE | grep ",$NIC_NUMA" | cut -d, -f1)

# 각 CPU에 전용 TX 큐 매핑
i=0
for cpu in $CPUS; do
    TXQ="/sys/class/net/eth0/queues/tx-$i/xps_cpus"
    if [ -f "$TXQ" ]; then
        printf '%x' $((1 << cpu)) > "$TXQ"
    fi
    i=$((i + 1))
done

# 설정 결과 확인
for txq in /sys/class/net/eth0/queues/tx-*/xps_cpus; do
    echo "$txq: $(cat $txq)"
done

XPS와 TX 큐 락 경합

멀티큐 NIC에서 각 TX 큐는 qdisc 락으로 보호된다. XPS 없이 여러 CPU가 같은 TX 큐를 사용하면 spinlock 경합이 발생한다. XPS로 CPU-큐를 1:1 매핑하면 이 경합이 완전히 제거된다.

/* net/sched/sch_generic.c — qdisc 전송 시 락 경합 */
static inline int __dev_xmit_skb(struct sk_buff *skb,
                                  struct Qdisc *q,
                                  struct net_device *dev,
                                  struct netdev_queue *txq)
{
    /* 이 spinlock이 XPS 미설정 시 경합 지점!
     * 여러 CPU가 같은 txq를 사용하면
     * spin_lock(&q->busylock)에서 대기 시간 발생 */
    spin_lock(root_lock);

    /* qdisc에 skb 삽입 */
    rc = q->enqueue(skb, q, &to_free);
    if (rc == NET_XMIT_SUCCESS) {
        /* 직접 전송 시도 */
        qdisc_run(q);
    }

    spin_unlock(root_lock);
    return rc;
}

/* XPS로 CPU 1:1 매핑 시:
 *   CPU 0 → TX Queue 0 (전용) → 락 경합 없음
 *   CPU 1 → TX Queue 1 (전용) → 락 경합 없음
 *   ...
 * 결과: spin_lock 대기 시간 ≈ 0 */
# XPS 전후 TX 락 경합 측정

# 방법 1: perf lock contention 분석
$ perf lock record -a -- sleep 10
$ perf lock report
# qdisc_lock 또는 root_lock의 contention이 줄어드는지 확인

# 방법 2: bpftrace로 TX 큐 선택 모니터링
$ bpftrace -e '
kretprobe:__netdev_pick_tx {
    @txq[cpu] = lhist(retval, 0, 16, 1);
}
END { print(@txq); }
' -- sleep 5
# 각 CPU가 고유한 TX 큐를 선택하는지 확인

aRFS (Accelerated RFS) 구현 상세

aRFS는 RFS의 소프트웨어 결정을 NIC 하드웨어에 반영하여, 패킷이 DMA 단계에서부터 올바른 CPU의 RX 큐로 전달되도록 한다. 이 섹션에서는 ndo_rx_flow_steer 콜백의 동작, ntuple 필터와의 관계, 그리고 ethtool을 통한 설정을 다룬다.

ndo_rx_flow_steer 콜백 동작

커널의 get_rps_cpu()가 desired CPU와 current CPU의 불일치를 감지하면, NIC 드라이버의 ndo_rx_flow_steer 콜백을 호출하여 하드웨어 필터를 설정한다.

/* net/core/dev.c — aRFS 트리거 로직 (get_rps_cpu 내부) */

/* desired_cpu != current_cpu이고 CPU 전환이 결정된 경우: */
if (rflow->filter != rflow_to_cpu_id) {
    /* NIC에 flow steering 규칙 설정 요청 */
    rflow->filter = dev->netdev_ops->ndo_rx_flow_steer(
        dev, skb,
        rxq_index,    /* desired CPU에 바인딩된 RX 큐 */
        flow_id       /* 해시 기반 플로우 ID */
    );
    /* 반환값: NIC에 설정된 필터 ID
     * → rflow->filter에 저장하여 나중에 만료 시 삭제에 사용 */
}

/* 드라이버 구현 예시: mlx5 (Mellanox ConnectX) */
static int mlx5e_rx_flow_steer(struct net_device *dev,
                               const struct sk_buff *skb,
                               u16 rxq_index, u32 flow_id)
{
    struct mlx5e_priv *priv = netdev_priv(dev);
    struct mlx5e_arfs_tables *arfs = &priv->fs->arfs;
    struct arfs_rule *arfs_rule;

    /* 패킷의 5-tuple 추출 */
    /* 기존 규칙 검색 → 없으면 새 규칙 생성 */
    arfs_rule = arfs_find_rule(arfs, skb);
    if (!arfs_rule) {
        arfs_rule = arfs_alloc_rule(priv, skb, rxq_index, flow_id);
        /* workqueue를 통해 비동기적으로 H/W 규칙 설정
         * (softirq 컨텍스트에서 직접 H/W 프로그래밍 불가) */
        queue_work(priv->wq, &arfs_rule->arfs_work);
    }

    return arfs_rule->filter_id;
}

ntuple 필터와 aRFS의 관계

항목수동 ntuple (ethtool -N)aRFS (자동)
규칙 생성 관리자가 수동으로 설정 커널이 RFS 결정에 따라 자동 생성/삭제
우선순위 높음 (RSS보다 우선) 높음 (RSS보다 우선, 수동 ntuple과 동급)
만료/삭제 수동 삭제 필요 rps_may_expire_flow()에 의해 자동 만료
필터 테이블 NIC FDIR/Flow Table 공유 NIC FDIR/Flow Table 공유
충돌 가능성 - 같은 플로우에 수동 + aRFS 규칙이 공존하면 예측 불가
# === aRFS 활성화 전제 조건 ===

# 1. NIC ntuple 지원 확인
$ ethtool -k eth0 | grep ntuple
ntuple-filters: on  # off이면 -K로 활성화

# 2. ntuple 활성화
$ ethtool -K eth0 ntuple on

# 3. rx-flow-hash 설정 (해시 필드 확인)
$ ethtool -n eth0 rx-flow-hash tcp4
# sdfn (4-tuple) 권장

# 4. RFS 설정 (aRFS의 전제 조건)
$ echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
for rxq in /sys/class/net/eth0/queues/rx-*; do
    echo 4096 > "$rxq/rps_flow_cnt"
done

# === aRFS 동작 확인 ===

# FDIR 통계로 확인
$ ethtool -S eth0 | grep fdir
     fdir_match: 28745      # aRFS 규칙 매칭 성공
     fdir_miss: 312          # 미매칭 → RSS 폴백
     fdir_overflow: 0        # FDIR 테이블 초과 (0이어야 정상)

# 현재 활성 ntuple 규칙 목록 (수동 + aRFS 포함)
$ ethtool -n eth0
aRFS 규칙 수 제한: NIC의 FDIR/Flow Table 크기는 유한하다. Intel ixgbe는 약 8K 규칙, ice(E810)는 최대 16K 규칙을 지원한다. 동시 활성 플로우가 이 한도를 초과하면 fdir_overflow 카운터가 증가하고, 초과 플로우는 RSS 폴백으로 처리된다. ethtool -S eth0 | grep fdir_overflow를 모니터링하여 테이블 고갈을 감지하라.

Flow Dissector — 패킷 파싱 엔진

Flow Dissector는 패킷 헤더를 파싱하여 flow_keys 구조체로 추출하는 커널의 범용 패킷 파싱 엔진이다. RPS/RFS의 해시 계산, TC(Traffic Control)의 패킷 분류, Netfilter의 conntrack 등 다양한 서브시스템에서 사용된다.

skb_flow_dissect와 flow_keys

/* include/net/flow_dissector.h — flow_keys 구조체 */
struct flow_keys {
    struct flow_dissector_key_control control;
    struct flow_dissector_key_basic   basic;
    /* L3 */
    union {
        struct flow_dissector_key_ipv4_addrs ipv4;
        struct flow_dissector_key_ipv6_addrs ipv6;
    } addrs;
    /* L4 */
    struct flow_dissector_key_ports    ports;
    struct flow_dissector_key_tags     tags;
    struct flow_dissector_key_vlan     vlan;
    struct flow_dissector_key_keyid    keyid;
    /* ... 기타 키 타입 (MPLS, GRE, 암호화 등) */
};

/* flow_dissector_key_basic — 프로토콜 정보 */
struct flow_dissector_key_basic {
    __be16 n_proto;    /* L3 프로토콜 (ETH_P_IP 등) */
    u8     ip_proto;   /* L4 프로토콜 (IPPROTO_TCP 등) */
};

/* 핵심 파싱 함수 */
bool __skb_flow_dissect(
    const struct net *net,
    const struct sk_buff *skb,
    struct flow_dissector *flow_dissector,
    void *target_container,     /* flow_keys를 채울 대상 */
    const void *data,
    __be16 proto,
    int nhoff, int hlen,
    unsigned int flags);

/* 파싱 파이프라인:
 *   1. Ethernet 헤더 → n_proto (VLAN 태그 스킵)
 *   2. IP 헤더 → src/dst IP, ip_proto
 *      (터널링: VXLAN/GRE/IPIP → 내부 헤더 재귀 파싱)
 *   3. TCP/UDP/SCTP 헤더 → src/dst port
 *   4. 결과를 flow_keys에 저장 → 해시 계산에 사용
 */

BPF Flow Dissector

커널 5.3부터 BPF 프로그램으로 Flow Dissector를 교체할 수 있다. 커스텀 프로토콜의 해싱이나, 독자적인 터널 프로토콜의 inner header 파싱이 필요할 때 유용하다.

/* BPF Flow Dissector 프로그램 예시 (libbpf) */
SEC("flow_dissector")
int custom_flow_dissector(struct __sk_buff *skb)
{
    struct bpf_flow_keys *keys =
        (struct bpf_flow_keys *)skb->flow_keys;

    /* 커스텀 프로토콜 파싱 로직 */
    void *data = (void *)(long)skb->data;
    void *data_end = (void *)(long)skb->data_end;

    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end)
        return BPF_OK;

    keys->n_proto = eth->h_proto;
    keys->nhoff = sizeof(*eth);

    /* IP 헤더 파싱 */
    if (keys->n_proto == bpf_htons(ETH_P_IP)) {
        struct iphdr *iph = data + keys->nhoff;
        if ((void *)(iph + 1) > data_end)
            return BPF_OK;

        keys->ipv4_src = iph->saddr;
        keys->ipv4_dst = iph->daddr;
        keys->ip_proto = iph->protocol;
        keys->thoff = keys->nhoff + (iph->ihl * 4);
    }

    return BPF_OK;
}
# BPF Flow Dissector 로드 (네트워크 네임스페이스에 연결)
$ bpftool prog load flow_dissector.bpf.o /sys/fs/bpf/flow_dissector
$ bpftool net attach flow_dissector \
    pinned /sys/fs/bpf/flow_dissector

# 확인
$ bpftool net list
flow_dissector:
  netns(id:1) flow_dissector id 42

# 해제
$ bpftool net detach flow_dissector
Flow Dissector 사용처: Flow Dissector는 RPS/RFS 해시뿐 아니라 tc flower 필터, nft flow 테이블, cls_bpf, act_ct (conntrack), skb_get_hash() 전체에서 사용된다. BPF Flow Dissector를 교체하면 이 모든 서브시스템의 패킷 파싱 동작이 함께 변경된다.

per-CPU backlog 큐 — softnet_data 심화

리눅스 네트워크 스택의 수신 경로에서 softnet_data 구조체는 CPU마다 하나씩 존재하는 네트워크 처리 허브이다. RPS가 패킷을 다른 CPU로 전달할 때 사용하는 backlog 큐, NAPI poll 리스트, 그리고 각종 통계 카운터가 이 구조체에 포함된다.

softnet_data 구조체 상세

/* include/linux/netdevice.h — per-CPU 네트워크 처리 구조체 */
struct softnet_data {
    /* === NAPI poll 관련 === */
    struct list_head     poll_list;
    /* NET_RX_SOFTIRQ에서 처리할 NAPI 인스턴스 리스트
     * 드라이버의 NAPI + backlog NAPI가 여기에 등록됨 */

    /* === RPS backlog 큐 === */
    struct sk_buff_head  input_pkt_queue;
    /* 다른 CPU에서 RPS/enqueue_to_backlog()을 통해
     * 이 CPU로 전달된 패킷이 대기하는 큐
     * 최대 크기: /proc/sys/net/core/netdev_max_backlog */

    struct sk_buff_head  process_queue;
    /* process_backlog() NAPI poll에서 실제 처리하는 큐
     * input_pkt_queue에서 splice로 옮겨온 패킷들 */

    struct napi_struct   backlog;
    /* backlog 처리용 가상 NAPI 인스턴스
     * poll 함수: process_backlog()
     * input_pkt_queue의 패킷을 프로토콜 스택으로 전달 */

    /* === 통계 카운터 === */
    unsigned int         processed;
    /* 이 CPU에서 처리한 총 패킷 수 */

    unsigned int         dropped;
    /* backlog 오버플로로 드롭된 패킷 수
     * /proc/net/softnet_stat 두 번째 컬럼 */

    unsigned int         time_squeeze;
    /* softirq 시간/budget 제한으로 처리 못한 횟수
     * /proc/net/softnet_stat 세 번째 컬럼 */

    unsigned int         received_rps;
    /* RPS를 통해 이 CPU로 전달된 패킷 수 */

    /* ... 기타 필드 */
};

process_backlog NAPI poll 함수

/* net/core/dev.c — backlog NAPI poll 함수 */
static int process_backlog(struct napi_struct *napi, int quota)
{
    struct softnet_data *sd = container_of(napi,
        struct softnet_data, backlog);
    int work = 0;

    /* input_pkt_queue → process_queue로 일괄 이동 (splice)
     * splice 중에만 spinlock 필요 → 락 보유 시간 최소화 */
    rps_lock_irqsave(sd, &flags);
    skb_queue_splice_tail_init(&sd->input_pkt_queue,
                               &sd->process_queue);
    rps_unlock_irq_restore(sd, &flags);

    /* process_queue에서 패킷을 하나씩 꺼내 프로토콜 스택에 전달 */
    while (work < quota) {
        struct sk_buff *skb = __skb_dequeue(&sd->process_queue);
        if (!skb) {
            /* 처리할 패킷이 없으면 input_pkt_queue 재확인 */
            rps_lock_irqsave(sd, &flags);
            if (skb_queue_empty(&sd->input_pkt_queue)) {
                __napi_complete(napi);
                rps_unlock_irq_restore(sd, &flags);
                break;
            }
            skb_queue_splice_tail_init(
                &sd->input_pkt_queue,
                &sd->process_queue);
            rps_unlock_irq_restore(sd, &flags);
            skb = __skb_dequeue(&sd->process_queue);
        }

        /* 프로토콜 스택으로 전달 */
        __netif_receive_skb(skb);
        work++;
    }

    return work;
}

backlog 큐 오버플로 처리와 모니터링

# === /proc/net/softnet_stat 해독 ===
# 각 행은 하나의 CPU에 대응 (CPU 0부터 순서대로)
# 컬럼 (16진수): processed  dropped  time_squeeze  ...  received_rps  flow_limit_count

$ cat /proc/net/softnet_stat
00a1b2c3 00000000 00000005 00000000 00000000 00000000 00000000 00000000 00000000 001a2b3c 00000000
009f8d21 0000002a 00000003 00000000 00000000 00000000 00000000 00000000 00000000 00198abc 00000000
# CPU 0: dropped=0, time_squeeze=5 → 정상
# CPU 1: dropped=42 → backlog 오버플로 42회 발생!

# === 오버플로 대응 ===

# 1. backlog 큐 크기 증가 (기본: 1000)
$ echo 10000 > /proc/sys/net/core/netdev_max_backlog

# 2. RPS CPU 맵 조정으로 부하 재분배
# (특정 CPU에 과부하 → 다른 CPU 추가)

# 3. time_squeeze가 높으면 → softirq budget 증가
$ echo 600 > /proc/sys/net/core/netdev_budget
$ echo 4000 > /proc/sys/net/core/netdev_budget_usecs

# === 실시간 모니터링 스크립트 ===
$ watch -d -n 1 'cat /proc/net/softnet_stat | \
  awk "{printf \"CPU%d: processed=%d dropped=%d squeeze=%d rps_recv=%d\\n\", \
  NR-1, strtonum(\"0x\"$1), strtonum(\"0x\"$2), strtonum(\"0x\"$3), strtonum(\"0x\"$10)}"'

netif_receive_skb 경로와 backlog 큐 진입

/* net/core/dev.c — 패킷 수신의 분기점 */

/* netif_receive_skb() 호출 경로:
 *
 * NAPI poll()
 *   └→ napi_gro_receive() 또는 netif_receive_skb()
 *       └→ netif_receive_skb_internal()
 *           ├→ RPS 미설정: __netif_receive_skb() (현재 CPU에서 직접 처리)
 *           └→ RPS 설정:
 *               ├→ get_rps_cpu() → 대상 CPU 결정
 *               ├→ 대상 == 현재 CPU: __netif_receive_skb()
 *               └→ 대상 != 현재 CPU: enqueue_to_backlog()
 *                   └→ 대상 CPU의 softnet_data.input_pkt_queue에 삽입
 *                       └→ ____napi_schedule() → IPI → NET_RX_SOFTIRQ
 *                           └→ process_backlog() → __netif_receive_skb()
 */

/* netif_receive_skb_internal 핵심 분기 */
static int netif_receive_skb_internal(struct sk_buff *skb)
{
    int cpu = get_rps_cpu(skb->dev, skb, &rflow);

    if (cpu >= 0 && cpu != smp_processor_id()) {
        /* 다른 CPU로 전달 */
        return enqueue_to_backlog(skb, cpu, &rflow->last_qtail);
    }

    /* 현재 CPU에서 직접 처리 */
    return __netif_receive_skb(skb);
}

멀티코어 네트워크 성능 튜닝 실전

지금까지 다룬 RSS, RPS, RFS, XPS, aRFS를 실전 환경에서 조합하여 최적 성능을 달성하는 방법을 설명한다. NUMA 인지 IRQ affinity, busy polling, GRO/GSO 최적화, softirq 부하 분석, 그리고 perf/bpftrace를 활용한 병목 진단을 다룬다.

NUMA 인지 IRQ Affinity 설정

IRQ affinity는 네트워크 성능의 가장 기본적인 튜닝 포인트이다. NIC가 연결된 PCIe 슬롯의 NUMA 노드와 일치하는 CPU에 IRQ를 고정하면 캐시 미스와 원격 메모리 접근을 최소화할 수 있다.

# === irqbalance vs 수동 설정 ===

# irqbalance: 자동 IRQ 분산 데몬
# 장점: 설정 간편, 동적 재조정
# 단점: NUMA 인지가 불완전할 수 있음, 네트워크 집약 환경에서 부적합
$ systemctl status irqbalance

# 고성능 환경에서는 irqbalance 비활성화 + 수동 설정 권장
$ systemctl stop irqbalance
$ systemctl disable irqbalance

# === NIC NUMA 노드 확인 ===
$ cat /sys/class/net/eth0/device/numa_node
0

# NUMA 노드 0의 CPU 목록
$ cat /sys/devices/system/node/node0/cpulist
0-7

# === IRQ Affinity 수동 설정 ===
# 각 큐의 IRQ를 같은 NUMA 노드의 CPU에 1:1 매핑

# IRQ 번호 확인
$ grep eth0 /proc/interrupts | awk '{print $1, $NF}'
128: eth0-TxRx-0
129: eth0-TxRx-1
130: eth0-TxRx-2
131: eth0-TxRx-3
132: eth0-TxRx-4
133: eth0-TxRx-5
134: eth0-TxRx-6
135: eth0-TxRx-7

# IRQ를 NUMA 0의 CPU에 1:1 매핑
$ echo 1   > /proc/irq/128/smp_affinity   # CPU 0
$ echo 2   > /proc/irq/129/smp_affinity   # CPU 1
$ echo 4   > /proc/irq/130/smp_affinity   # CPU 2
$ echo 8   > /proc/irq/131/smp_affinity   # CPU 3
$ echo 10  > /proc/irq/132/smp_affinity   # CPU 4
$ echo 20  > /proc/irq/133/smp_affinity   # CPU 5
$ echo 40  > /proc/irq/134/smp_affinity   # CPU 6
$ echo 80  > /proc/irq/135/smp_affinity   # CPU 7

# 설정 확인
$ for irq in 128 129 130 131 132 133 134 135; do
    echo "IRQ $irq: $(cat /proc/irq/$irq/smp_affinity)"
done

# === Intel ice 드라이버 자동 IRQ affinity ===
# ice 드라이버는 irq_set_affinity_hint()로 NUMA 인지 힌트를 자동 설정
# /proc/irq/N/affinity_hint를 참조하여 설정할 수 있음
$ cat /proc/irq/128/affinity_hint

Busy Polling (SO_BUSY_POLL) 튜닝

Busy polling은 소켓 읽기 시 softirq를 기다리지 않고 직접 NIC의 NAPI poll을 호출하여 레이턴시를 줄이는 기법이다. CPU 사용률이 증가하지만, 지연 민감 워크로드(HFT, 실시간 서비스)에서 효과적이다.

# === 전역 busy polling 설정 ===

# busy_poll: poll()/select()/epoll_wait() 시 busy-poll 시간 (μs)
$ echo 50 > /proc/sys/net/core/busy_poll
# 50μs 동안 NAPI를 직접 폴링 → softirq 대기 없이 패킷 수신

# busy_read: 소켓 recvmsg() 시 busy-poll 시간 (μs)
$ echo 50 > /proc/sys/net/core/busy_read

# === 소켓별 설정 (SO_BUSY_POLL) ===
# C 코드에서:
# int val = 50;  // 50μs
# setsockopt(fd, SOL_SOCKET, SO_BUSY_POLL, &val, sizeof(val));

# === NAPI defer + busy polling 조합 (커널 5.11+) ===
# IRQ를 지연시키고 busy polling으로 패킷을 가져오는 하이브리드 모드
$ echo 1 > /sys/class/net/eth0/napi_defer_hard_irqs
$ echo 200000 > /sys/class/net/eth0/gro_flush_timeout
# → IRQ 발생을 200μs 지연, 그 사이에 busy polling으로 수신
# → 인터럽트 수 대폭 감소 + 레이턴시 개선

# === 효과 확인 ===
# busy polling으로 처리한 패킷 수
$ cat /proc/net/softnet_stat | awk '{print "CPU"NR-1": busy_poll="strtonum("0x"$11)}'
Busy Polling 주의사항:
  • CPU 사용률 증가 — busy polling 중에는 CPU가 100% 점유된다. CPU 코어가 충분하지 않으면 다른 워크로드에 영향을 줄 수 있다.
  • NIC 지원 필요 — NIC 드라이버가 napi_busy_loop을 지원해야 한다. 대부분의 현대 드라이버(ixgbe, i40e, ice, mlx5)가 지원한다.
  • epoll 호환epoll_wait()에서도 busy polling이 작동하지만, 많은 수의 소켓을 모니터링하면 각 소켓의 NAPI를 순회하여 오버헤드가 증가한다.

GRO/GSO 설정과 성능 영향

# === GRO/GSO 상태 확인 ===
$ ethtool -k eth0 | grep -E "(gro|gso|tso)"
generic-receive-offload: on
generic-segmentation-offload: on
tcp-segmentation-offload: on
rx-gro-hw: off         # HW-GRO (커널 5.19+)
rx-gro-list: off        # GRO list mode

# === GRO 튜닝 ===

# GRO 활성화 (기본: on)
$ ethtool -K eth0 gro on

# HW-GRO 활성화 (NIC 지원 시)
$ ethtool -K eth0 rx-gro-hw on

# GRO flush timeout (커널 4.6+)
# 기본 0 = poll 종료 시 즉시 flush
# 값을 늘리면 더 큰 super-packet 생성 → 처리량↑, 지연↑
$ echo 20000 > /sys/class/net/eth0/gro_flush_timeout
# 20μs 동안 GRO 패킷 병합 대기

# === GSO/TSO 튜닝 ===

# TSO (TCP Segmentation Offload) — NIC H/W에 세그멘테이션 위임
$ ethtool -K eth0 tso on

# GSO (Generic Segmentation Offload) — S/W 세그멘테이션
$ ethtool -K eth0 gso on

# === 효과 측정 ===
# GRO 통계
$ ethtool -S eth0 | grep gro
# 처리량 비교: GRO on vs off
$ iperf3 -c  -t 30 -P 4   # 멀티스레드 처리량 측정

코어별 softirq 부하 분석

# === /proc/softirqs — softirq 유형별 CPU별 카운터 ===
$ cat /proc/softirqs | grep NET
                    CPU0       CPU1       CPU2       CPU3
      NET_TX:       1523       1487       1501       1498
      NET_RX:    1523847    1518293    1521056    1519834
# NET_RX가 균등하게 분배되는지 확인
# 특정 CPU에 집중 → RSS/RPS 재설정 필요

# === /proc/net/softnet_stat 분석 ===
$ cat /proc/net/softnet_stat
# 주요 확인 포인트:
# - 컬럼 2 (dropped) > 0  → netdev_max_backlog 증가 필요
# - 컬럼 3 (time_squeeze) > 0  → netdev_budget 증가 필요
# - 특정 CPU의 컬럼 1 (processed)가 비정상 높음 → IRQ affinity 재조정

# === mpstat으로 softirq CPU 사용률 확인 ===
$ mpstat -P ALL 1 5 | grep -E "CPU|all"
# %soft 컬럼: softirq에 사용된 CPU 비율
# %soft > 50% → 해당 CPU가 네트워크 처리로 과부하

# === sar로 네트워크 인터럽트 추이 확인 ===
$ sar -I ALL 1 5
# IRQ별 초당 발생 횟수 확인

perf/bpftrace 활용 네트워크 병목 분석

# === perf: 네트워크 스택 핫스팟 프로파일링 ===

# CPU별 네트워크 함수 프로파일
$ perf record -g -a -C 0-7 -- sleep 10
$ perf report --sort=symbol | head -30
# netif_receive_skb, __netif_receive_skb_core,
# tcp_v4_rcv 등이 상위에 위치하면 네트워크 바운드

# 패킷 드롭 위치 추적
$ perf record -e skb:kfree_skb -a -- sleep 30
$ perf script | head -20
# kfree_skb가 호출된 스택 트레이스로 드롭 원인 파악

# === bpftrace: 실시간 네트워크 분석 ===

# RPS CPU 선택 분포 확인
$ bpftrace -e '
kretprobe:get_rps_cpu {
    @rps_cpu = lhist(retval, -1, 16, 1);
}
interval:s:5 { print(@rps_cpu); clear(@rps_cpu); }
'

# NAPI poll 시간 측정 (μs)
$ bpftrace -e '
kprobe:napi_poll { @start[tid] = nsecs; }
kretprobe:napi_poll /@start[tid]/ {
    @poll_us = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
}
'

# backlog 큐 길이 히스토그램
$ bpftrace -e '
kprobe:enqueue_to_backlog {
    $sd = (struct softnet_data *)arg1;
    @backlog_len = lhist($sd->input_pkt_queue.qlen, 0, 10000, 100);
}
'

# per-flow 처리 레이턴시 (IP → 소켓 전달까지)
$ bpftrace -e '
kprobe:ip_rcv { @ip_start[arg0] = nsecs; }
kprobe:tcp_queue_rcv /@ip_start[arg0]/ {
    @latency_us = hist((nsecs - @ip_start[arg0]) / 1000);
    delete(@ip_start[arg0]);
}
'

환경별 튜닝 레시피

환경RSSRPS/RFSXPSaRFSBusy Poll기타
고처리량 웹 서버 큐 = NUMA CPU 수 RFS on CPU 1:1 on (NIC 지원 시) off GRO on, netdev_budget=600, conntrack NOTRACK(port 80/443)
저지연 금융 (HFT) 전용 큐 isolate off (RPS 불필요) CPU 1:1 on 50~100μs napi_defer_hard_irqs=1, isolcpus, nohz_full
가상머신 (virtio) 지원 시 on RPS+RFS on CPU 1:1 미지원 off netdev_max_backlog=10000, vhost-net 사용
네트워크 라우터/방화벽 큐 = CPU 수 off CPU 1:1 off off conntrack 최적화 또는 비활성화, GRO on, ip_forward sysctl
컨테이너 호스트 큐 = CPU 수 RFS on NUMA 그룹 on off tc mqprio, cgroup net_cls, conntrack_max 증가
종합 튜닝 체크리스트:
  1. NUMA 확인: cat /sys/class/net/<dev>/device/numa_node로 NIC NUMA 노드 확인
  2. 큐 수 설정: ethtool -L <dev> combined N (N = NUMA 노드의 CPU 수)
  3. IRQ affinity: irqbalance 중지 후 수동 설정 (큐 IRQ → 같은 NUMA CPU)
  4. RSS 키/RETA: ethtool -X <dev> equal N (균등) 또는 weight (가중치)
  5. XPS: xps_cpus로 CPU 1:1 매핑
  6. RFS + aRFS: rps_sock_flow_entries, rps_flow_cnt, ntuple on
  7. backlog: netdev_max_backlog, netdev_budget 조정
  8. 검증: ethtool -S, /proc/net/softnet_stat, mpstat으로 확인

네트워크 패킷 흐름 (Packet Flow) 심화

전체 네트워크 스택 RX/TX 경로

Linux 네트워크 스택은 NIC 하드웨어부터 유저스페이스 애플리케이션까지 여러 계층을 통과합니다. 다음 다이어그램은 패킷이 수신(RX)되고 송신(TX)되는 전체 경로를 보여줍니다.

네트워크 스택 개요 NIC/NAPI L2/L3/L4 Netfilter/라우팅 Socket/Qdisc RX: NIC → 소켓 전달 TX: 소켓 → qdisc/NIC 전송 아래 상세 다이어그램에서 훅/함수 단위를 확인
큰 상세도에 들어가기 전, 패킷이 통과하는 핵심 계층만 먼저 파악하는 개요판입니다.
Linux 네트워크 스택 전체 플로우 ← 수신 경로 (RX) 1. NIC 하드웨어 2. DMA → Ring Buffer 3. 인터럽트/NAPI Poll 4. netif_receive_skb() 5. L2/L3/L4 프로토콜 스택 (Ethernet → IP → TCP/UDP) Netfilter: PREROUTING 라우팅 결정 local Netfilter: INPUT 6. 소켓 수신 큐 7. 유저 프로세스 송신 경로 (TX) → 1. 유저 프로세스 2. 소켓 송신 (send/write) 3. L4/L3 프로토콜 처리 (TCP/UDP → IP → Ethernet) Netfilter: OUTPUT 라우팅 선택 Netfilter: POSTROUTING 4. TC/Qdisc (QoS) 5. 디바이스 드라이버 6. DMA → TX Ring Buffer 7. NIC 하드웨어 송신 핵심 구조체 struct sk_buff struct net_device struct sock struct dst_entry XDP/eBPF Hook 드라이버 레벨 (RX 최초 처리)
Linux 네트워크 스택 전체 플로우: 수신(RX)과 송신(TX) 경로

수신 경로 (RX Path) 상세

/* 1. NIC 하드웨어가 패킷 수신 → DMA로 메모리에 복사 */
/* 2. 인터럽트 발생 → NAPI softirq로 처리 전환 */

/* net/core/dev.c — 수신 처리 핵심 */
static int __netif_receive_skb_core(struct sk_buff **pskb, bool pfmemalloc,
                                   struct packet_type **ppt_prev)
{
    struct sk_buff *skb = *pskb;
    struct net_device *orig_dev = skb->dev;

    /* 3. XDP 프로그램 실행 (드라이버 레벨) */
    /* 4. Generic XDP (드라이버가 native XDP 미지원 시) */
    if (static_branch_unlikely(&generic_xdp_needed_key)) {
        int ret2 = do_xdp_generic(rcu_dereference(skb->dev->xdp_prog), skb);
        if (ret2 != XDP_PASS) return NET_RX_DROP;
    }

    /* 5. 프로토콜 핸들러 실행 (eth_type_trans → ip_rcv → tcp_v4_rcv) */
    list_for_each_entry_rcu(ptype, &ptype_all, list) {
        if (ptype->dev && ptype->dev != skb->dev)
            continue;
        deliver_skb(skb, ptype, orig_dev);
    }

    return NET_RX_SUCCESS;
}

/* net/ipv4/ip_input.c — IP 계층 수신 */
int ip_rcv(struct sk_buff *skb, struct net_device *dev,
         struct packet_type *pt, struct net_device *orig_dev)
{
    /* IP 헤더 검증 (체크섬, 버전, 길이) */
    if (!pskb_may_pull(skb, sizeof(struct iphdr)))
        goto inhdr_error;

    /* Netfilter PREROUTING 훅 실행 → 라우팅 결정 */
    return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING,
                    net(dev), NULL, skb, dev, NULL,
                    ip_rcv_finish);
}

/* net/ipv4/tcp_ipv4.c — TCP 계층 수신 */
int tcp_v4_rcv(struct sk_buff *skb)
{
    struct sock *sk = __inet_lookup_skb(&tcp_hashinfo, skb, ...);

    if (sk->sk_state == TCP_LISTEN)
        return tcp_v4_do_rcv(sk, skb);  /* SYN 처리 */

    /* 소켓 수신 큐에 추가 → 유저 프로세스가 read/recv */
    tcp_queue_rcv(sk, skb, &fragstolen);
    return 0;
}

송신 경로 (TX Path) 상세

/* 유저 프로세스: write() → sys_sendto() → sock->ops->sendmsg() */

/* net/ipv4/tcp.c — TCP 송신 시작 */
int tcp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size)
{
    /* sk_buff 할당 및 데이터 복사 */
    skb = tcp_write_queue_tail(sk);

    /* TCP 헤더 구성 (시퀀스 번호, ACK, 윈도우 크기 등) */
    tcp_push(sk, flags, mss_now, TCP_NAGLE_OFF);

    return copied;
}

/* net/ipv4/ip_output.c — IP 계층 송신 */
int ip_queue_xmit(struct sock *sk, struct sk_buff *skb, struct flowi *fl)
{
    /* 라우팅 테이블 검색 → 출력 인터페이스 결정 */
    rt = (struct rtable *)__sk_dst_check(sk, 0);

    /* IP 헤더 구성 (TTL, 체크섬, src/dst IP) */
    ip_copy_addrs(iph, fl4);
    iph->ttl = ip_select_ttl(inet, dst);

    /* Netfilter OUTPUT 훅 실행 */
    return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_OUT,
                    net, sk, skb, NULL, rt->dst.dev,
                    ip_output);
}

/* net/core/dev.c — 디바이스 큐 송신 */
static int __dev_queue_xmit(struct sk_buff *skb, struct net_device *sb_dev)
{
    struct netdev_queue *txq = netdev_pick_tx(dev, skb, sb_dev);
    struct Qdisc *q = rcu_dereference_bh(txq->qdisc);

    /* TC/Qdisc 통과 (트래픽 셰이핑, 우선순위 큐 등) */
    if (q->enqueue) {
        rc = __dev_xmit_skb(skb, q, dev, txq);
    }

    /* 드라이버의 ndo_start_xmit() 호출 → DMA → NIC */
    return dev_hard_start_xmit(skb, dev, txq, &rc);
}
💡
성능 최적화 지점:
  • RX 경로: XDP/eBPF를 사용하여 드라이버 레벨에서 조기 필터링 (DDoS 방어, 로드밸런싱)
  • NAPI: 인터럽트 병합으로 CPU 사용률 감소 (ethtool -C eth0 rx-usecs 50)
  • TX 경로: TSO/GSO로 대용량 패킷을 NIC에 오프로드
  • Qdisc: 고성능 환경에서는 pfifo_fast 대신 fq_codel 또는 noqueue 사용

Netfilter 체인과 패킷 경로

NIC (RX) PREROUTING 라우팅 INPUT Local Process FORWARD POSTROUTING OUTPUT NIC (TX) local forward output routing DROP ✕ DROP ✕ DROP ✕ DROP ✕ DROP ✕

패킷 경로별 상세 흐름

경로Netfilter 훅 순서설명주요 처리
LOCAL_IN PREROUTING → INPUT 외부 → 로컬 프로세스 DNAT(PREROUTING), conntrack, 방화벽(INPUT), 소켓 전달
FORWARD PREROUTING → FORWARD → POSTROUTING 외부 → 라우팅 → 외부 DNAT, 포워딩 정책, SNAT(POSTROUTING), TTL 감소
LOCAL_OUT OUTPUT → POSTROUTING 로컬 프로세스 → 외부 출력 필터링(OUTPUT), SNAT/MASQUERADE(POSTROUTING)
DROP 어느 훅에서든 패킷 폐기 NF_DROP 반환, kfree_skb(), 드롭 카운터 증가

Connection Tracking (conntrack) 심화

/* conntrack은 PREROUTING/OUTPUT 훅에서 패킷의 연결 상태를 추적 */
/* 모든 netfilter 기반 NAT, stateful 방화벽의 기초 */

/* conntrack 엔트리 상태 */
IP_CT_NEW           /* 첫 번째 패킷 (SYN) */
IP_CT_ESTABLISHED   /* 양방향 트래픽 확인됨 */
IP_CT_RELATED       /* 기존 연결과 관련 (FTP data, ICMP error) */
IP_CT_INVALID       /* 상태 추적 실패 */

/* conntrack 해시 테이블 크기 — 성능에 직접 영향 */
/* /proc/sys/net/netfilter/nf_conntrack_max = 262144 */
/* /proc/sys/net/netfilter/nf_conntrack_buckets (readonly) */
/* 최적: max = buckets × 4 (체인 길이 ~4 유지) */
conntrack 테이블 포화 문제: 고트래픽 환경에서 conntrack 테이블이 가득 차면 nf_conntrack: table full, dropping packet 에러와 함께 패킷이 무작위로 드롭됩니다.
  • nf_conntrack_max 증가 (메모리 비용: 엔트리당 ~300바이트)
  • 타임아웃 조정: nf_conntrack_tcp_timeout_established (기본 432000초 = 5일)
  • conntrack 불필요한 트래픽은 NOTRACK (raw 테이블)으로 바이패스
  • 초고성능 라우터에서는 conntrack 자체를 비활성화 고려

패킷 드롭 디버깅

# 드롭 모니터 — 패킷이 어디서 드롭되는지 추적
perf record -e skb:kfree_skb -a sleep 10
perf script

# dropwatch 도구 활용
dropwatch -l kas
> start
# 출력: drop at: tcp_v4_rcv+0x1a (sobjects hit: 15)

# nftables/iptables 카운터로 규칙별 드롭 확인
iptables -L -v -n | grep DROP
nft list ruleset | grep drop

# 인터페이스 통계로 드롭 위치 파악
ethtool -S eth0 | grep -i drop
cat /proc/net/softnet_stat  # 컬럼: processed, dropped, time_squeeze

# netstat 프로토콜별 에러 통계
netstat -s | grep -i -E "drop|error|overflow|pruned"

# BPF 기반 패킷 추적 (bcc/bpftrace)
bpftrace -e 'tracepoint:skb:kfree_skb { @[kstack] = count(); }'

패킷 흐름 설계 시 고려사항

고성능 패킷 처리 설계 포인트:
  • PREROUTING에서 조기 드롭 — 불필요한 패킷은 가능한 일찍 드롭하여 후속 처리 비용 절감
  • conntrack 바이패스 — 상태 추적 불필요한 트래픽(DNS 캐시, CDN)은 raw 테이블에서 NOTRACK
  • FORWARD 최적화 — IP forwarding 시 bridge vs routing 성능 차이 고려. nf_conntrack 비활성화 검토
  • LOCAL_OUT 경로 — 로컬 소켓의 출력 경로도 netfilter를 거침. 컨테이너 환경에서 iptables 규칙 수 관리 중요
  • XDP 조기 처리 — netfilter보다 앞단(드라이버 레벨)에서 XDP로 패킷 필터링/리다이렉트 가능
  • nftables 선호 — iptables 대비 nftables는 단일 패스 처리로 체인이 많을 때 성능 우위

네트워크 디버깅 체크리스트

네트워크 스택 주요 버그 사례

리눅스 커널 네트워크 스택은 수십만 줄의 코드와 수백 개의 프로토콜 구현으로 구성된 복잡한 서브시스템입니다. 이 섹션에서는 실제 발생한 주요 버그와 취약점 사례를 분석하여 커널 네트워크 개발 시 주의해야 할 패턴을 살펴봅니다.

AF_PACKET 경쟁 조건 (CVE-2016-8655)

AF_PACKET 소켓의 TPACKET_V3 링 버퍼 설정 과정에서 타이머 핸들러와 소켓 종료 사이의 경쟁 조건(race condition)이 발견되었습니다. packet_set_ring() 함수에서 링 버퍼를 해제하는 동안 타이머 콜백이 이미 해제된 메모리에 접근하여 use-after-free가 발생했습니다.

⚠️

CVE-2016-8655 (CVSS 7.8): 로컬 비권한 사용자가 AF_PACKET 소켓의 TPACKET_V3 타이머 경쟁 조건을 악용하여 use-after-free를 트리거하고, 커널 코드 실행을 통해 root 권한 상승이 가능합니다. Linux 4.8.14 이전 커널이 영향을 받습니다.

/* net/packet/af_packet.c — 취약한 코드 (단순화) */

/* Thread A: packet_set_ring() — 링 버퍼 해제 */
static int packet_set_ring(struct sock *sk, ...)
{
    /* 문제: 타이머가 아직 실행 중일 수 있음 */
    if (closing) {
        kfree(rb->prb_bdqc);      /* 메모리 해제 */
        rb->prb_bdqc = NULL;
    }
    return 0;
}

/* Thread B: 타이머 콜백 — 해제된 메모리 접근! */
static void prb_retire_rx_blk_timer_expired(struct timer_list *t)
{
    /* pkc가 이미 kfree()된 메모리를 가리킴 → use-after-free! */
    prb_retire_current_block(pkc);  /* BOOM */
}

/* 수정된 코드: del_timer_sync()로 타이머 완전 취소 후 해제 */
static int packet_set_ring(struct sock *sk, ...)
{
    mutex_lock(&fanout_mutex);
    if (closing && po->tp_version == TPACKET_V3) {
        del_timer_sync(&rb->prb_bdqc->retire_blk_timer);
        kfree(rb->prb_bdqc);
        rb->prb_bdqc = NULL;
    }
    mutex_unlock(&fanout_mutex);
    return 0;
}
💡

교훈: 소켓 종료 경로에서 타이머, 워크큐, tasklet 등 비동기 핸들러가 모두 완료되었는지 반드시 확인해야 합니다. del_timer_sync(), cancel_work_sync() 등의 동기식 취소 함수를 사용하세요. 단순 del_timer()는 이미 실행 중인 콜백을 기다리지 않으므로 race condition에 취약합니다.

SYN Flood는 TCP 3-way handshake의 설계를 악용한 대표적인 DoS 공격입니다. 공격자가 위조된 소스 IP로 대량의 SYN 패킷을 전송하면 서버의 SYN 큐가 가득 차 정상적인 연결 요청도 거부됩니다.

SYN Cookie는 서버가 SYN 큐에 상태를 저장하지 않고도 정상적인 연결을 수립할 수 있게 해주는 방어 메커니즘입니다. SYN-ACK의 ISN(Initial Sequence Number)에 연결 정보를 암호학적으로 인코딩합니다:

/* net/ipv4/syncookies.c — SYN Cookie ISN 구성:
 *
 *  31      27 26  24 23                0
 * ┌──────────┬──────┬───────────────────┐
 * │ t(5bits) │ MSS  │    hash(24bits)   │
 * │(타이머)  │(3bit)│(HMAC 기반 검증값) │
 * └──────────┴──────┴───────────────────┘
 * hash = SHA-1(saddr, daddr, sport, dport, t, secret)
 */

static __u32 cookie_v4_init_sequence(
    const struct sk_buff *skb, __u16 *mssp)
{
    /* 클라이언트 MSS를 사전 정의된 테이블에서 가장 가까운 값으로 매핑 */
    for (mssind = ARRAY_SIZE(msstab) - 1; mssind; mssind--)
        if (mss >= msstab[mssind])
            break;
    return secure_tcp_syn_cookie(
        iph->saddr, iph->daddr,
        th->source, th->dest,
        ntohl(th->seq), mssind);
}
# SYN Cookie 활성화 (기본값: 1)
sysctl -w net.ipv4.tcp_syncookies=1

# SYN 큐 크기 증가
sysctl -w net.ipv4.tcp_max_syn_backlog=65536

# SYN-ACK 재전송 횟수 감소 (빠른 타임아웃)
sysctl -w net.ipv4.tcp_synack_retries=2

# 확인
cat /proc/net/netstat | grep "TCPReqQFullDoCookies"

Netfilter nf_conntrack 테이블 고갈

Netfilter의 연결 추적 모듈인 nf_conntrack은 NAT, stateful 방화벽의 핵심입니다. conntrack 테이블이 가득 차면 새로운 패킷이 드롭되어 심각한 서비스 장애가 발생합니다:

# 현재 conntrack 사용량 확인
cat /proc/sys/net/netfilter/nf_conntrack_count   # 현재 추적 중인 연결 수
cat /proc/sys/net/netfilter/nf_conntrack_max     # 최대 허용 연결 수

# 최대 연결 수 증가
sysctl -w net.netfilter.nf_conntrack_max=1048576

# 타임아웃 감소로 만료된 연결 빠르게 제거
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=3600
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30

# 특정 트래픽의 conntrack 제외 (NOTRACK)
iptables -t raw -A PREROUTING -p tcp --dport 80 -j NOTRACK
iptables -t raw -A OUTPUT -p tcp --sport 80 -j NOTRACK

TCP SACK Panic (CVE-2019-11477)

2019년 Netflix 보안팀이 발견한 취약점입니다. TCP SACK 처리 과정에서 정수 오버플로우가 발생하여, 원격 공격자가 특수 조작된 SACK 시퀀스를 전송하는 것만으로 커널 패닉을 유발할 수 있었습니다.

⚠️

CVE-2019-11477 — SACK Panic (CVSS 7.5): SACK 블록 처리로 SKB가 과도하게 분할되면 tcp_gso_segs(17-bit 필드)에서 정수 오버플로우가 발생하여 BUG_ON()이 트리거되어 커널 패닉이 일어납니다. Linux 2.6.29 이후 모든 커널이 영향을 받습니다.

# 임시 완화: SACK 비활성화 (성능 저하 주의)
sysctl -w net.ipv4.tcp_sack=0

# 대안: 비정상적으로 작은 MSS 차단
iptables -A INPUT -p tcp -m tcpmss --mss 1:500 -j DROP

# 영구 수정: 커널 업데이트
# Linux 4.4.182, 4.9.182, 4.14.127, 4.19.52, 5.1.11 이후 수정됨
uname -r   # 현재 커널 버전 확인
💡

TCP 스택 보안 점검: (1) 커널을 최신 보안 패치 버전으로 유지합니다. (2) SACK을 비활성화하기보다 커널 업데이트로 대응합니다. (3) iptables/nftables에서 비정상 MSS 값을 차단합니다. (4) /proc/net/netstat의 비정상 카운터를 모니터링합니다.

네트워크 스택과 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.