네트워크 패킷 흐름 & 디버깅
Linux 네트워크 스택 고급 주제를 다룹니다. 멀티코어 NIC 환경에서 RSS/RPS/RFS/XPS/aRFS가 실제로 트래픽을 어떻게 분산하는지, GRO/GSO가 CPU 사용률과 레이턴시에 미치는 영향, conntrack과 Netfilter 경로의 오버헤드, 패킷 드롭·큐 적체·reorder 원인을 추적하는 방법, NUMA/IRQ affinity/busy-poll 기반 튜닝 전략까지 실전 중심으로 설명합니다.
핵심 요약
- 핵심 객체 — 이 문서의 중심이 되는 자료구조/API를 먼저 파악합니다.
- 실행 경로 — 요청이 들어와 처리되고 종료되는 흐름을 확인합니다.
- 병목 지점 — 지연이나 처리량 저하가 발생하는 구간을 점검합니다.
- 동기화 지점 — 경합과 경쟁 조건이 생길 수 있는 구간을 구분합니다.
- 운영 포인트 — 관측 지표와 튜닝 항목을 함께 확인합니다.
단계별 이해
- 구성요소 확인
핵심 자료구조와 주요 API를 먼저 식별합니다. - 요청 흐름 추적
입력부터 완료까지의 호출 경로를 순서대로 따라갑니다. - 예외 경로 점검
실패 처리, 재시도, 타임아웃 등 경계 조건을 확인합니다. - 성능/안정성 점검
잠금 경합, 큐 적체, 병목 지점을 측정하고 조정합니다.
NAPI 심화 — 성능 튜닝과 주의사항
NAPI 상태 전이와 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 통계 확인 */
NAPI 드라이버 구현 주의사항
- budget 미준수 — poll 함수가 budget 이상 처리하면 안 됨. 정확히 budget만큼 처리했으면 budget 반환, 적게 처리하면 실제 수를 반환
- napi_complete_done 누락 — work_done < budget일 때 반드시 호출해야 다음 IRQ에서 재스케줄 가능
- IRQ 재활성화 순서 —
napi_complete_done()이후에 HW 인터럽트를 재활성화해야 함. 순서가 반대면 race condition - 멀티큐 미고려 — RSS/멀티큐 NIC에서는 큐마다 별도의 NAPI 인스턴스 필요. CPU affinity 설정 중요
- 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) |
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
(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의 각 엔트리는 실제 수신 큐 번호를 가리킨다.
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 트래픽 수신 안 함
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 아키텍처
RPS 커널 구현
RPS의 핵심 로직은 net/core/dev.c의 get_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
- RSS가 가능하면 RSS를 먼저 사용 — RPS는 소프트웨어 처리이므로 IRQ 처리 CPU에 추가 부하가 발생한다. NIC가 RSS를 지원하면 하드웨어 분산이 더 효율적이다.
- IPI 오버헤드 — 패킷마다 IPI를 발생시키므로 cache line bouncing이 생길 수 있다. 대량 트래픽에서는 RSS 대비 성능이 낮다.
- IRQ CPU 제외 — IRQ를 처리하는 CPU를 rps_cpus 비트맵에서 제외하면, 해당 CPU의 부하를 줄이고 다른 CPU로만 분산시킬 수 있다.
- 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;
}
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
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 동작 흐름
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
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
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 channel 후 tc 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 → 문제!
RFS (Receive Flow Steering) 구현 상세
RFS는 단순한 해시 기반 분배를 넘어 애플리케이션 locality를 활용한다. 패킷을 해당 소켓을 마지막으로 처리한 CPU로 전달하여 L1/L2/L3 캐시 히트율을 극대화한다.
이중 테이블 메커니즘
RFS가 두 개의 테이블을 사용하는 이유는 패킷 순서 보장(out-of-order prevention) 때문이다. 단일 테이블로 CPU를 즉시 변경하면, 이전 CPU의 backlog에 아직 처리되지 않은 패킷이 있을 때 동일 플로우의 패킷 순서가 뒤바뀔 수 있다.
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
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
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
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)}'
- 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]);
}
'
환경별 튜닝 레시피
| 환경 | RSS | RPS/RFS | XPS | aRFS | Busy 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 증가 |
- NUMA 확인:
cat /sys/class/net/<dev>/device/numa_node로 NIC NUMA 노드 확인 - 큐 수 설정:
ethtool -L <dev> combined N(N = NUMA 노드의 CPU 수) - IRQ affinity: irqbalance 중지 후 수동 설정 (큐 IRQ → 같은 NUMA CPU)
- RSS 키/RETA:
ethtool -X <dev> equal N(균등) 또는 weight (가중치) - XPS:
xps_cpus로 CPU 1:1 매핑 - RFS + aRFS:
rps_sock_flow_entries,rps_flow_cnt,ntuple on - backlog:
netdev_max_backlog,netdev_budget조정 - 검증:
ethtool -S,/proc/net/softnet_stat,mpstat으로 확인
네트워크 패킷 흐름 (Packet Flow) 심화
전체 네트워크 스택 RX/TX 경로
Linux 네트워크 스택은 NIC 하드웨어부터 유저스페이스 애플리케이션까지 여러 계층을 통과합니다. 다음 다이어그램은 패킷이 수신(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 체인과 패킷 경로
패킷 경로별 상세 흐름
| 경로 | 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 유지) */
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는 단일 패스 처리로 체인이 많을 때 성능 우위
네트워크 디버깅 체크리스트
- 증상 재현 — 패킷 유실/지연/커널 로그를 재현 가능한 조건으로 고정합니다.
- 경로 추적 — NAPI, Netfilter, conntrack, qdisc 경로를 순서대로 점검합니다.
- 완화 적용 — sysctl/큐 길이/타임아웃 정책을 조정해 영향 범위를 줄입니다.
네트워크 스택 주요 버그 사례
리눅스 커널 네트워크 스택은 수십만 줄의 코드와 수백 개의 프로토콜 구현으로 구성된 복잡한 서브시스템입니다. 이 섹션에서는 실제 발생한 주요 버그와 취약점 사례를 분석하여 커널 네트워크 개발 시 주의해야 할 패턴을 살펴봅니다.
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 공격과 SYN Cookie 메커니즘
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의 비정상 카운터를 모니터링합니다.
관련 문서
네트워크 스택과 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.