sk_buff 자료구조
Linux 커널 네트워크 스택의 핵심 자료구조 sk_buff: 메모리 레이아웃, 데이터 조작 함수, clone/copy, 소켓(struct sock) 연동, 소켓 옵션, raw socket, 체크섬 오프로드, GSO/GRO, zero-copy 전송까지 종합 분석합니다. 커널 내부 데이터 경로, 핵심 자료구조/API, 운영 환경 튜닝 포인트와 장애 디버깅 절차까지 실무 관점으로 다룹니다.
핵심 요약
- 메모리 레이아웃 —
head,data,tail,end4개 포인터로 버퍼 관리.skb_push/pull/put로 데이터 영역 조작. - 참조 모델 —
clone은 메타데이터만 복사하고 버퍼 공유,copy는 완전 복사. 참조 카운트 관리가 핵심. - 소켓 메모리 —
sk_rmem_alloc/sk_wmem_alloc이truesize기반 소켓 버퍼 제한 구현. - 헤더 포인터 —
mac_header,network_header,transport_header로 L2/L3/L4 헤더 오프셋 추적. - 수명주기 — 할당 → 프로토콜 처리 → 소켓 전달 → 사용자 복사 → 해제. 각 단계에서 다른 함수와 상태 변화.
- skb 확장 —
skb_ext로 conntrack, IPsec secpath, bridge NF 등 가변 메타데이터를 skb에 동적 연결. 5.x+에서 메모리 효율 향상. - page_pool — 최신 고성능 드라이버(6.x+)는 page_pool로 DMA 매핑 캐시와 페이지 재활용을 구현해 할당/해제 비용 최소화.
- XDP 인터페이스 —
xdp_buff는 skb 할당 이전 단계로 동작. XDP_PASS 시build_skb()를 통해 sk_buff로 변환되어 일반 스택 진입.
단계별 이해
- 구조체 이해
4개 포인터(head, data, tail, end)와 3개 헤더 포인터(mac, network, transport)의 관계를 먼저 익힙시다. 이게 sk_buff의 핵심입니다. - 데이터 조작 함수
skb_push(헤더 추가),skb_pull(헤더 제거),skb_put(데이터 추가)의 동작을 코드로 직접 연습합니다. - 할당 함수 선택
alloc_skb(일반),netdev_alloc_skb(드라이버 수신),napi_alloc_skb(NAPI),page_pool_alloc_pages(6.x 고성능) 차이점을 파악합니다. - 수명주기 추적
수신 경로(NIC → 드라이버 → L2 → L3 → L4 → 소켓)와 전송 경로의 함수 호출 체인을 따라가며 이해합니다. - 확장 시스템 학습
skb_ext,page_pool, XDP ↔ sk_buff 변환 등 현대 커널의 최적화 메커니즘을 파악해 고성능 드라이버 코드를 읽는 눈을 키웁니다. - 실전 디버깅 연습
perf trace -e skb:kfree_skb로 드롭 원인을 추적하고,/proc/net/softnet_stat으로 CPU별 처리량을 분석하며,dropwatch로 병목 지점을 찾아봅니다.
개요
struct sk_buff(흔히 skb라 부름)는 Linux 네트워크 스택에서 모든 패킷을 표현하는 자료구조입니다. 수신 패킷이든 전송 패킷이든, L2 프레임이든 L4 세그먼트든, 커널 내에서 네트워크 데이터가 이동할 때는 항상 sk_buff가 사용됩니다.
O'Reilly의 "Understanding Linux Network Internals"에서 Christian Benvenuti는 sk_buff를 "네트워크 스택의 왕(king of networking)"이라 표현했습니다. 이 자료구조를 이해하지 않고는 Linux 네트워크 코드를 읽을 수 없습니다.
- 헤더 파일:
<linux/skbuff.h> - 주요 소스:
net/core/skbuff.c - 구조체 크기: x86_64에서 약 232바이트 (커널 6.x 기준)
struct sk_buff 주요 필드
/* include/linux/skbuff.h (주요 필드만 발췌) */
struct sk_buff {
union {
struct {
struct sk_buff *next; /* 리스트 내 다음 skb */
struct sk_buff *prev; /* 리스트 내 이전 skb */
};
struct rb_node rbnode; /* TCP retransmit queue용 */
};
struct sock *sk; /* 소속 소켓 */
struct net_device *dev; /* 수신/전송 네트워크 디바이스 */
unsigned int len; /* 전체 데이터 길이 (linear + frags) */
unsigned int data_len; /* 비선형(paged) 데이터 길이 */
__u16 mac_len; /* MAC 헤더 길이 */
__u16 hdr_len; /* 클론 시 writable 헤더 길이 */
__be16 protocol; /* 패킷 프로토콜 (ETH_P_IP 등) */
__u32 priority; /* QoS 우선순위 */
sk_buff_data_t transport_header; /* L4 헤더 오프셋 */
sk_buff_data_t network_header; /* L3 헤더 오프셋 */
sk_buff_data_t mac_header; /* L2 헤더 오프셋 */
sk_buff_data_t tail; /* 데이터 끝 */
sk_buff_data_t end; /* 할당된 버퍼 끝 */
unsigned char *head; /* 할당된 버퍼 시작 */
unsigned char *data; /* 실제 데이터 시작 */
unsigned int truesize; /* 실제 메모리 사용량 */
refcount_t users; /* 참조 카운트 */
};
자주 사용되는 추가 필드
위의 핵심 필드 외에도, sk_buff에는 프로토콜 처리와 성능 최적화에 쓰이는 중요한 필드들이 있습니다:
struct sk_buff {
/* ... 위의 핵심 필드들 ... */
char cb[48]; /* 프로토콜별 제어 블록 (Control Buffer) */
__u32 hash; /* 패킷 해시 (RSS, flow steering) */
__u8 pkt_type:3; /* PACKET_HOST, PACKET_BROADCAST 등 */
__u8 ip_summed:2; /* 체크섬 오프로드 상태 */
__u32 mark; /* netfilter/tc 마킹 (iptables -j MARK) */
__u16 queue_mapping; /* 멀티큐 NIC 큐 인덱스 */
unsigned int napi_id; /* NAPI 구조체 ID (busy polling) */
union {
__u32 tstamp; /* 수신 타임스탬프 */
u64 skb_mstamp_ns; /* 고해상도 타임스탬프 */
};
__u8 cloned:1; /* clone 여부 */
__u8 nohdr:1; /* 페이로드 참조만 (헤더 없음) */
__u8 peeked:1; /* MSG_PEEK으로 이미 확인됨 */
};
| 필드 | 크기 | 용도 | 접근 방법 |
|---|---|---|---|
cb[48] | 48바이트 | 프로토콜 레이어가 임시 데이터 저장 (TCP: tcp_skb_cb) | TCP_SKB_CB(skb), IPCB(skb) |
hash | 32비트 | 수신 패킷의 flow hash (RSS, RPS에 활용) | skb_get_hash(skb) |
pkt_type | 3비트 | 패킷 수신 유형: HOST, BROADCAST, MULTICAST, OTHERHOST | 직접 접근 |
ip_summed | 2비트 | 체크섬 오프로드 모드 (NONE/UNNECESSARY/COMPLETE/PARTIAL) | 직접 접근 |
mark | 32비트 | netfilter, tc, 라우팅 결정에 사용되는 패킷 마크 | 직접 접근 |
queue_mapping | 16비트 | 멀티큐 NIC에서 TX/RX 큐 선택 | skb_get_queue_mapping(skb) |
napi_id | 32비트 | NAPI 인스턴스 식별 (SO_BUSY_POLL 연동) | 직접 접근 |
cb[48]은 각 프로토콜 레이어가 자신만의 구조체로 캐스팅하여 사용합니다. 예를 들어 TCP는 TCP_SKB_CB(skb)로 struct tcp_skb_cb에 시퀀스 번호, 타임스탬프 등을 저장합니다. 한 레이어가 cb를 사용하면 다음 레이어에서 덮어쓰므로, 레이어 간 데이터 전달에는 사용하지 마세요.
체크섬 오프로드와 ip_summed
ip_summed 필드는 NIC 하드웨어의 체크섬 오프로드 상태를 나타내는 핵심 플래그입니다:
| 값 | 의미 (RX) | 의미 (TX) |
|---|---|---|
CHECKSUM_NONE | HW 미지원, SW 검증 필요 | SW가 체크섬 계산 완료 |
CHECKSUM_UNNECESSARY | HW 검증 완료, 유효함 | 체크섬 불필요 (loopback 등) |
CHECKSUM_COMPLETE | HW가 전체 체크섬 제공 | 사용 안 함 |
CHECKSUM_PARTIAL | 사용 안 함 | HW에 체크섬 계산 위임 |
/* 수신: 드라이버에서 체크섬 상태 설정 */
void my_driver_rx(struct sk_buff *skb, bool csum_ok)
{
if (csum_ok) {
skb->ip_summed = CHECKSUM_UNNECESSARY; /* SW 검증 생략 */
} else {
skb->ip_summed = CHECKSUM_NONE; /* SW 검증 필요 */
}
}
/* 전송: CHECKSUM_PARTIAL 설정 예 */
skb->ip_summed = CHECKSUM_PARTIAL;
skb->csum_start = skb_transport_header(skb) - skb->head;
skb->csum_offset = offsetof(struct udphdr, check);
CHECKSUM_UNNECESSARY를 설정하면 TCP/UDP 프로토콜 스택에서 __skb_checksum_validate_needed()를 건너뛰어 CPU 사이클을 크게 절약합니다. 하지만 일부 buggy NIC에서는 가짜 양수(false positive)가 발생할 수 있어, 문제 발생 시 ethtool -K eth0 rx-checksumming off로 비활성화하고 테스트하세요.
skb 할당과 해제
sk_buff 할당 함수는 사용 상황에 따라 여러 변형이 있습니다:
| 함수 | 컨텍스트 | 특징 |
|---|---|---|
alloc_skb(size, gfp) | 일반 (프로세스/softirq) | 기본 할당 함수. kmalloc으로 linear 버퍼 할당 |
netdev_alloc_skb(dev, len) | NAPI/irq 수신 경로 | NET_SKB_PAD headroom 자동 확보, per-CPU 캐시 활용 |
napi_alloc_skb(napi, len) | NAPI poll 내부 | NAPI 전용 per-CPU 페이지 캐시, 최적 성능 |
build_skb(data, frag_size) | 사전 할당 버퍼 | 이미 할당된 버퍼에 skb 메타데이터만 생성 |
__alloc_skb(size, gfp, flags) | 내부 API | SKB_ALLOC_FCLONE, SKB_ALLOC_RX 등 플래그 지정 |
/* 일반적인 전송 경로 할당 */
struct sk_buff *skb = alloc_skb(MAX_HEADER + payload_len, GFP_KERNEL);
if (!skb)
return -ENOMEM;
skb_reserve(skb, MAX_HEADER); /* L2/L3/L4 헤더용 headroom */
/* NAPI 수신 경로 할당 (드라이버 내) */
struct sk_buff *skb = napi_alloc_skb(napi, 256); /* 헤더만 linear */
/* 페이로드는 page fragment로 추가 */
skb_add_rx_frag(skb, 0, page, offset, size, truesize);
/* build_skb: XDP, 고성능 드라이버에서 사용 */
void *buf = page_address(page);
struct sk_buff *skb = build_skb(buf, PAGE_SIZE);
if (!skb) {
put_page(page);
return;
}
skb_reserve(skb, headroom);
해제 함수도 상황에 따라 구분됩니다:
| 함수 | 용도 | tracepoint |
|---|---|---|
kfree_skb(skb) | 패킷 드롭 (에러/필터링) | skb:kfree_skb 발생 (원인 추적 가능) |
consume_skb(skb) | 정상적 소비 완료 | skb:consume_skb 발생 |
dev_kfree_skb_any(skb) | 드라이버 (irq/process 모두) | 컨텍스트에 따라 지연 해제 가능 |
dev_consume_skb_any(skb) | 드라이버 정상 소비 | irq-safe한 consume_skb |
kfree_skb_reason(skb, reason) | 드롭 원인 명시 (6.x+) | 드롭 원인을 enum으로 기록 |
kfree_skb()와 consume_skb()의 차이는 기능적으로 동일하지만, tracepoint가 다릅니다. 정상 경로에서 kfree_skb()를 사용하면 드롭 모니터링 도구가 오탐을 보고합니다. 반드시 의미에 맞는 함수를 사용하세요.
메모리 레이아웃
sk_buff의 데이터 영역은 4개의 핵심 포인터로 관리됩니다:
이 레이아웃은 불변식 head ≤ data ≤ tail ≤ end를 항상 유지합니다.
이 조건이 깨지면 커널 패닉 또는 메모리 손상으로 이어집니다. skb_push()/skb_pull()/skb_put() 계열 함수는 호출 전에 이 불변식을 검증하며, 위반 시 skb_over_panic / skb_under_panic을 트리거합니다.
메모리 관련 주요 매크로
sk_buff 버퍼 크기를 계산할 때 자주 사용되는 매크로입니다:
| 매크로 | 정의 (개념) | 용도 |
|---|---|---|
SKB_DATA_ALIGN(X) |
ALIGN(X, SMP_CACHE_BYTES) |
SMP 캐시 라인 단위(보통 64B)로 올림 정렬. end 포인터 계산에 사용. |
SKB_WITH_OVERHEAD(X) |
X - SKB_DATA_ALIGN(sizeof(struct skb_shared_info)) |
할당 크기 X에서 skb_shared_info를 뺀 실제 사용 가능한 linear 데이터 크기. |
SKB_TRUESIZE(X) |
SKB_DATA_ALIGN(X + sizeof(struct sk_buff)) + SKB_DATA_ALIGN(sizeof(struct skb_shared_info)) |
X바이트 데이터를 담는 sk_buff를 할당할 때 실제로 필요한 총 메모리 크기. skb->truesize 초기값으로 사용. |
SKB_MAX_HEAD(X) |
SKB_WITH_OVERHEAD(PAGE_SIZE - X) |
헤더용 headroom X를 예약한 뒤 한 페이지 내에서 사용할 수 있는 linear 데이터 최대 크기. |
alloc_skb(size, gfp)는 내부적으로 SKB_TRUESIZE(size)로 skb->truesize를 초기화합니다. 소켓의 수신 버퍼 제한(sk_rmem_alloc)은 이 값을 누적해 추적하므로, 페이지 프래그먼트를 직접 추가할 때는 skb_add_rx_frag()의 truesize 인자를 실제 할당 크기(예: PAGE_SIZE)로 정확히 넘겨야 합니다.
데이터 조작 함수
sk_buff의 데이터 영역을 조작하는 4대 함수:
| 함수 | 동작 | 용도 |
|---|---|---|
skb_reserve(skb, len) | data와 tail을 len만큼 뒤로 | 할당 직후 headroom 확보 |
skb_put(skb, len) | tail을 len만큼 뒤로 | 데이터 끝에 추가 (전송 시) |
skb_push(skb, len) | data를 len만큼 앞으로 | 헤더 추가 (L4→L3→L2) |
skb_pull(skb, len) | data를 len만큼 뒤로 | 헤더 제거 (수신 시 L2→L3→L4) |
/* 전형적인 전송 경로에서의 skb 조작 순서 */
struct sk_buff *skb = alloc_skb(1500 + headroom, GFP_KERNEL);
/* 1. headroom 확보 */
skb_reserve(skb, headroom); /* data, tail 이동 → headroom 공간 */
/* 2. 페이로드 추가 */
unsigned char *p = skb_put(skb, payload_len); /* tail 이동 */
memcpy(p, payload_data, payload_len);
/* 3. TCP 헤더 추가 */
struct tcphdr *th = skb_push(skb, sizeof(*th)); /* data 앞으로 이동 */
skb_reset_transport_header(skb);
/* 4. IP 헤더 추가 */
struct iphdr *ih = skb_push(skb, sizeof(*ih)); /* data 더 앞으로 */
skb_reset_network_header(skb);
/* 5. Ethernet 헤더 추가 */
struct ethhdr *eh = skb_push(skb, ETH_HLEN);
skb_reset_mac_header(skb);
Clone/Copy 메커니즘
여러 곳에서 같은 패킷을 참조해야 할 때 (예: 패킷 미러링, tcpdump 캡처, netfilter bridge), 세 가지 복사 전략을 선택할 수 있습니다:
/* clone: sk_buff 메타데이터만 복사, 데이터 버퍼는 공유 (refcount 증가) */
struct sk_buff *clone = skb_clone(skb, GFP_ATOMIC);
/* clone->data == skb->data (같은 버퍼 참조) */
/* skb_shared_info.dataref 증가됨 */
/* pskb_copy: linear 헤더만 복사, paged data는 page refcount 증가 */
struct sk_buff *pcopy = pskb_copy(skb, GFP_ATOMIC);
/* 헤더를 수정해야 하지만 페이로드는 그대로인 경우 최적 */
/* copy: 메타데이터 + linear + paged 데이터 모두 완전 복사 */
struct sk_buff *copy = skb_copy(skb, GFP_ATOMIC);
/* copy->data != skb->data (독립적 버퍼) */
/* skb_share_check: 공유 여부 확인 후 필요 시 clone */
skb = skb_share_check(skb, GFP_ATOMIC);
if (!skb)
return NET_RX_DROP;
/* 이제 skb를 독점적으로 소유 — 안전하게 메타데이터 수정 가능 */
선택 기준: 패킷을 읽기만 한다면 skb_clone(), 헤더만 수정해야 한다면 pskb_copy(), 페이로드까지 수정해야 한다면 skb_copy()를 사용하세요. Netfilter NAT는 pskb_copy()를 주로 사용합니다.
프래그먼트와 scatter-gather
대용량 패킷은 linear 데이터 외에 paged fragments를 사용합니다:
/* skb_shared_info: end 포인터 바로 뒤에 위치 */
struct skb_shared_info {
__u8 nr_frags; /* fragment 수 */
__u8 tx_flags;
unsigned short gso_size; /* GSO 세그먼트 크기 */
unsigned short gso_segs; /* GSO 세그먼트 수 */
unsigned short gso_type; /* GSO 타입 */
struct sk_buff *frag_list; /* 연결된 skb 리스트 */
skb_frag_t frags[MAX_SKB_FRAGS]; /* page fragment 배열 */
atomic_t dataref; /* 데이터 공유 참조 카운트 */
};
/* fragment 접근 */
struct skb_shared_info *si = skb_shinfo(skb);
for (int i = 0; i < si->nr_frags; i++) {
skb_frag_t *frag = &si->frags[i];
struct page *page = skb_frag_page(frag);
unsigned int offset = skb_frag_off(frag);
unsigned int size = skb_frag_size(frag);
}
고급 데이터 조작
패킷 처리 시 데이터 레이아웃을 변경해야 하는 상황이 자주 발생합니다. 아래 함수들은 각각 다른 상황에서 사용됩니다:
| 함수 | 동작 | 사용 시나리오 |
|---|---|---|
skb_linearize(skb) | 모든 paged fragment를 linear 영역으로 합침 | 레거시 드라이버, fragment 미지원 코드 |
pskb_may_pull(skb, len) | len 바이트까지 linear 영역에 확보 | 프로토콜 헤더 파싱 전 (필수 패턴) |
pskb_expand_head(skb, nhead, ntail, gfp) | headroom/tailroom 확장 (필요 시 버퍼 재할당) | encapsulation 헤더 추가 (tunnel, VLAN) |
skb_cow_head(skb, headroom) | 공유 skb의 헤더를 안전하게 쓰기 가능하게 | clone된 skb의 헤더 수정 전 |
skb_make_writable(skb, len) | len 바이트까지 쓰기 가능하게 (clone 해제+linearize) | netfilter에서 패킷 내용 수정 전 |
/* pskb_may_pull: 프로토콜 헤더 파싱의 필수 패턴 */
static int my_protocol_rcv(struct sk_buff *skb)
{
struct my_hdr *hdr;
/* linear 영역에 최소 헤더 크기만큼 확보 */
if (!pskb_may_pull(skb, sizeof(*hdr)))
goto drop;
hdr = (struct my_hdr *)skb_transport_header(skb);
/* 이제 hdr-> 필드에 안전하게 접근 가능 */
/* 가변 길이 헤더라면 두 번째 pull */
if (!pskb_may_pull(skb, hdr->hdr_len))
goto drop;
hdr = (struct my_hdr *)skb_transport_header(skb); /* 포인터 재취득! */
/* ... 처리 ... */
}
/* skb_cow_head: 터널 encapsulation 전 headroom 확보 */
static int my_tunnel_xmit(struct sk_buff *skb, struct net_device *dev)
{
int hdr_len = sizeof(struct iphdr) + sizeof(struct gre_hdr);
/* headroom이 부족하거나 skb가 공유 상태이면 재할당 */
if (skb_cow_head(skb, hdr_len + LL_RESERVED_SPACE(dev))) {
kfree_skb(skb);
return NETDEV_TX_OK;
}
/* 이제 안전하게 헤더 추가 가능 */
skb_push(skb, hdr_len);
/* ... GRE + IP 헤더 설정 ... */
}
/* skb_linearize: fragment가 있는 skb를 하나의 연속 버퍼로 */
if (skb_is_nonlinear(skb)) {
if (skb_linearize(skb))
goto drop; /* 메모리 부족 */
/* 이제 모든 데이터가 head~tail 사이에 연속으로 존재 */
}
pskb_may_pull() 호출 후에는 반드시 헤더 포인터를 재취득해야 합니다. 내부적으로 버퍼 재할당이 일어날 수 있어 이전 포인터가 무효화됩니다. 이 실수는 커널 네트워크 코드에서 가장 흔한 버그 패턴 중 하나입니다.
sk_buff 리스트 관리
/* sk_buff_head: 이중 연결 리스트의 head (sentinel) */
struct sk_buff_head {
struct sk_buff *next;
struct sk_buff *prev;
__u32 qlen; /* 큐 내 skb 수 */
spinlock_t lock; /* 동시성 보호 */
};
/* 초기화 */
struct sk_buff_head my_queue;
skb_queue_head_init(&my_queue);
/* 큐 조작 */
skb_queue_tail(&my_queue, skb); /* 큐 끝에 추가 */
skb_queue_head(&my_queue, skb); /* 큐 앞에 추가 */
struct sk_buff *s = skb_dequeue(&my_queue); /* 큐 앞에서 제거 */
skb_queue_purge(&my_queue); /* 전체 비우기 */
소켓과 sk_buff의 관계
sk_buff의 sk 필드는 해당 패킷이 소속된 소켓(struct sock)을 가리킵니다. 이 연결은 소켓 메모리 계산, 소켓 옵션 적용, 프로토콜별 처리의 기반이 됩니다.
struct sock 계층 구조
커널 소켓은 3단계 계층으로 구성됩니다:
/* 소켓 구조체 계층 (간략) */
struct socket { /* BSD 소켓 (사용자 공간 인터페이스) */
socket_state state; /* SS_UNCONNECTED, SS_CONNECTED 등 */
struct file *file; /* VFS file (fd와 연결) */
struct sock *sk; /* 네트워크 레이어 소켓 */
const struct proto_ops *ops; /* connect, sendmsg 등 */
};
struct sock { /* 프로토콜 무관 공통 소켓 */
struct sk_buff_head sk_receive_queue; /* 수신 skb 큐 */
struct sk_buff_head sk_write_queue; /* 전송 skb 큐 */
struct sk_buff_head sk_error_queue; /* 에러 큐 (ICMP 등) */
struct {
struct sk_buff *head, *tail;
} sk_backlog; /* backlog 큐 (lock 중 수신) */
atomic_t sk_rmem_alloc; /* 수신 큐 메모리 사용량 */
atomic_t sk_wmem_alloc; /* 전송 큐 메모리 사용량 */
int sk_rcvbuf; /* SO_RCVBUF 값 */
int sk_sndbuf; /* SO_SNDBUF 값 */
unsigned long sk_flags; /* 소켓 플래그 */
struct proto *sk_prot; /* 프로토콜 핸들러 */
void (*sk_data_ready)(struct sock *sk); /* 수신 알림 */
/* ... */
};
/* 프로토콜별 확장 (임베디드 패턴) */
struct inet_sock { /* IPv4/IPv6 공통 */
struct sock sk; /* 공통 sock 내장 */
__be32 inet_saddr; /* 소스 IP */
__be32 inet_daddr; /* 목적지 IP */
__be16 inet_sport; /* 소스 포트 */
__be16 inet_dport; /* 목적지 포트 */
__u8 tos; /* IP_TOS 옵션 */
__u8 min_ttl; /* IP_MINTTL 옵션 */
__s16 uc_ttl; /* IP_TTL 옵션 (-1 = 기본값) */
struct ip_options_rcu *inet_opt; /* IP 옵션 */
/* ... */
};
struct tcp_sock {
struct inet_connection_sock inet_conn; /* inet_sock ⊃ sock 내장 */
u32 snd_una; /* 가장 오래된 미확인 시퀀스 */
u32 snd_nxt; /* 다음 전송 시퀀스 */
u32 rcv_nxt; /* 다음 수신 기대 시퀀스 */
u32 mss_cache; /* MSS (최대 세그먼트 크기) */
/* ... 수십 개 TCP 전용 필드 ... */
};
struct udp_sock {
struct inet_sock inet;
int pending; /* cork 상태 */
__u8 encap_type; /* UDP encap (VXLAN 등) */
/* ... */
};
/* 캐스팅 매크로 */
#define inet_sk(sk) ((struct inet_sock *)(sk))
#define tcp_sk(sk) ((struct tcp_sock *)(sk))
#define udp_sk(sk) ((struct udp_sock *)(sk))
skb↔sk 바인딩과 소켓 메모리 관리
skb->sk가 설정되면 해당 skb의 메모리 사용량이 소켓에 과금됩니다. 이 메커니즘이 SO_RCVBUF/SO_SNDBUF 제한을 실현합니다:
/* skb를 소켓에 연결 — 메모리 과금 시작 */
static inline void skb_set_owner_r(struct sk_buff *skb, struct sock *sk)
{
skb->sk = sk;
skb->destructor = sock_rfree; /* 해제 시 콜백 */
atomic_add(skb->truesize, &sk->sk_rmem_alloc);
/* → 수신 큐 메모리 사용량에 truesize만큼 추가 */
}
static inline void skb_set_owner_w(struct sk_buff *skb, struct sock *sk)
{
skb->sk = sk;
skb->destructor = sock_wfree; /* 해제 시 콜백 */
refcount_add(skb->truesize, &sk->sk_wmem_alloc);
/* → 전송 큐 메모리 사용량에 truesize만큼 추가 */
}
/* skb 해제 시: destructor 콜백이 메모리 차감 */
void sock_rfree(struct sk_buff *skb)
{
struct sock *sk = skb->sk;
atomic_sub(skb->truesize, &sk->sk_rmem_alloc);
/* → 수신 큐 메모리 사용량에서 차감 */
}
/* 수신 큐 과부하 확인 — 소켓 버퍼 제한 */
static inline bool sk_rmem_schedule(struct sock *sk, struct sk_buff *skb, int size)
{
/* sk_rmem_alloc + size > sk_rcvbuf 이면 false → 패킷 드롭 */
return __sk_mem_schedule(sk, size, SK_MEM_RECV);
}
| 필드/콜백 | 방향 | 역할 |
|---|---|---|
sk_rmem_alloc | RX | 수신 큐에 쌓인 skb의 총 truesize (SO_RCVBUF와 비교) |
sk_wmem_alloc | TX | 전송 중인 skb의 총 truesize (SO_SNDBUF와 비교) |
sock_rfree | RX | skb 해제 시 sk_rmem_alloc 차감 |
sock_wfree | TX | skb 해제 시 sk_wmem_alloc 차감, 전송 대기 프로세스 wakeup |
skb_orphan(skb) | 양방향 | skb↔sk 연결 해제 (destructor 호출 후 sk=NULL) |
skb->truesize는 skb 구조체 크기 + 할당된 데이터 버퍼 크기를 합산한 값입니다. 소켓의 메모리 추적은 이 값 기반이므로, truesize가 실제와 어긋나면 SO_RCVBUF 제한이 오작동합니다. 너무 작으면 메모리 과다 사용, 너무 크면 조기 패킷 드롭이 발생합니다.
소켓 옵션(setsockopt)과 sk_buff
사용자 공간의 setsockopt() 호출은 struct sock 필드를 변경하고, 이것이 skb 생성·처리에 직접 반영됩니다:
| 소켓 옵션 | 레벨 | sock/skb 영향 |
|---|---|---|
SO_RCVBUF | SOL_SOCKET | sk->sk_rcvbuf 설정 → 수신 큐 skb 총량 제한 |
SO_SNDBUF | SOL_SOCKET | sk->sk_sndbuf 설정 → 전송 큐 skb 총량 제한 |
SO_MARK | SOL_SOCKET | sk->sk_mark → skb->mark로 복사 (netfilter/tc/라우팅) |
SO_PRIORITY | SOL_SOCKET | sk->sk_priority → skb->priority로 복사 (QoS) |
SO_BINDTODEVICE | SOL_SOCKET | sk->sk_bound_dev_if → skb의 dev 제한 |
SO_TIMESTAMP | SOL_SOCKET | 수신 skb에 타임스탬프 기록, recvmsg() cmsg로 전달 |
SO_BUSY_POLL | SOL_SOCKET | sk->sk_napi_id + skb->napi_id로 busy polling |
IP_TOS | SOL_IP | inet->tos → 전송 skb IP 헤더 TOS 필드 |
IP_TTL | SOL_IP | inet->uc_ttl → 전송 skb IP 헤더 TTL 필드 |
IP_HDRINCL | SOL_IP | raw socket: 사용자가 IP 헤더를 직접 제공 |
TCP_NODELAY | SOL_TCP | Nagle 알고리즘 해제 → 소량 데이터 즉시 skb 전송 |
TCP_CORK | SOL_TCP | skb 전송 지연 (cork), uncork 시 한번에 전송 |
UDP_CORK | SOL_UDP | 여러 sendmsg를 하나의 skb로 합쳐 전송 |
UDP_GRO | SOL_UDP | 수신 UDP GRO 활성화 → 여러 패킷이 하나의 큰 skb로 |
/* 전송 경로에서 sock 옵션 → skb 필드 복사 과정 */
static void ip_copy_addrs(struct iphdr *iph, const struct flowi4 *fl4)
{
/* flowi4는 routing lookup 입력: sock의 IP/포트에서 구성 */
iph->saddr = fl4->saddr;
iph->daddr = fl4->daddr;
}
/* ip_queue_xmit: TCP 전송 시 sock 옵션 적용 */
int __ip_queue_xmit(struct sock *sk, struct sk_buff *skb, ...)
{
struct inet_sock *inet = inet_sk(sk);
/* SK 옵션 → skb 필드 전파 */
skb->priority = sk->sk_priority; /* SO_PRIORITY */
skb->mark = sk->sk_mark; /* SO_MARK */
/* IP 헤더 필드: inet_sock에서 가져옴 */
iph->tos = inet->tos; /* IP_TOS */
iph->ttl = ip_select_ttl(inet, ...); /* IP_TTL 또는 기본값 */
/* ... */
}
/* SO_RCVBUF 설정과 수신 큐 제한의 관계 */
/* 사용자 공간 */
int bufsize = 262144; /* 256KB */
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize));
/* 커널: sk->sk_rcvbuf = min(bufsize * 2, sysctl_rmem_max)
* → 실제 커널 값은 요청값의 2배 (overhead 고려)
*
* 수신 시: atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf 이면
* → 새 패킷 드롭 (ENOMEM) */
SO_RCVBUF/SO_SNDBUF에 설정한 값은 커널 내에서 2배로 증폭됩니다 (sock_setsockopt() 내부). 이는 skb 구조체와 메타데이터 오버헤드를 고려한 것입니다. getsockopt()으로 읽으면 2배된 값이 반환됩니다. 시스템 전역 상한은 /proc/sys/net/core/rmem_max, wmem_max입니다.
Raw Socket과 sk_buff
Raw socket(SOCK_RAW)은 프로토콜 스택의 일부를 우회하여 직접 패킷을 구성하거나 수신합니다. 일반 SOCK_STREAM/SOCK_DGRAM과 달리 커널의 L4 프로토콜 처리를 거치지 않고 skb를 직접 다루므로, 네트워크 도구(ping, traceroute, tcpdump, nmap 등)와 프로토콜 구현의 핵심입니다.
Raw Socket 타입 비교
| 타입 | 생성 | 접근 계층 | skb 관계 |
|---|---|---|---|
| IP raw socket | socket(AF_INET, SOCK_RAW, IPPROTO_XXX) |
L3 (IP) | IP_HDRINCL 없으면 커널이 IP 헤더 생성. 수신 시 IP 헤더 포함 |
| IP raw + IP_HDRINCL | setsockopt(IP_HDRINCL, 1) |
L3 (IP) | 사용자가 IP 헤더까지 직접 작성. skb->data가 IP 헤더부터 시작 |
| Packet socket (L2 raw) | socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)) |
L2 (Ethernet) | Ethernet 헤더 포함 전체 프레임 접근. skb를 MAC 헤더부터 수신/전송 |
| Packet socket (L2 cooked) | socket(AF_PACKET, SOCK_DGRAM, htons(ETH_P_ALL)) |
L3 (IP) | L2 헤더 제거 후 전달. 송신 시 커널이 L2 헤더 생성 |
| AF_PACKET + TPACKET | setsockopt(PACKET_VERSION, TPACKET_V3) |
L2 (Ethernet) | mmap 기반 ring buffer → skb 없이 직접 DMA 버퍼 접근 (고성능) |
| Ping socket | socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP) |
L4 (ICMP) | CAP_NET_RAW 불필요. 커널이 ICMP 헤더 id/checksum 관리 |
Raw Socket 권한 모델
Raw socket 생성에는 CAP_NET_RAW capability가 필요합니다. 커널은 sock_create() → inet_create() 경로에서 capability를 검사합니다:
/* net/ipv4/af_inet.c — inet_create() */
static int inet_create(struct net *net, struct socket *sock,
int protocol, int kern)
{
struct inet_protosw *answer;
struct sock *sk;
/* SOCK_RAW 사용 시 CAP_NET_RAW 검사 */
if (sock->type == SOCK_RAW && !kern &&
!ns_capable(net->user_ns, CAP_NET_RAW))
return -EPERM;
/* protocol 번호로 inetsw[] 해시 테이블에서 프로토콜 핸들러 검색 */
answer = inet_protosw_lookup(sock->type, protocol);
/* SOCK_RAW → raw_prot (net/ipv4/raw.c)
* SOCK_STREAM → tcp_prot
* SOCK_DGRAM → udp_prot */
sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer->prot, kern);
/* ... */
}
/* net/packet/af_packet.c — AF_PACKET도 CAP_NET_RAW 필요 */
static int packet_create(struct net *net, struct socket *sock,
int protocol, int kern)
{
if (!kern && !ns_capable(net->user_ns, CAP_NET_RAW))
return -EPERM;
/* ... */
}
Ping socket 예외: Linux 3.0+에서 도입된 ping socket(socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP))은 CAP_NET_RAW 없이도 ICMP Echo Request를 보낼 수 있습니다. /proc/sys/net/ipv4/ping_group_range로 허용 GID 범위를 설정합니다. setcap cap_net_raw+ep /usr/bin/ping 대신 이 메커니즘을 사용합니다.
커널 내부 자료구조 — struct raw_sock
/* include/net/raw.h */
struct raw_sock {
struct inet_sock inet; /* inet_sock 상속 (→ sock → sock_common) */
struct icmp_filter filter; /* ICMP 타입별 필터 비트맵 */
u32 ipmr_table; /* 멀티캐스트 라우팅 테이블 ID */
};
/* raw socket 프로토콜 해시 테이블
* protocol 번호로 해싱하여 수신 시 O(1) 조회 */
struct raw_hashinfo {
spinlock_t lock;
struct hlist_head ht[RAW_HTABLE_SIZE]; /* 256 버킷 */
};
/* 전역 raw 해시 테이블 — 모든 AF_INET SOCK_RAW 소켓 관리 */
struct raw_hashinfo raw_v4_hashinfo; /* IPv4 */
struct raw_hashinfo raw_v6_hashinfo; /* IPv6 */
/* 해시 함수: protocol 번호를 버킷 인덱스로 변환 */
static inline u32 raw_hashfunc(const struct net *net, u32 proto)
{
return proto & (RAW_HTABLE_SIZE - 1); /* 0~255 */
}
/* raw socket의 프로토콜 연산 테이블 */
struct proto raw_prot = {
.name = "RAW",
.owner = THIS_MODULE,
.close = raw_close,
.connect = ip4_datagram_connect,
.sendmsg = raw_sendmsg,
.recvmsg = raw_recvmsg,
.bind = raw_bind,
.hash = raw_hash_sk,
.unhash = raw_unhash_sk,
.obj_size = sizeof(struct raw_sock),
};
AF_INET SOCK_RAW 수신 경로
IP 계층에서 패킷이 로컬로 배달될 때, TCP/UDP 디먹싱 이전에 raw socket으로의 복제가 먼저 수행됩니다. 즉, raw socket은 패킷의 사본을 받으며, 원본 skb는 정상 프로토콜 스택으로 계속 진행합니다:
/* net/ipv4/ip_input.c — ip_local_deliver_finish()
* 패킷이 로컬 배달될 때 raw socket에 먼저 전달 */
static int ip_local_deliver_finish(struct net *net, struct sock *sk,
struct sk_buff *skb)
{
__skb_pull(skb, skb_network_header_len(skb));
int protocol = ip_hdr(skb)->protocol;
const struct net_protocol *ipprot;
/* ① raw socket이 있으면 먼저 skb 사본 전달 */
raw_local_deliver(skb, protocol);
/* ② 등록된 프로토콜 핸들러 호출 (tcp_v4_rcv, udp_rcv 등) */
ipprot = rcu_dereference(inet_protos[protocol]);
if (ipprot) {
ret = INDIRECT_CALL_2(ipprot->handler, tcp_v4_rcv, udp_rcv,
skb);
}
}
/* net/ipv4/raw.c — raw_local_deliver()
* 해당 프로토콜의 raw socket들을 해시 테이블에서 찾아 전달 */
int raw_local_deliver(struct sk_buff *skb, int protocol)
{
struct raw_hashinfo *h = &raw_v4_hashinfo;
struct hlist_head *head;
int hash;
hash = raw_hashfunc(dev_net(skb_dst(skb)->dev), protocol);
head = &h->ht[hash];
if (!hlist_empty(head)) {
/* 매칭되는 모든 raw socket에 skb 복제본 전달 */
raw_v4_input(skb, ip_hdr(skb), hash);
}
return 0;
}
/* net/ipv4/raw.c — raw_v4_input()
* 프로토콜 번호와 목적지 주소가 매칭되는 모든 raw socket에 전달 */
static int raw_v4_input(struct sk_buff *skb, const struct iphdr *iph,
int hash)
{
struct sock *sk;
struct hlist_head *head = &raw_v4_hashinfo.ht[hash];
int delivered = 0;
rcu_read_lock();
sk_for_each_rcu(sk, head) {
/* 프로토콜 번호, 목적지 IP, 소스 IP, 네트워크 네임스페이스 매칭 */
if (raw_v4_match(net, sk, iph->protocol,
iph->saddr, iph->daddr,
skb->dev->ifindex, sdif)) {
/* skb를 clone하여 해당 raw socket에 전달 */
raw_rcv(sk, skb);
delivered++;
}
}
rcu_read_unlock();
return delivered;
}
/* net/ipv4/raw.c — raw_rcv()
* skb clone → IP 헤더 포함한 상태로 수신 큐에 추가 */
int raw_rcv(struct sock *sk, struct sk_buff *skb)
{
struct raw_sock *rp = raw_sk(sk);
/* ICMP 필터 적용: 관심 없는 ICMP 타입은 드롭 */
if (sk->sk_protocol == IPPROTO_ICMP) {
struct icmphdr *icmph = icmp_hdr(skb);
if (raw_icmp_type_filtered(rp, icmph->type))
return 0; /* 필터에 의해 드롭 */
}
/* skb 복제: 원본은 프로토콜 스택이 계속 사용 */
struct sk_buff *clone = skb_clone(skb, GFP_ATOMIC);
if (!clone)
return 0;
/* 핵심: data 포인터를 network_header (IP 헤더) 위치로 복원
* ip_local_deliver_finish()에서 __skb_pull로 L4까지 당겼으므로
* raw socket은 IP 헤더부터 보여줘야 함 */
skb_push(clone, clone->data - skb_network_header(clone));
/* 수신 큐에 추가 → recvmsg()로 사용자에게 전달 */
if (sock_queue_rcv_skb(sk, clone) < 0)
kfree_skb(clone);
return 0;
}
핵심 포인트: 동일 프로토콜 번호를 사용하는 여러 raw socket이 열려 있으면, 하나의 수신 패킷이 모든 매칭 소켓에 clone되어 전달됩니다. 예: 두 프로세스가 각각 IPPROTO_ICMP raw socket을 열면, ICMP 패킷 수신 시 두 프로세스 모두 사본을 받습니다. 이는 raw_v4_input()의 sk_for_each_rcu() 루프가 해시 버킷의 모든 소켓을 순회하기 때문입니다.
AF_INET SOCK_RAW 전송 경로
Raw socket의 전송 경로는 IP_HDRINCL 옵션에 따라 두 가지로 분기됩니다:
/* net/ipv4/raw.c — raw_sendmsg()
* 사용자 공간의 sendto()/sendmsg() → raw_sendmsg() */
static int raw_sendmsg(struct sock *sk, struct msghdr *msg, size_t len)
{
struct inet_sock *inet = inet_sk(sk);
struct flowi4 fl4;
struct rtable *rt;
int err;
/* 목적지 주소 결정 */
struct sockaddr_in *usin = (struct sockaddr_in *)msg->msg_name;
__be32 daddr;
if (usin) {
daddr = usin->sin_addr.s_addr;
} else {
/* connect()로 미리 바인딩된 주소 사용 */
daddr = inet->inet_daddr;
if (!daddr)
return -EDESTADDRREQ;
}
/* 라우팅 테이블 조회 */
flowi4_init_output(&fl4, ...);
rt = ip_route_output_flow(net, &fl4, sk);
if (inet->hdrincl) {
/* ── IP_HDRINCL 모드 ──
* 사용자가 IP 헤더를 직접 작성
* 커널은 최소한의 필드만 보정 */
err = raw_send_hdrinc(sk, &fl4, msg, len, &rt, msg->msg_flags);
} else {
/* ── 일반 raw 모드 ──
* 커널이 IP 헤더를 자동 생성
* 사용자 데이터는 L4 페이로드로 취급 */
err = ip_append_data(sk, &fl4, raw_getfrag,
msg, len, 0, &ipc, &rt, msg->msg_flags);
if (!err) {
err = ip_push_pending_frames(sk, &fl4);
/* → ip_output() → dev_queue_xmit() */
}
}
ip_rt_put(rt);
return err;
}
IP_HDRINCL 상세 — 커널의 보정 동작
IP_HDRINCL을 설정하면 사용자가 IP 헤더를 직접 작성하지만, 커널은 안전성과 정확성을 위해 일부 필드를 자동 보정합니다:
/* net/ipv4/raw.c — raw_send_hdrinc()
* IP_HDRINCL 모드의 실제 전송 처리 */
static int raw_send_hdrinc(struct sock *sk, struct flowi4 *fl4,
struct msghdr *msg, unsigned int len,
struct rtable **rtp, unsigned int flags)
{
struct iphdr *iph;
struct sk_buff *skb;
unsigned int iphlen;
/* skb 할당: IP 헤더 + 페이로드 크기 */
skb = sock_alloc_send_skb(sk,
len + LL_ALLOCATED_SPACE(rt->dst.dev), /* L2 headroom 확보 */
flags & MSG_DONTWAIT, &err);
if (!skb)
return err;
/* L2 헤더 공간 예약 */
skb_reserve(skb, LL_RESERVED_SPACE(rt->dst.dev));
skb->protocol = htons(ETH_P_IP);
/* 사용자 데이터를 skb에 복사 (IP 헤더 포함) */
skb_put(skb, len);
skb->network_header = skb->data;
skb_copy_datagram_from_iter(skb, 0, &msg->msg_iter, len);
iph = ip_hdr(skb);
/* ── 커널이 자동 보정하는 필드 ── */
/* (1) tot_len: 0이면 커널이 skb->len으로 설정 */
if (!iph->tot_len)
iph->tot_len = htons(len);
/* (2) saddr: 0이면 라우팅 결과의 소스 IP로 채움 */
if (!iph->saddr)
iph->saddr = fl4->saddr;
/* (3) id: 0이면 커널이 고유 ID 할당 */
if (!iph->id)
ip_select_ident(net, skb, NULL);
/* (4) check: 항상 커널이 재계산 (사용자 값 무시) */
iph->check = 0;
iph->check = ip_fast_csum((unsigned char *)iph, iph->ihl);
/* Netfilter OUTPUT 체인 통과 후 전송 */
err = NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_OUT,
net, sk, skb, NULL, rt->dst.dev,
dst_output);
return err;
}
| IP 헤더 필드 | 사용자 제공 시 | 0 또는 미설정 시 |
|---|---|---|
version | 사용자 값 사용 | 사용자가 반드시 4로 설정해야 함 |
ihl | 사용자 값 사용 | 사용자가 설정 (보통 5) |
tos | 사용자 값 사용 | 0 (기본 서비스) |
tot_len | 사용자 값 사용 | 커널이 skb->len으로 설정 |
id | 사용자 값 사용 | 커널이 ip_select_ident()로 할당 |
frag_off | 사용자 값 사용 | 0 (단편화 없음) |
ttl | 사용자 값 사용 | 사용자가 반드시 설정해야 함 |
protocol | 사용자 값 사용 | 사용자가 반드시 설정해야 함 |
saddr | 사용자 값 사용 (스푸핑 가능) | 커널이 라우팅 테이블에서 결정 |
daddr | 사용자 값 사용 | 사용자가 반드시 설정해야 함 |
check | 무시 — 커널이 항상 재계산 | 커널이 ip_fast_csum()으로 계산 |
IP_HDRINCL과 IP Spoofing: IP_HDRINCL을 사용하면 saddr(소스 IP)를 임의로 설정할 수 있어 IP 스푸핑이 가능합니다. 이것이 CAP_NET_RAW가 필요한 주요 보안 이유 중 하나입니다. 네트워크 장비의 BCP 38 (uRPF) 필터링이나 커널의 rp_filter 설정으로 발신 스푸핑 패킷을 차단할 수 있습니다.
raw_recvmsg() — 사용자 공간으로 전달
/* net/ipv4/raw.c — raw_recvmsg()
* 사용자의 recvfrom()/recvmsg() 처리 */
static int raw_recvmsg(struct sock *sk, struct msghdr *msg,
size_t len, int flags, int *addr_len)
{
struct sk_buff *skb;
struct sockaddr_in *sin = (struct sockaddr_in *)msg->msg_name;
int err, copied;
/* 수신 큐에서 skb 꺼내기 (대기 가능) */
skb = skb_recv_datagram(sk, flags, &err);
if (!skb)
return err;
/* skb에서 사용자 버퍼로 데이터 복사
* → IP 헤더부터 전체 패킷이 사용자에게 전달됨 */
copied = skb->len;
if (len < copied) {
msg->msg_flags |= MSG_TRUNC; /* 잘림 알림 */
copied = len;
}
skb_copy_datagram_msg(skb, 0, msg, copied);
/* 소스 주소 정보 채우기 */
if (sin) {
sin->sin_family = AF_INET;
sin->sin_addr.s_addr = ip_hdr(skb)->saddr;
sin->sin_port = 0; /* raw socket은 포트 개념 없음 */
}
/* IP_PKTINFO, IP_TTL 등 ancillary data (cmsg) 전달 */
if (inet_cmsg_flags(inet))
ip_cmsg_recv(msg, skb);
skb_free_datagram(sk, skb);
return copied;
}
ICMP 필터 (ICMP_FILTER)
IPPROTO_ICMP raw socket에서 관심 있는 ICMP 타입만 수신하도록 비트맵 필터를 설정할 수 있습니다:
/* include/uapi/linux/icmp.h */
struct icmp_filter {
__u32 data; /* 비트맵: bit N이 1이면 ICMP type N을 필터링(드롭) */
};
/* 사용자 공간 예: Echo Reply(type 0)만 수신, 나머지 필터링 */
struct icmp_filter filt;
filt.data = ~(1 << ICMP_ECHOREPLY); /* type 0만 통과 */
setsockopt(fd, SOL_RAW, ICMP_FILTER, &filt, sizeof(filt));
/* 커널 내부: raw_rcv()에서 필터 검사 */
static inline bool raw_icmp_type_filtered(const struct raw_sock *rp,
u8 type)
{
/* type에 해당하는 비트가 1이면 필터링(드롭) */
return (rp->filter.data >> type) & 1;
}
IPv6 Raw Socket (AF_INET6 SOCK_RAW)
IPv6 raw socket은 IPv4와 유사하지만 중요한 차이점이 있습니다:
| 특성 | IPv4 (AF_INET) | IPv6 (AF_INET6) |
|---|---|---|
| IP 헤더 접근 | IP_HDRINCL로 IP 헤더 포함 가능 |
IPv6 헤더는 항상 커널이 생성 (IPV6_HDRINCL 미지원) |
| 확장 헤더 | IP 옵션을 IP_OPTIONS로 설정 |
IPV6_RTHDR, IPV6_HOPOPTS 등 ancillary data(cmsg)로 설정 |
| ICMPv6 체크섬 | 사용자가 직접 계산 | 커널이 자동 계산 (RFC 3542 요구사항) |
| 체크섬 오프셋 | 해당 없음 | IPV6_CHECKSUM — 페이로드 내 체크섬 위치 지정, 커널이 계산 |
| 필터 | ICMP_FILTER |
ICMPV6_FILTER — 256비트 비트맵 (struct icmp6_filter) |
| 커널 소스 | net/ipv4/raw.c |
net/ipv6/raw.c |
/* IPv6 raw socket에서 ICMPv6 체크섬은 커널이 자동 계산 */
/* net/ipv6/raw.c — rawv6_send_hdrinc() 내부 */
/* IPV6_CHECKSUM 소켓 옵션: 체크섬 계산 위치 지정 */
int offset = 2; /* 페이로드 시작부터 체크섬 필드의 바이트 오프셋 */
setsockopt(fd, IPPROTO_IPV6, IPV6_CHECKSUM, &offset, sizeof(offset));
/* 커널이 IPv6 pseudo-header 포함 체크섬을 해당 오프셋에 기록 */
/* ICMPv6 raw socket (protocol = IPPROTO_ICMPV6)은
* IPV6_CHECKSUM이 자동으로 offset=2에 설정됨
* → ICMPv6 체크섬 필드 위치가 헤더 시작+2바이트 */
/* ICMPv6 필터 예: Neighbor Solicitation만 수신 */
struct icmp6_filter filt;
ICMP6_FILTER_SETBLOCKALL(&filt);
ICMP6_FILTER_SETPASS(ND_NEIGHBOR_SOLICIT, &filt);
setsockopt(fd, IPPROTO_ICMPV6, ICMPV6_FILTER, &filt, sizeof(filt));
AF_PACKET 심화 — L2 프레임 접근
AF_PACKET 소켓은 Ethernet 프레임 수준에서 패킷을 캡처/전송합니다. tcpdump, wireshark, dhclient, arping 등이 사용합니다.
| 소켓 타입 | 수신 시 포함 헤더 | 전송 시 필요 헤더 | 사용 사례 |
|---|---|---|---|
AF_PACKET, SOCK_RAW |
Ethernet + IP + L4 + 페이로드 | 사용자가 Ethernet 헤더 포함 전체 작성 | tcpdump, 패킷 injection |
AF_PACKET, SOCK_DGRAM |
IP + L4 + 페이로드 (Ethernet 제거) | 커널이 Ethernet 헤더 생성 | dhclient, 프로토콜 분석 |
/* net/packet/af_packet.c — packet_type 등록
* AF_PACKET 소켓 생성 시 packet_type을 등록하여
* NIC 드라이버의 수신 경로에 후킹 */
static int packet_create(struct net *net, struct socket *sock,
int protocol, int kern)
{
struct packet_sock *po;
struct sock *sk;
sk = sk_alloc(net, PF_PACKET, GFP_KERNEL, &packet_proto, kern);
po = pkt_sk(sk);
/* packet_type 구조체 설정 */
po->prot_hook.func = packet_rcv; /* 수신 콜백 */
po->prot_hook.af_packet_priv = sk; /* 소켓 포인터 */
if (protocol) {
po->prot_hook.type = protocol; /* ETH_P_ALL, ETH_P_IP 등 */
__register_prot_hook(sk);
/* → dev_add_pack() → ptype_all 또는 ptype_base[] 리스트에 등록
* → netif_receive_skb() 경로에서 모든 수신 패킷에 대해 콜백 */
}
}
/* 수신 콜백: netif_receive_skb() → deliver_skb() → packet_rcv() */
static int packet_rcv(struct sk_buff *skb, struct net_device *dev,
struct packet_type *pt, struct net_device *orig_dev)
{
struct sock *sk = pt->af_packet_priv;
struct sk_buff *copy;
unsigned int snaplen, res;
/* BPF 필터 적용 (setsockopt SO_ATTACH_FILTER) */
res = run_filter(skb, sk, snaplen);
if (!res)
goto drop; /* BPF 필터에 의해 드롭 */
/* ETH_P_ALL인 경우 모든 패킷에 대해 호출됨 */
copy = skb_clone(skb, GFP_ATOMIC);
if (!copy)
goto drop;
/* SOCK_RAW: MAC 헤더부터 전체 프레임 노출 */
if (sk->sk_type == SOCK_RAW)
skb_push(copy, skb_mac_header_len(skb));
/* sockaddr_ll에 수신 메타데이터 기록 */
struct sockaddr_ll *sll = &PACKET_SKB_CB(copy)->sa.ll;
sll->sll_ifindex = orig_dev->ifindex;
sll->sll_hatype = dev->type;
sll->sll_pkttype = skb->pkt_type; /* PACKET_HOST, PACKET_BROADCAST 등 */
sock_queue_rcv_skb(sk, copy);
return 0;
drop:
kfree_skb(skb);
return 0;
}
/* AF_PACKET 전송: 사용자 → dev_queue_xmit() 직접 전달 */
static int packet_sendmsg(struct socket *sock, struct msghdr *msg,
size_t len)
{
/* SOCK_RAW: 사용자가 Ethernet 헤더 포함 전체 프레임 작성 */
/* SOCK_DGRAM: sockaddr_ll에서 목적지 MAC, 커널이 Ethernet 헤더 생성 */
struct sk_buff *skb = packet_alloc_skb(sk, ...);
/* 사용자 데이터 복사 */
skb_copy_datagram_from_iter(skb, 0, &msg->msg_iter, len);
/* IP 스택을 완전히 우회하여 직접 디바이스 큐로 전송 */
err = dev_queue_xmit(skb);
/* → qdisc → NIC 드라이버 → 물리 전송 */
}
TPACKET — mmap 기반 고성능 캡처
TPACKET(PACKET_MMAP)은 커널-사용자 간 mmap된 공유 ring buffer를 사용하여 recvmsg()/sendmsg() 시스템콜 오버헤드 없이 패킷을 교환합니다. tcpdump, libpcap, suricata 등 고성능 캡처 도구의 핵심입니다.
| 버전 | 커널 | 특징 | 제한/이슈 |
|---|---|---|---|
TPACKET_V1 |
2.4+ | 기본 ring buffer, 고정 크기 프레임 | 32비트 타임스탬프, 큰 패킷 지원 불가 |
TPACKET_V2 |
2.6.27+ | VLAN 태그 보존, 64비트 타임스탬프 | 여전히 고정 크기 프레임 |
TPACKET_V3 |
3.2+ | 가변 크기 블록, 타임아웃 기반 블록 해제, 배치 처리 | TX ring 미지원 (V2 사용), 구현 복잡 |
/* TPACKET_V3 ring buffer 설정 예 (사용자 공간) */
struct tpacket_req3 req = {
.tp_block_size = 1 << 22, /* 4MB 블록 */
.tp_block_nr = 64, /* 64개 블록 = 256MB */
.tp_frame_size = TPACKET_ALIGNMENT << 7, /* 프레임 정렬 */
.tp_frame_nr = (1 << 22) * 64 / (TPACKET_ALIGNMENT << 7),
.tp_retire_blk_tov = 60, /* 블록 타임아웃 60ms */
.tp_feature_req_word = TP_FT_REQ_FILL_RXHASH,
};
/* TPACKET 버전 설정 */
int ver = TPACKET_V3;
setsockopt(fd, SOL_PACKET, PACKET_VERSION, &ver, sizeof(ver));
/* RX ring buffer 설정 */
setsockopt(fd, SOL_PACKET, PACKET_RX_RING, &req, sizeof(req));
/* 커널-사용자 공유 메모리 매핑 */
void *ring = mmap(NULL, req.tp_block_size * req.tp_block_nr,
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_LOCKED,
fd, 0);
/* 패킷 수신 루프 (V3 블록 기반) */
while (1) {
struct tpacket_block_desc *pbd = block_descs[current_block];
/* 블록이 준비될 때까지 대기 */
while (!(pbd->hdr.bh1.block_status & TP_STATUS_USER))
poll(&pfd, 1, -1);
/* 블록 내 모든 패킷 순회 */
int num_pkts = pbd->hdr.bh1.num_pkts;
struct tpacket3_hdr *ppd = (struct tpacket3_hdr *)
((uint8_t *)pbd + pbd->hdr.bh1.offset_to_first_pkt);
for (int i = 0; i < num_pkts; i++) {
uint8_t *pkt_data = (uint8_t *)ppd + ppd->tp_mac;
uint32_t pkt_len = ppd->tp_snaplen;
process_packet(pkt_data, pkt_len); /* 패킷 처리 */
ppd = (struct tpacket3_hdr *)
((uint8_t *)ppd + ppd->tp_next_offset);
}
/* 블록을 커널에 반환 */
pbd->hdr.bh1.block_status = TP_STATUS_KERNEL;
current_block = (current_block + 1) % req.tp_block_nr;
}
/* 커널 내부: TPACKET_V3 수신 처리
* net/packet/af_packet.c — tpacket_rcv() */
static int tpacket_rcv(struct sk_buff *skb, struct net_device *dev,
struct packet_type *pt,
struct net_device *orig_dev)
{
/* V3: 현재 블록에 패킷 추가 (가변 크기)
* → skb 데이터를 mmap 버퍼에 직접 복사
* → 블록이 가득 차거나 타임아웃 시 TP_STATUS_USER로 전환
* → 사용자는 poll()로 알림 받고 mmap 메모리에서 직접 읽음
* → recvmsg() 시스콜 불필요 = zero-copy 수신 */
/* BPF 필터 먼저 실행 */
res = run_filter(skb, sk, snaplen);
if (!res)
goto drop;
/* ring buffer의 현재 블록에 패킷 데이터 복사 */
h.raw = packet_current_rx_frame(po, skb, ...);
skb_copy_bits(skb, 0, h.raw + macoff, snaplen);
/* 패킷 메타데이터 기록: 타임스탬프, 길이, VLAN 등 */
h.h3->tp_sec = ts.tv_sec;
h.h3->tp_nsec = ts.tv_nsec;
h.h3->tp_snaplen = snaplen;
h.h3->tp_len = skb->len;
}
PACKET_FANOUT — 멀티코어 패킷 분산
여러 AF_PACKET 소켓이 동일 인터페이스에서 패킷을 분산 처리할 수 있습니다. suricata, PF_RING 대안으로 사용됩니다:
/* PACKET_FANOUT 모드 */
#define PACKET_FANOUT_HASH 0 /* 흐름 해시 기반 분배 (기본) */
#define PACKET_FANOUT_LB 1 /* 라운드 로빈 */
#define PACKET_FANOUT_CPU 2 /* CPU ID 기반 (RSS 활용) */
#define PACKET_FANOUT_ROLLOVER 3 /* 큐 가득 차면 다음 소켓으로 */
#define PACKET_FANOUT_RND 4 /* 랜덤 분배 */
#define PACKET_FANOUT_QM 5 /* skb 큐 매핑 기반 */
#define PACKET_FANOUT_CBPF 6 /* cBPF 프로그램으로 분배 결정 */
#define PACKET_FANOUT_EBPF 7 /* eBPF 프로그램으로 분배 결정 */
/* 사용 예: 4개 워커 스레드가 흐름 해시 기반으로 패킷 분산 */
int fanout_arg = (PACKET_FANOUT_HASH | PACKET_FANOUT_FLAG_DEFRAG)
| (group_id << 16);
setsockopt(fd, SOL_PACKET, PACKET_FANOUT, &fanout_arg,
sizeof(fanout_arg));
/* 커널 내부: fanout_demux() — 소켓 선택 */
static struct sock *fanout_demux_hash(
struct packet_fanout *f, struct sk_buff *skb, unsigned int num)
{
/* skb 흐름 해시를 소켓 수로 나눠 분배 */
return f->arr[reciprocal_scale(
__skb_get_hash_symmetric(skb), num)];
}
/* PACKET_FANOUT_FLAG 옵션 */
#define PACKET_FANOUT_FLAG_ROLLOVER 0x1000 /* 소켓 백로그 시 롤오버 */
#define PACKET_FANOUT_FLAG_UNIQUEID 0x2000 /* 고유 그룹 ID 자동 할당 */
#define PACKET_FANOUT_FLAG_DEFRAG 0x8000 /* IP 단편화 재조합 후 분배 */
Raw Socket과 Netfilter 관계
Raw socket으로 전송하는 패킷도 Netfilter 체인을 통과합니다. 수신은 프로토콜 핸들러 이전(raw_local_deliver)에 처리되므로 INPUT 체인보다 먼저 clone이 발생합니다:
| 방향 | 소켓 타입 | Netfilter 통과 여부 | 설명 |
|---|---|---|---|
| TX | AF_INET SOCK_RAW | OUTPUT 체인 통과 | raw_send_hdrinc() → NF_HOOK(NF_INET_LOCAL_OUT) |
| TX | AF_PACKET SOCK_RAW | Netfilter 우회 | dev_queue_xmit() 직접 호출 (L3 스택 미통과) |
| RX | AF_INET SOCK_RAW | PREROUTING 이후, INPUT 이전 | NF_INET_PRE_ROUTING 통과 후 raw_local_deliver() |
| RX | AF_PACKET SOCK_RAW | Netfilter 이전에 수신 | netif_receive_skb()에서 ptype 콜백 (L3 이전) |
tcpdump가 DROP된 패킷도 보이는 이유: AF_PACKET 소켓은 netif_receive_skb()의 ptype_all 리스트에 등록되어 Netfilter 이전에 skb 사본을 받습니다. 따라서 iptables/nftables에서 DROP된 패킷도 tcpdump에서 관찰됩니다. 전송 방향도 마찬가지로, AF_PACKET TX는 dev_queue_xmit()을 직접 호출하여 Netfilter OUTPUT 체인을 우회합니다.
Raw Socket의 bind()와 connect()
/* AF_INET SOCK_RAW에서 bind()와 connect()의 역할 */
/* bind() — 수신 필터링: 특정 로컬 IP로의 패킷만 수신 */
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_addr.s_addr = inet_addr("192.168.1.10"),
};
bind(fd, (struct sockaddr *)&addr, sizeof(addr));
/* → raw_v4_match()에서 daddr 매칭에 사용
* → 해당 IP가 목적지인 패킷만 수신 큐에 전달 */
/* connect() — 기본 목적지 설정 + 수신 필터링 */
struct sockaddr_in dest = {
.sin_family = AF_INET,
.sin_addr.s_addr = inet_addr("10.0.0.1"),
};
connect(fd, (struct sockaddr *)&dest, sizeof(dest));
/* → send()에서 목적지 주소 생략 가능 (sendto 대신 send 사용)
* → 수신 시 해당 소스 IP에서 온 패킷만 수신 (소스 필터) */
/* AF_PACKET에서 bind() — 특정 인터페이스에 바인딩 */
struct sockaddr_ll sll = {
.sll_family = AF_PACKET,
.sll_protocol = htons(ETH_P_ALL),
.sll_ifindex = if_nametoindex("eth0"),
};
bind(fd, (struct sockaddr *)&sll, sizeof(sll));
/* → 해당 인터페이스의 패킷만 수신
* → 바인딩 없으면 모든 인터페이스의 패킷 수신 */
실용 예제
/* 예제 1: ICMP Echo Request 전송 (ping 구현) */
int fd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
/* CAP_NET_RAW 필요 */
struct {
struct icmphdr hdr;
char data[56]; /* 페이로드 (타임스탬프 등) */
} pkt;
pkt.hdr.type = ICMP_ECHO;
pkt.hdr.code = 0;
pkt.hdr.un.echo.id = htons(getpid());
pkt.hdr.un.echo.sequence = htons(seq++);
pkt.hdr.checksum = 0;
pkt.hdr.checksum = icmp_checksum(&pkt, sizeof(pkt));
struct sockaddr_in dest = { .sin_family = AF_INET,
.sin_addr.s_addr = inet_addr("8.8.8.8") };
sendto(fd, &pkt, sizeof(pkt), 0,
(struct sockaddr *)&dest, sizeof(dest));
/* 커널이 IP 헤더를 자동 생성 (IP_HDRINCL 미설정이므로)
* → skb 할당 → ICMP 페이로드 복사 → IP 헤더 추가
* → raw_sendmsg() → ip_append_data() → ip_push_pending_frames()
* → Netfilter OUTPUT → ip_output() → dev_queue_xmit() */
/* 수신: raw socket은 모든 ICMP 패킷을 받으므로 필터 설정 */
struct icmp_filter filt;
filt.data = ~(1 << ICMP_ECHOREPLY);
setsockopt(fd, SOL_RAW, ICMP_FILTER, &filt, sizeof(filt));
char buf[1500];
struct sockaddr_in from;
socklen_t fromlen = sizeof(from);
int n = recvfrom(fd, buf, sizeof(buf), 0,
(struct sockaddr *)&from, &fromlen);
/* buf[0..19] = IP 헤더 (raw socket은 항상 IP 헤더 포함 수신)
* buf[20..] = ICMP 헤더 + 페이로드 */
/* 예제 2: ARP Request 전송 (AF_PACKET SOCK_RAW) */
int fd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ARP));
/* 전체 Ethernet 프레임을 직접 구성 */
struct {
struct ethhdr eth; /* Ethernet 헤더 (14 bytes) */
struct arphdr arp; /* ARP 헤더 */
uint8_t ar_sha[6]; /* 송신자 MAC */
uint8_t ar_sip[4]; /* 송신자 IP */
uint8_t ar_tha[6]; /* 대상 MAC (ARP Request에서는 0) */
uint8_t ar_tip[4]; /* 대상 IP */
} frame;
/* Ethernet 헤더: 브로드캐스트 */
memset(frame.eth.h_dest, 0xff, ETH_ALEN); /* FF:FF:FF:FF:FF:FF */
memcpy(frame.eth.h_source, my_mac, ETH_ALEN);
frame.eth.h_proto = htons(ETH_P_ARP);
/* ARP 헤더: ARP Request */
frame.arp.ar_hrd = htons(ARPHRD_ETHER);
frame.arp.ar_pro = htons(ETH_P_IP);
frame.arp.ar_hln = 6;
frame.arp.ar_pln = 4;
frame.arp.ar_op = htons(ARPOP_REQUEST);
/* sockaddr_ll로 출력 인터페이스 지정 */
struct sockaddr_ll sll = {
.sll_ifindex = if_nametoindex("eth0"),
.sll_halen = ETH_ALEN,
};
memset(sll.sll_addr, 0xff, ETH_ALEN);
sendto(fd, &frame, sizeof(frame), 0,
(struct sockaddr *)&sll, sizeof(sll));
/* → packet_sendmsg() → dev_queue_xmit()
* → IP 스택, Netfilter 완전 우회
* → 직접 NIC 드라이버로 전달 */
/* 예제 3: IP_HDRINCL로 커스텀 IP 패킷 전송 */
int fd = socket(AF_INET, SOCK_RAW, IPPROTO_RAW);
/* IPPROTO_RAW (255)는 자동으로 IP_HDRINCL 활성화 */
struct {
struct iphdr ip;
struct udphdr udp;
char payload[64];
} pkt;
/* IP 헤더 구성 */
pkt.ip.version = 4;
pkt.ip.ihl = 5;
pkt.ip.tos = 0;
pkt.ip.tot_len = htons(sizeof(pkt));
pkt.ip.id = 0; /* 커널이 자동 할당 */
pkt.ip.frag_off = htons(IP_DF);
pkt.ip.ttl = 64;
pkt.ip.protocol = IPPROTO_UDP;
pkt.ip.check = 0; /* 커널이 자동 계산 */
pkt.ip.saddr = inet_addr("10.0.0.1");
pkt.ip.daddr = inet_addr("10.0.0.2");
/* UDP 헤더 구성 */
pkt.udp.source = htons(12345);
pkt.udp.dest = htons(53);
pkt.udp.len = htons(sizeof(pkt.udp) + sizeof(pkt.payload));
pkt.udp.check = 0; /* UDP 체크섬은 사용자가 계산해야 함 */
sendto(fd, &pkt, sizeof(pkt), 0, ...);
/* → raw_sendmsg() → inet->hdrincl=1이므로 raw_send_hdrinc()
* → 커널은 check, tot_len(0이면), id(0이면), saddr(0이면)만 보정
* → NF_HOOK(NF_INET_LOCAL_OUT) → dst_output() → dev_queue_xmit() */
보안 고려사항
| 위협 | 관련 소켓 타입 | 방어 메커니즘 |
|---|---|---|
| IP 스푸핑 | AF_INET + IP_HDRINCL | rp_filter (Reverse Path Filtering), BCP 38 (uRPF) |
| ARP 스푸핑 | AF_PACKET SOCK_RAW | DAI (Dynamic ARP Inspection), 정적 ARP 엔트리 |
| 패킷 스니핑 | AF_PACKET (ETH_P_ALL) | CAP_NET_RAW 제한, 네트워크 네임스페이스 격리 |
| 프로토콜 스택 DoS | SOCK_RAW 대량 전송 | net.core.rmem_max, sk->sk_sndbuf 제한 |
| 컨테이너 탈출 | AF_PACKET TPACKET | CAP_NET_RAW 제거, seccomp 필터 |
# CAP_NET_RAW 관련 보안 설정
# 특정 바이너리에만 CAP_NET_RAW 부여 (setuid 대체)
setcap cap_net_raw+ep /usr/bin/ping
# ping socket 허용 범위 설정 (CAP_NET_RAW 불필요)
# GID 0~2147483647 범위의 사용자가 ICMP ping 가능
sysctl -w net.ipv4.ping_group_range="0 2147483647"
# Reverse Path Filtering (IP 스푸핑 방지)
sysctl -w net.ipv4.conf.all.rp_filter=1 # strict mode
sysctl -w net.ipv4.conf.all.rp_filter=2 # loose mode
# 컨테이너에서 CAP_NET_RAW 제거 (Docker)
docker run --cap-drop=NET_RAW ...
# seccomp으로 raw socket 시스콜 차단
# socket(AF_PACKET, ...) 또는 socket(AF_INET, SOCK_RAW, ...) 블록
# 열린 raw socket 확인
ss -w -a # RAW 소켓 목록
cat /proc/net/raw # IPv4 raw socket 상세 정보
cat /proc/net/raw6 # IPv6 raw socket 상세 정보
cat /proc/net/packet # AF_PACKET 소켓 목록
IPPROTO_RAW (255) 특수 동작: socket(AF_INET, SOCK_RAW, IPPROTO_RAW)는 전송 전용 raw socket을 생성합니다. IP_HDRINCL이 자동 활성화되며, 이 소켓으로는 수신이 불가합니다 (recvmsg()가 영원히 블록). 수신하려면 IPPROTO_RAW 대신 구체적인 프로토콜 번호(예: IPPROTO_UDP)를 지정하거나 별도의 수신용 raw socket을 생성해야 합니다.
소켓 디먹싱과 skb 전달
수신된 skb가 올바른 소켓을 찾아가는 과정 (디먹싱):
/* TCP 수신 디먹싱: 4-tuple 해시 → 소켓 lookup */
/* tcp_v4_rcv() 내부 */
struct sock *sk = __inet_lookup_skb(
&tcp_hashinfo, /* TCP 소켓 해시 테이블 */
skb,
__tcp_hdrlen(th),
th->source, /* 소스 포트 */
th->dest, /* 목적지 포트 */
iph->saddr, /* 소스 IP */
iph->daddr, /* 목적지 IP */
sdif);
/* 반환: established 소켓 또는 listen 소켓 */
/* UDP 수신 디먹싱 */
struct sock *sk = __udp4_lib_lookup_skb(skb, uh->source, uh->dest,
udptable);
/* 소켓을 찾은 후 skb를 수신 큐에 전달 */
if (!sock_owned_by_user(sk)) {
/* 소켓이 lock 상태가 아니면 직접 수신 큐에 추가 */
__skb_queue_tail(&sk->sk_receive_queue, skb);
sk->sk_data_ready(sk); /* epoll/poll/select wakeup */
} else {
/* 소켓이 lock 중이면 backlog에 임시 저장 */
__sk_add_backlog(sk, skb);
/* → release_sock() 시 backlog 처리 */
}
성능 팁: SO_REUSEPORT와 BPF_PROG_TYPE_SK_REUSEPORT를 조합하면, 동일 포트를 여러 소켓이 공유하면서 BPF 프로그램으로 skb를 특정 소켓에 스티어링할 수 있습니다. 이는 nginx, envoy 등의 고성능 프록시에서 활용됩니다.
Zero-copy 전송
대용량 데이터를 전송할 때, 사용자 공간 버퍼를 커널로 복사하지 않고 직접 NIC에 전달하여 CPU 사용량과 지연을 줄일 수 있습니다:
| 메커니즘 | 시스템콜 | 동작 방식 |
|---|---|---|
sendfile() | sendfile(out_fd, in_fd, ...) | 파일 → 소켓 직접 전송 (페이지 캐시 → skb frag) |
splice() | splice(fd_in, ..., fd_out, ...) | 파이프 기반 zero-copy, 파일 ↔ 소켓 모두 가능 |
MSG_ZEROCOPY | send(fd, buf, len, MSG_ZEROCOPY) | 사용자 버퍼 → skb frag (완료 통지 필요, 4.14+) |
/* 커널 내부: sendfile의 skb 구성 */
/* 파일 페이지를 skb fragment로 직접 참조 */
skb_fill_page_desc(skb, frag_idx, page, offset, size);
/* page refcount 증가, 복사 없음 */
/* MSG_ZEROCOPY: 사용자 버퍼 페이지를 pin */
/* skb->destructor = sock_zerocopy_callback;
* 전송 완료 시 사용자 공간에 completion notification 전달
* (errqueue에서 SO_EE_ORIGIN_ZEROCOPY 메시지 수신) */
/* 드라이버: skb_page_frag_refill로 페이지 풀 활용 */
struct page_frag_cache *nc = &this_cpu_ptr(&nf_skb_cache)->pf_cache;
if (!skb_page_frag_refill(size, nc, GFP_ATOMIC))
return -ENOMEM;
/* nc->va + nc->offset 에서 size 바이트 사용 가능 */
MSG_ZEROCOPY는 10Gbps 이상 고속 네트워크에서 효과적입니다. 그러나 작은 패킷(~수KB 이하)에서는 페이지 pinning과 completion 통지 오버헤드가 복사 비용보다 클 수 있습니다. Google의 벤치마크에 따르면 5~8% CPU 절감이 일반적입니다.
수신/전송 경로에서의 skb 변형
sk_buff 생애주기 (Lifecycle)
sk_buff는 네트워크 패킷의 전체 생명주기 동안 커널 메모리를 차지하며, 할당부터 해제까지 다양한 변형을 거칩니다. 다음 다이어그램은 수신(RX)과 송신(TX) 경로에서 sk_buff의 생애주기를 보여줍니다.
참조 카운트와 메모리 관리
/* sk_buff의 참조 카운트는 users 필드로 관리 */
struct sk_buff {
atomic_t users; /* 참조 카운트 (skb_get/skb_put으로 증감) */
/* ... */
};
/* 참조 카운트 증가 — 소유권 공유 */
static inline struct sk_buff *skb_get(struct sk_buff *skb)
{
refcount_inc(&skb->users);
return skb;
}
/* 참조 카운트 감소 — 0이 되면 해제 */
static inline void kfree_skb(struct sk_buff *skb)
{
if (!skb) return;
if (refcount_dec_and_test(&skb->users))
__kfree_skb(skb); /* 실제 해제 */
}
/* 정상 소비 (드롭 아님) — 통계 구분 */
static inline void consume_skb(struct sk_buff *skb)
{
if (!skb) return;
if (refcount_dec_and_test(&skb->users))
__kfree_skb(skb);
}
/* 사용 예: 소켓 큐에서 꺼낸 후 해제 */
struct sk_buff *skb = skb_dequeue(&sk->sk_receive_queue);
if (skb) {
process_packet(skb);
consume_skb(skb); /* 정상 소비 */
}
/* 드롭 시 kfree_skb 사용 (디버깅 추적 가능) */
if (!pskb_may_pull(skb, sizeof(struct iphdr))) {
kfree_skb(skb); /* 드롭: perf/dropwatch로 추적됨 */
return -EINVAL;
}
수신/송신 경로에서의 데이터 영역 변화
수신 경로: 각 계층에서 헤더를 제거하며 data 포인터가 앞으로 이동합니다.
| 단계 | 대표 함수 | 버퍼 레이아웃 | data 포인터 위치 |
|---|---|---|---|
| 1. DMA 복사 직후 | NIC RX | headroom | ETH | IP | TCP | DATA | tail |
ETH 시작점 |
| 2. L2 처리 | eth_type_trans() |
headroom | ETH | IP | TCP | DATA | tail |
skb_pull(ETH_HLEN) 후 IP 시작점 |
| 3. L3 처리 | ip_rcv() |
headroom | ETH | IP | TCP | DATA | tail |
skb_pull(ip_hdr_len) 후 TCP 시작점 |
| 4. L4 처리 | tcp_rcv() |
headroom | ETH | IP | TCP | DATA | tail |
payload 시작점 (헤더 제거 완료) |
송신 경로: 각 계층에서 헤더를 추가하며 data 포인터가 뒤로 이동합니다.
| 단계 | 대표 함수 | 버퍼 레이아웃 | data 포인터 이동 |
|---|---|---|---|
| 1. payload 준비 | 소켓 송신 준비 | headroom | PAYLOAD | tail |
payload 시작점 |
| 2. L4 헤더 추가 | tcp_transmit_skb() |
headroom | TCP | PAYLOAD | tail |
skb_push(tcp_hdr_len) |
| 3. L3 헤더 추가 | ip_queue_xmit() |
headroom | IP | TCP | PAYLOAD |
skb_push(ip_hdr_len) |
| 4. L2 헤더 추가 | dev_hard_start_xmit() |
headroom | ETH | IP | TCP | PAYLOAD |
skb_push(ETH_HLEN) 후 NIC DMA 전송 |
skb_realloc_headroom()이 호출되어 새로운 버퍼를 할당합니다. 이는 성능 저하를 유발하므로, 초기 할당 시 충분한 headroom을 확보하는 것이 중요합니다 (NET_SKB_PAD + NET_IP_ALIGN + 예상 헤더 크기).
eth_type_trans() 호출 전후 skb 필드 변화
NIC 드라이버가 eth_type_trans(skb, dev)를 호출하면 다음 필드들이 갱신됩니다:
| 필드 | 호출 전 (DMA 직후) | 호출 후 |
|---|---|---|
skb->mac_header |
미설정 | L2(Ethernet) 헤더 시작 오프셋으로 설정 (skb_reset_mac_header() 수행) |
skb->protocol |
미설정 | EtherType 값 (예: ETH_P_IP, ETH_P_IPV6) — ntohs() 변환 포함 |
skb->data |
L2(ETH) 헤더 시작 | L3 헤더 시작 (skb_pull(ETH_HLEN) 또는 VLAN 포함 크기만큼 당겨짐) |
skb->len |
L2 프레임 전체 크기 | ETH 헤더 제거 후 L3 이상 크기 |
skb->network_header |
미설정 | 미설정 — ip_rcv() 진입 후 skb_reset_network_header()로 설정됨 |
skb->mac_len |
미설정 | 미설정 — L3 처리 진입 시 skb->network_header - skb->mac_header로 초기화됨 |
mac_header가 설정된 뒤에는 eth_hdr(skb)로 Ethernet 헤더 포인터를 얻을 수 있고, skb_mac_header(skb)로 skb->head + skb->mac_header에 해당하는 포인터를 얻습니다. skb->data는 이미 L3 시작으로 옮겨졌으므로 혼동하지 않도록 주의하십시오.
수신/전송 경로 요약
수신 경로 (NIC → 앱):
- NIC 드라이버:
netdev_alloc_skb()로 skb 할당, DMA 데이터 복사 - L2 처리:
skb_pull(ETH_HLEN)으로 Ethernet 헤더 제거 - L3 처리:
skb_pull(ip_hdr_len)으로 IP 헤더 제거, transport_header 설정 - L4 처리: TCP/UDP 디먹싱, 소켓 수신 큐에
skb_queue_tail() - 앱:
recvmsg()에서 데이터를 사용자 공간에 복사
전송 경로 (앱 → NIC):
- 앱:
sendmsg()에서 사용자 데이터를 skb에 복사 - L4:
skb_push()로 TCP/UDP 헤더 추가 - L3:
skb_push()로 IP 헤더 추가, 라우팅 - L2:
skb_push()로 Ethernet 헤더 추가 - NIC 드라이버:
dev_queue_xmit()→ DMA 전송
커널 내 실제 사용 사례
sk_buff는 네트워크 스택의 모든 서브시스템에서 핵심적으로 사용됩니다:
| 서브시스템 | 주요 skb 활용 | 핵심 함수/패턴 |
|---|---|---|
| TCP | 전송 큐, 재전송 큐, OOO 큐에 skb 관리 | tcp_write_xmit(), tcp_retransmit_skb(), TCP_SKB_CB()로 cb[] 활용 |
| UDP | 소켓 수신 큐에 skb 대기열 | udp_rcv(), skb_consume_udp(), MSG_PEEK 처리 |
| Netfilter | 패킷 필터링/수정/NAT | skb_make_writable() 후 헤더 수정, nf_ct_get(skb)로 conntrack |
| Bridge | L2 포워딩, VLAN 처리 | skb_clone()으로 멀티캐스트 복제, skb_vlan_push/pop() |
| Tunnel (GRE, VXLAN) | encapsulation/decapsulation | skb_cow_head()로 headroom 확보, skb_push()로 외부 헤더 추가 |
| TC (Traffic Control) | QoS, 큐잉, 셰이핑 | skb->priority, skb->mark, skb_get_queue_mapping() |
| BPF/XDP | 프로그래밍 가능 패킷 처리 | TC-BPF: __skb_buff 컨텍스트, XDP: skb 이전 단계 (xdp_buff → build_skb) |
| SCTP | 멀티스트리밍, 멀티호밍 | skb_queue_head_init()으로 청크별 큐 관리 |
/* TCP: cb[]를 tcp_skb_cb로 활용하는 패턴 */
struct tcp_skb_cb {
__u32 seq; /* 시작 시퀀스 번호 */
__u32 end_seq; /* 끝 시퀀스 번호 */
__u32 ack_seq; /* ACK 번호 */
__u8 tcp_flags; /* TCP 플래그 */
__u8 sacked; /* SACK 상태 */
/* ... */
};
#define TCP_SKB_CB(__skb) \
((struct tcp_skb_cb *)&((__skb)->cb[0]))
/* 사용 예: TCP 재전송 판단 */
if (after(TCP_SKB_CB(skb)->end_seq, tp->snd_una))
/* 아직 ACK되지 않은 데이터 — 재전송 대상 */
/* Netfilter: 패킷 수정 전 쓰기 가능 확보 */
static unsigned int my_nf_hook(void *priv,
struct sk_buff *skb,
const struct nf_hook_state *state)
{
struct iphdr *iph;
if (skb_ensure_writable(skb, sizeof(*iph)))
return NF_DROP;
iph = ip_hdr(skb);
iph->ttl--; /* 안전하게 수정 가능 */
ip_send_check(iph);
return NF_ACCEPT;
}
성능 튜닝 경험적 팁
커널 네트워크 스택과 NIC 드라이버를 다루면서 얻는 경험적 성능 최적화 팁들입니다:
할당 최적화
- NAPI 컨텍스트에서는
napi_alloc_skb사용: 일반alloc_skb보다 per-CPU 캐시를 활용해Cache hit율 높임. IRQ 컨텍스트에서는 atomic GFP(Get Free Pages) 플래그 필수. - 페이지 프래그먼트 활용: 수신 시 데이터가 크면 linear 버퍼 대신
skb_add_rx_frag로 페이지을 DMA 버퍼에 직접 추가. memcpy를 피하면 대역폭 활용이 크게 향상됨. - headroom 충분하게 확보: 초기 할당 시
NET_SKB_PAD(보통 32바이트) +NET_IP_ALIGN(2 또는 0) + 최대 헤더 크기(예: 100바이트) 확보. 나중에skb_realloc_headroom호출은 심각한 성능 저하 유발.
Clone vs Copy 선택
- 읽기 전용 경로:
skb_clone사용. 데이터 버퍼 공유하므로 memcpy 1회 절약. Netfilter의 MIRROR 타겟, tcpdump가 이 패턴. - 헤더만 수정:
pskb_copy사용. Linear 영역만 복사하고 프래그먼트는 refcount 공유. NAT, 라우팅 변경에서 주로 사용. - Payload 수정 필요:
skb_copy사용. 완전 복사이므로 가장 느리지만 안전. - 경험적 판단: "이 패킷을 두 곳에서 동시에 수정하는가?" → 아니면 clone, 그 외면 copy.
큐 및 스케줄링
- RSS(Receive Side Scaling) 활용: 멀티큐 NIC에서
skb->queue_mapping이 수신 큐 인덱스 저장.irqbalance또는 수동 IRQ affinity 설정으로 각 큐를 다른 CPU에 분산. - softirq 튜닝:
/proc/net/softnet_stat에서 각 CPU의 처리량 확인.net.core.netdev_budget(기본 300)으로 softirq time slice 조절. - NAPI 폴링 시간:
netif_napi_add시napi->weight(기본 64) 값을 조절. 높은 대역폭 지연이 허용되면 값을 크게, 저지연이 중요하면 작게 설정.
Zero-Copy 경로
- sendfile(): 파일 전송 시 가장 효율적. 페이지 캐시 → NIC 직접 경로로 복사 최소화. HTTP 서버 정적 파일 전송에 적합.
- MSG_ZEROCOPY: 대용량 UDP 전송에서 효과적. 10Gbps 이상에서 CPU 절약이 크게 향상됨. 단, TX 완료 대기로 추가 지연이 발생함.
- TPACKET (mbuf): 고성능 캡처에서 필수. mmap으로 커널-사용자 공간 복사를 완전히 제거함.
suricata,tcpdump -i any참조.
하드웨어 offload 활용
- 체크섬 offload 활성화:
ethtool -K eth0 rx-checksumming on tx-checksumming on. 대부분의 modern NIC에서 기본값. - TSO/GSO 활성화:
ethtool -K eth0 tso on gso on. 대용량 TCP 전송 시 극적인 성능 향상. 64KB super-packet이 NIC에서 자동 분할. - GRO 활성화:
ethtool -K eth0 gro on. 수신측 병합으로 수천 PPS에서 CPU 사용량 크게 감소.
# NIC 오프로드 상태 확인
$ ethtool -k eth0 | grep -E "checksum|gso|gro|rss"
tcp-segmentation-offload: on
udp-fragmentation-offload: [fixed]
generic-segmentation-offload: on
generic-receive-offload: on
tcp6-segmentation-offload: on
rx-checksumming: on
tx-checksumming: on
# RSS 설정 확인 및 변경
$ ethtool -l eth0 # 큐 개수 확인
$ ethtool -L eth0 combined 4 # 4개 combined 큐로 설정
# interrupt coalescing 조절 (지연 vs 처리량)
$ ethtool -C eth0 rx-usecs 100 tx-usecs 100 # moderate coalescing
$ ethtool -C eth0 rx-usecs 0 tx-usecs 0 # 낮은 지연 (latency)
GRO off → GRO on으로 변경 시 CPU 사용량이 약 40% 감소했습니다. 하지만 특정 레거시 애플리케이션에서는 GRO로 인한 packet reordering이 문제를 일으킬 수 있어, 프로덕션 변경 전 반드시 테스트 환경에서 검증하세요.
주의사항과 함정 (Common Mistakes)
1. skb leak (메모리 누수)
/* 잘못된 코드: 에러 경로에서 skb 해제 누락 */
static int my_rx(struct sk_buff *skb)
{
struct my_hdr *hdr = (struct my_hdr *)skb->data;
if (hdr->version != MY_VERSION)
return -EINVAL; /* BUG! skb가 해제되지 않음 */
/* ... */
}
/* 올바른 코드 */
static int my_rx(struct sk_buff *skb)
{
struct my_hdr *hdr = (struct my_hdr *)skb->data;
if (hdr->version != MY_VERSION) {
kfree_skb(skb); /* 에러 경로 → kfree_skb (드롭) */
return -EINVAL;
}
/* ... 정상 처리 후 ... */
consume_skb(skb); /* 정상 경로 → consume_skb */
return 0;
}
2. pskb_may_pull 후 포인터 미갱신
/* 잘못된 코드: pull 후 이전 포인터 사용 */
struct iphdr *iph = ip_hdr(skb);
if (!pskb_may_pull(skb, iph->ihl * 4))
goto drop;
/* BUG! pskb_may_pull이 버퍼를 재할당했을 수 있음 → iph는 dangling pointer */
pr_info("saddr: %pI4\\n", &iph->saddr);
/* 올바른 코드: 포인터 재취득 */
if (!pskb_may_pull(skb, sizeof(struct iphdr)))
goto drop;
struct iphdr *iph = ip_hdr(skb); /* pull 후 재취득 */
if (!pskb_may_pull(skb, iph->ihl * 4))
goto drop;
iph = ip_hdr(skb); /* 두 번째 pull 후에도 재취득! */
3. 공유 skb 데이터 수정
/* 잘못된 코드: clone된 skb의 데이터를 바로 수정 */
struct iphdr *iph = ip_hdr(skb);
iph->ttl = 64; /* BUG! skb가 clone 상태면 원본도 수정됨 */
/* 올바른 코드: 쓰기 전 독점 소유 확보 */
if (skb_ensure_writable(skb, skb_network_header_len(skb)))
goto drop;
struct iphdr *iph = ip_hdr(skb); /* 포인터 재취득 */
iph->ttl = 64; /* 이제 안전 */
4. truesize 불일치
/* 잘못된 코드: skb에 페이지를 추가하면서 truesize 미갱신 */
skb_add_rx_frag(skb, idx, page, offset, size, size);
/* 마지막 인자(truesize)가 실제 할당 크기보다 작으면
* → 소켓 메모리 추적(sk_rmem_alloc)이 실제보다 작게 계산됨
* → 소켓이 제한 없이 메모리를 소비 → OOM 가능
*/
/* 올바른 코드: truesize는 실제 할당된 메모리 크기 */
skb_add_rx_frag(skb, idx, page, offset, size, PAGE_SIZE);
/* PAGE_SIZE = 실제 할당 단위 (page order 0 기준) */
5. refcount 이중 해제
/* 잘못된 코드: netif_rx 후 skb를 다시 해제 */
netif_rx(skb); /* 네트워크 스택에 소유권 이전 */
kfree_skb(skb); /* BUG! 이중 해제 → use-after-free */
/* 올바른 패턴: 전달 후 skb를 사용하지 않음 */
netif_rx(skb); /* 소유권 이전, skb는 더 이상 사용하지 않음 */
/* netif_rx(), netif_receive_skb(), napi_gro_receive() 등은
* skb의 소유권을 가져감 — 이후 skb 접근 금지 */
6. headroom 부족으로 인한 skb_under_panic
/* 잘못된 코드: headroom 확인 없이 헤더 추가 */
skb_push(skb, sizeof(struct my_encap_hdr));
/* headroom이 부족하면 skb_under_panic → 커널 panic */
/* 올바른 코드: headroom 확보 후 추가 */
int needed = sizeof(struct my_encap_hdr) + LL_RESERVED_SPACE(dev);
if (skb_cow_head(skb, needed)) {
kfree_skb(skb);
return NETDEV_TX_OK;
}
skb_push(skb, sizeof(struct my_encap_hdr)); /* 이제 안전 */
7. 레이어 경계 처리 실수
RX → 포워딩 → TX 경로를 거치는 패킷은 mac_header, network_header 포인터의 유효성이 경계마다 달라집니다. 이를 무시하면 잘못된 헤더 접근이나 커널 BUG를 유발합니다.
RX → 포워딩: mac_header 갭 문제
/* 수신 후 포워딩 경로에서 mac_header와 network_header 사이에
* "갭(hole)"이 생길 수 있음:
* eth_type_trans() → data를 L3 시작으로 당김
* ip_rcv() → network_header = data 위치로 재설정
* → mac_header는 그대로이므로 mac_header < network_header
* (갭 = Ethernet 헤더 크기)
*
* 포워딩 후 재전송 시 L2 헤더 재구성이 필요하면:
*/
skb_mac_header_rebuild(skb); /* mac_header를 network_header 바로 앞으로 재정렬 */
포워딩 → TX: mac_header 무효화
/* IPSec/GRE 등 캡슐화 후 원래 mac_header가 무효화될 수 있음.
* 헤더 재구성 전에 반드시 유효성 확인: */
if (skb_mac_header_was_set(skb)) {
/* mac_header가 유효할 때만 eth_hdr(skb) 접근 */
struct ethhdr *eth = eth_hdr(skb);
/* ... */
}
/* 캡슐화로 mac_header가 갱신되지 않은 경우 직접 재설정 */
skb_reset_mac_header(skb); /* mac_header = data - head */
IPSec: headroom 확보 및 network_header 재설정
/* IPSec 암호화(outbound) 전: ESP/AH 헤더를 위한 headroom 확보 */
int head_delta = skb_cow_head(skb, esp_hdr_len + LL_RESERVED_SPACE(dst->dev));
if (head_delta)
goto error;
skb_push(skb, esp_hdr_len); /* ESP 헤더 공간 확보 */
skb_reset_network_header(skb); /* IP 헤더 위치 재설정 */
/* IPSec 복호화(inbound) 후: network_header가 바뀌었으므로 재설정 필수 */
skb_pull(skb, esp_hdr_len); /* ESP 헤더 제거 */
skb_reset_network_header(skb); /* 복호화된 IP 헤더로 포인터 재설정 */
network_header, transport_header, mac_header를 반드시 재설정하십시오. 포인터를 재설정하지 않으면 ip_hdr(skb), tcp_hdr(skb) 등이 잘못된 주소를 반환하여 조용한 메모리 손상이 발생합니다.
실제 트러블슈팅 사례
네트워크 문제를 분석하면서 자주 마주하는 skb 관련 실제 케이스들입니다:
사례 1: 메모리 누수가 의심될 때
증상: ss -s나 /proc/net/sockstat에서 사용 중인 소켓 수가 비정상적으로 많거나, 시스템 메모리가 점진적으로 감소합니다.
# 현재 소켓 상태 확인
$ ss -s
$ cat /proc/net/sockstat
# orphan(소멸된) 소켓 수 — TIME_WAIT 소켓이 정리되지 않으면 증가
$ cat /proc/net/sockstat | grep TCP
# 드롭된 패킷 수 확인
$ nstat -az TcpExt.ListenOverflows
$ cat /proc/net/netstat | grep -E "Tcp|Ext" | column -t
원인: 에러 경로에서 kfree_skb() 호출 누락, 또는 consume_skb() 대신 kfree_skb()를 사용해서 메모리 참조가 해제되지 않음.
사례 2: 체크섬 검증 실패
증상: 특정 NIC에서만 TCP/UDP 체크섬 오류가 발생하거나, 애플리케이션에서 "bad checksum" 로그가 반복됩니다.
# NIC 드라이버와 체크섬 오프로드 상태 확인
$ ethtool -k eth0 | grep checksum
rx-checksumming: on
tx-checksumming: on
# 드라이버 메시지 확인 (dmesg)
$ dmesg | grep -i "eth0\|ixgbe\|mlx5"
[12345.678] ixgbe 0000:01:00.0: ixgbe_check_bad_counter: Detected bad TCP checksum,
but feature turned on — actual problem may exist
# 테스트: 체크섬 offload 비활성화
$ ethtool -K eth0 rx-checksumming off tx-checksumming off
원인: 일부 저가형 또는 legacy NIC에서 HW 체크섬 계산이 부정확한 경우 (false positive). 커널 버그로 인해 특정 드라이버에서만 발생.
사례 3: GRO로 인한 TCP 재전송 증가
증상: GRO 활성화 후 TCP 재전송이 증가하거나, 특정 애플리케이션에서 패킷 순서 오류 발생.
# GRO 상태 확인
$ ethtool -k eth0 | grep generic-receive-offload
# TCP 재전송 통계 확인
$ nstat -az TcpRetransSegs
$ cat /proc/net/snmp | grep -E "Retrans|OutSegs"
# 문제 구간 확인 — 서버-클라이언트 양쪽에서 GRO 상태 맞춰야 함
$ ethtool -K eth0 gro off # 테스트를 위해 off
$ iperf3 -c 10.0.0.1 -P 4 # 대역폭 재테스트
원인: GRO가 다른 흐름의 패킷을 잘못 병합하거나, NIC HW GRO의 구현 버그. 특히 가상화 환경(virtio, VM에서) 자주 발생.
사례 4: 프래그먼트된 대용량 패킷 처리 지연
증상: 대용량 파일 전송 시 예상보다 낮은 throughput, 또는 특정 크기(예: 64KB 근처)에서 throughput 급격 감소.
# skb_linearize 빈도 확인 — linearization은 비용이 큼
$ cat /proc/net/netstat | grep SkbConcatenate
TcpSmbConcatenate: 12345
# GSO/TSO 상태 확인
$ ethtool -k eth0 | grep -E "segmentation|offload"
# 수신측 gro_flush_timeout 확인 (지연 병합)
$ sysctl net.core.gro_flush_timeout
net.core.gro_flush_timeout = 2000
원인: NIC이 TSO를 지원하지 않으면 커널에서 software GSO가 linearize를 유발하거나, 수신 측 GRO가 타임아웃까지 대기를 위해 지연 발생.
사례 5: NAPI 기아 상태 (starvation)
증상: 고대역폭 트래픽에서 일부 CPU만 max softirq time에 도달하고, 다른 CPU는 유휴 상태. 드롭이 특정 CPU에서 집중됨.
# CPU별 softirq 처리량 확인
$ cat /proc/net/softnet_stat | awk '{print $1, $2, $3, $4}' | head -20
# 컬럼: cpu_id, processed, dropped, time_squeeze
# IRQ affinity 확인
$ cat /proc/interrupts | grep eth0
$ cat /proc/irq/<irq_num>/smp_affinity
# NAPI 가중치 확인
$ ls /sys/class/net/eth0/napi
$ cat /sys/class/net/eth0/napi/<napi_id>/poll_time
원인: IRQ가 단일 CPU에 집중되거나, NAPI weight가 너무 작아서 time slice 내에 처리를 못 함. RSS 설정과 IRQ balancing 문제.
디버깅 기법
tracepoint 활용
# skb 드롭 추적 (kfree_skb 호출 위치와 원인)
$ perf trace -e skb:kfree_skb --call-graph dwarf -a sleep 5
# skb 드롭 실시간 모니터링
$ cat /sys/kernel/debug/tracing/events/skb/kfree_skb/format
$ echo 1 > /sys/kernel/debug/tracing/events/skb/kfree_skb/enable
$ cat /sys/kernel/debug/tracing/trace_pipe
# dropwatch (커널 6.x+: kfree_skb_reason으로 드롭 원인 표시)
$ dropwatch -l kas
> start
perf probe로 동적 추적
# 특정 함수에서 skb->len 값 추적
$ perf probe --add 'tcp_v4_rcv skb->len skb->data_len'
$ perf record -e probe:tcp_v4_rcv -a sleep 10
$ perf script
# skb 할당 빈도 측정
$ perf stat -e 'skb:*' -a sleep 10
/proc/net 진단
# 소켓 메모리 사용량 확인 (skb leak 의심 시)
$ cat /proc/net/sockstat
sockets: used 1234
TCP: inuse 56 orphan 2 tw 128 alloc 60 mem 1024
UDP: inuse 12 mem 256
# mem: 페이지 단위 (mem * PAGE_SIZE = 실제 바이트)
# orphan: 소속 프로세스 없는 TCP 소켓 (skb leak 원인 가능)
# tw: TIME_WAIT 상태 (정상적이지만 과다하면 문제)
# 네트워크 스택 통계 (드롭/에러 확인)
$ nstat -az | grep -i drop
$ cat /proc/net/softnet_stat
디버깅 커널 옵션
| 옵션 | 기능 |
|---|---|
CONFIG_DEBUG_KMEMLEAK | skb를 포함한 커널 메모리 누수 탐지 |
CONFIG_KASAN | use-after-free, out-of-bounds 접근 탐지 |
CONFIG_NET_DROP_MONITOR | 네트워크 패킷 드롭 위치 추적 |
CONFIG_DEBUG_NET | 네트워크 스택 디버깅 assertion 활성화 |
CONFIG_SKB_EXTENSIONS | skb extension (conntrack, bridge 등) 디버깅 |
커널 버전별 변경사항
| 버전 | 변경 내용 |
|---|---|
| 3.18 | skb_frag_off() 접근자 도입 (직접 필드 접근 대체) |
| 4.14 | MSG_ZEROCOPY 소켓 옵션 도입 |
| 4.18 | UDP GSO (SKB_GSO_UDP_L4) 지원 |
| 5.0 | XDP에서 skb 모드 (XDP_FLAGS_SKB_MODE) 공식 지원 |
| 5.3 | skb_ensure_writable() 도입 (skb_make_writable 대체) |
| 5.17 | page_pool 기반 skb 할당 최적화 |
| 6.0 | kfree_skb_reason() 도입 — 드롭 원인 추적 개선 |
| 6.2 | skb->csum_level 필드로 중첩 체크섬 오프로드 지원 |
| 6.8 | netmem 기반 skb frag 관리 (page → netmem 추상화) |
참고 자료: skbuff.h (Bootlin), O'Reilly "Understanding Linux Network Internals" Ch. 2, LWN의 skb lifecycle 시리즈, Documentation/networking/skbuff.rst
skb 확장 (skb_ext)
커널 5.x부터 sk_buff에 가변 메타데이터를 동적으로 연결하는 skb_ext 메커니즘이 도입되었습니다. 이전에는 conntrack 포인터, IPsec secpath 등이 skb 내부에 직접 포함되어 항상 메모리를 차지했으나, skb_ext를 통해 필요할 때만 할당되어 메모리 효율이 크게 개선되었습니다.
/* include/linux/skbuff.h — skb_ext 구조체 */
struct skb_ext {
refcount_t refcnt; /* 참조 카운트 (clone 시 공유) */
u8 offset[SKB_EXT_NUM]; /* 각 확장의 data[] 내 오프셋 */
u8 chunks; /* 할당된 청크 수 (64B 단위) */
char data[]; /* 가변 길이 확장 데이터 */
};
/* 확장 타입 열거형 */
enum skb_ext_id {
SKB_EXT_BRIDGE_NF, /* br_netfilter 상태 (struct nf_bridge_info) */
SKB_EXT_SEC_PATH, /* IPsec 보안 경로 (struct sec_path) */
SKB_EXT_MPTCP, /* MPTCP 옵션 (struct mptcp_ext) */
TC_SKB_EXT, /* TC cls_act 메타데이터 (struct tc_skb_ext) */
SKB_EXT_NUM /* 총 확장 타입 수 */
};
/* skb_ext 추가 — 해당 타입의 확장 공간을 할당하고 포인터 반환 */
void *skb_ext_add(struct sk_buff *skb, enum skb_ext_id id)
{
struct skb_ext *new;
/* active_extensions 비트맵에 id 설정 */
skb->active_extensions |= 1 << id;
/* 확장 공간 할당 또는 기존 공간에서 오프셋 반환 */
return skb->extensions->data + skb->extensions->offset[id];
}
/* skb_ext 조회 — 해당 확장이 있으면 포인터, 없으면 NULL */
static inline void *skb_ext_find(const struct sk_buff *skb,
enum skb_ext_id id)
{
if (skb->active_extensions & (1 << id))
return skb->extensions->data + skb->extensions->offset[id];
return NULL;
}
/* Netfilter conntrack 연결: nf_ct_get()으로 conntrack 참조 */
static inline struct nf_conn *nf_ct_get(
const struct sk_buff *skb,
enum ip_conntrack_info *ctinfo)
{
/* skb->_nfct에서 conntrack 포인터와 상태 정보 추출 */
unsigned long nfct = skb->_nfct;
*ctinfo = nfct & NFCT_INFOMASK;
return (struct nf_conn *)(nfct & NFCT_PTRMASK);
}
/* TC 확장: cls_act에서 skb에 메타데이터 연결 */
struct tc_skb_ext {
__u32 chain; /* TC 체인 번호 */
__u16 mru; /* TC Maximum Receive Unit */
__u16 zone; /* conntrack zone */
u8 post_ct:1; /* CT action 이후 여부 */
u8 post_ct_snat:1;
u8 post_ct_dnat:1;
};
/* TC에서 skb_ext 사용 예 */
struct tc_skb_ext *ext = skb_ext_add(skb, TC_SKB_EXT);
if (ext)
ext->chain = chain_index;
| 확장 타입 | 구조체 | 크기 (약) | 사용 서브시스템 |
|---|---|---|---|
SKB_EXT_SEC_PATH | sec_path | ~40B | IPsec/xfrm — SA 참조 배열 |
SKB_EXT_BRIDGE_NF | nf_bridge_info | ~48B | br_netfilter — 원본 포트/MAC 보존 |
TC_SKB_EXT | tc_skb_ext | ~12B | TC cls_act — 체인/zone/CT 메타 |
SKB_EXT_MPTCP | mptcp_ext | ~24B | MPTCP — DSS/DSN 매핑 |
성능 영향: skb_ext 도입 전, struct sec_path와 struct nf_bridge_info는 skb 내에 항상 포인터를 차지했습니다 (각 8바이트). IPsec이나 bridge를 사용하지 않는 대다수 패킷에서 이 공간이 낭비되었습니다. skb_ext 전환 후 sizeof(struct sk_buff)가 약 16바이트 줄어들었고, 이는 수백만 동시 패킷을 처리하는 환경에서 상당한 메모리 절감입니다.
page_pool 기반 고성능 할당
커널 5.17+에서 도입된 page_pool은 네트워크 드라이버의 skb 데이터 버퍼 할당을 혁신적으로 개선합니다. DMA 매핑을 캐시하고, 해제된 페이지를 재활용하며, bulk 할당으로 lock contention을 최소화합니다. mlx5, ice, i40e, bnxt 등 주요 고성능 드라이버가 page_pool을 사용합니다.
/* include/net/page_pool/types.h — page_pool 생성 파라미터 */
struct page_pool_params {
unsigned int flags; /* PP_FLAG_DMA_MAP | PP_FLAG_DMA_SYNC_DEV */
unsigned int order; /* page order (0 = 4KB, 1 = 8KB) */
unsigned int pool_size; /* ring 크기 (기본 1024) */
int nid; /* NUMA 노드 (-1 = 현재 노드) */
struct device *dev; /* DMA 매핑 대상 디바이스 */
enum dma_data_direction dma_dir; /* DMA_FROM_DEVICE (RX) */
unsigned int max_len; /* 최대 데이터 길이 */
unsigned int offset; /* 데이터 시작 오프셋 */
};
/* 드라이버 초기화: page_pool 생성 */
struct page_pool_params pp_params = {
.flags = PP_FLAG_DMA_MAP | PP_FLAG_DMA_SYNC_DEV,
.order = 0, /* 4KB 페이지 */
.pool_size = 1024,
.nid = dev_to_node(dev),
.dev = &pdev->dev,
.dma_dir = DMA_FROM_DEVICE,
.max_len = PAGE_SIZE,
.offset = XDP_PACKET_HEADROOM,
};
struct page_pool *pool = page_pool_create(&pp_params);
/* 수신 경로: page_pool에서 페이지 할당 */
struct page *page = page_pool_dev_alloc_pages(pool);
/* → alloc.cache[]에서 O(1) 반환 (캐시 히트)
* → 캐시 비면 ring.queue에서 bulk refill
* → ring도 비면 buddy allocator + DMA 매핑 */
dma_addr_t dma = page_pool_get_dma_addr(page);
/* DMA 주소가 이미 캐시됨 — dma_map_page() 호출 불필요! */
/* skb 생성 후 page_pool 페이지를 frag로 추가 */
struct sk_buff *skb = napi_alloc_skb(napi, 256);
skb_add_rx_frag(skb, 0, page, offset, len, PAGE_SIZE);
skb_mark_for_recycle(skb); /* 해제 시 page_pool로 반환 */
/* skb 해제 시: page_pool_put_page()로 자동 재활용 */
/* consume_skb(skb) → skb_free_frag() → page_pool_put_page()
* → alloc.cache[]에 반환 (fast path)
* → 또는 ring.queue에 반환 (다른 CPU에서 해제 시) */
| 비교 항목 | 기존 (alloc_page + dma_map) | page_pool |
|---|---|---|
| 페이지 할당 | 매번 buddy allocator 호출 | per-CPU 캐시에서 O(1) |
| DMA 매핑 | 매번 dma_map_page() | 캐시된 DMA 주소 재사용 |
| 해제 | dma_unmap + put_page() | 캐시에 반환 (unmap 생략) |
| NUMA 인식 | 수동 관리 필요 | nid 파라미터로 자동 |
| XDP 호환 | 직접 구현 필요 | 내장 XDP headroom 지원 |
| bulk 할당 | 지원 안 함 | page_pool_alloc_pages_batch() |
page_pool 통계 확인: /sys/kernel/debug/page_pool/에서 각 풀의 할당/재활용/실패 통계를 확인할 수 있습니다. ethtool -S eth0 | grep page_pool로 드라이버별 통계도 확인 가능합니다. 재활용율이 90% 이하면 ring 크기 증가 또는 NUMA 문제를 점검하세요.
XDP와 sk_buff 인터페이스
XDP(eXpress Data Path)는 sk_buff 할당 이전 단계에서 패킷을 처리하는 고성능 프레임워크입니다. NIC 드라이버 내부에서 xdp_buff라는 경량 구조체로 패킷을 표현하며, XDP 프로그램의 판정(verdict)에 따라 패킷을 드롭/포워딩하거나 일반 네트워크 스택으로 전달합니다.
/* include/net/xdp.h — xdp_buff 구조체 (sk_buff보다 훨씬 경량) */
struct xdp_buff {
void *data; /* 패킷 데이터 시작 (L2) */
void *data_end; /* 패킷 데이터 끝 */
void *data_meta; /* 메타데이터 시작 (data 앞) */
void *data_hard_start;/* 버퍼 절대 시작 */
struct xdp_rxq_info *rxq; /* RX 큐 정보 */
struct xdp_txq_info *txq; /* TX 큐 정보 */
u32 frame_sz; /* 전체 프레임 크기 */
u32 flags; /* XDP_FLAGS_* */
};
/* xdp_buff → sk_buff 변환 (XDP_PASS 시) */
struct sk_buff *xdp_build_skb_from_buff(struct xdp_buff *xdp)
{
unsigned int headroom = xdp->data - xdp->data_hard_start;
unsigned int data_len = xdp->data_end - xdp->data;
struct sk_buff *skb;
/* build_skb: 기존 버퍼에 sk_buff 메타데이터만 생성 */
skb = build_skb(xdp->data_hard_start, xdp->frame_sz);
if (!skb)
return NULL;
skb_reserve(skb, headroom);
__skb_put(skb, data_len);
/* XDP 메타데이터가 있으면 skb에 전달 */
if (xdp->data_meta != xdp->data) {
int metasize = xdp->data - xdp->data_meta;
skb_metadata_set(skb, metasize);
}
return skb;
}
/* XDP BPF 프로그램 예: 특정 포트 패킷만 PASS, 나머지 DROP */
SEC("xdp")
int xdp_filter(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
if (data + sizeof(*eth) > data_end)
return XDP_DROP;
if (eth->h_proto != bpf_htons(ETH_P_IP))
return XDP_PASS; /* IP 아니면 일반 스택으로 */
struct iphdr *ip = data + sizeof(*eth);
if ((void *)(ip + 1) > data_end)
return XDP_DROP;
if (ip->protocol == IPPROTO_TCP) {
struct tcphdr *tcp = (void *)ip + (ip->ihl * 4);
if ((void *)(tcp + 1) > data_end)
return XDP_DROP;
if (tcp->dest == bpf_htons(80))
return XDP_PASS; /* HTTP → sk_buff 생성 → 스택 진입 */
}
return XDP_DROP; /* sk_buff 할당 없이 즉시 드롭 */
}
/* XDP 메타데이터: xdp_buff → sk_buff 전달
* BPF 프로그램이 data_meta 영역에 커스텀 메타데이터 기록 가능 */
SEC("xdp")
int xdp_with_meta(struct xdp_md *ctx)
{
/* 메타데이터 영역 확보 (data 앞으로 4바이트) */
if (bpf_xdp_adjust_meta(ctx, -4))
return XDP_PASS;
__u32 *meta = (void *)(long)ctx->data_meta;
if ((void *)(meta + 1) > (void *)(long)ctx->data)
return XDP_PASS;
*meta = 0xCAFE; /* 커스텀 마크 */
return XDP_PASS;
/* → sk_buff 변환 후 skb_metadata_len(skb) == 4
* → TC BPF에서 __sk_buff->data_meta로 접근 가능 */
}
| XDP 액션 | sk_buff 할당 | 동작 | 성능 |
|---|---|---|---|
XDP_DROP | 안 함 | 패킷 즉시 드롭, 페이지 반환 | ~24Mpps (64B) |
XDP_TX | 안 함 | 같은 NIC으로 즉시 재전송 | ~14Mpps |
XDP_REDIRECT | 안 함 | 다른 NIC/CPU/AF_XDP로 전달 | ~12Mpps |
XDP_PASS | 생성 | build_skb() → 일반 스택 | 일반 스택 수준 |
XDP_ABORTED | 안 함 | 에러 발생, tracepoint 기록 | — |
XDP Generic vs Native: XDP_FLAGS_SKB_MODE(Generic)는 sk_buff가 이미 할당된 후 XDP 프로그램을 실행합니다. 따라서 XDP_DROP해도 skb 할당 비용이 발생하며, 성능 이점이 크게 감소합니다. 진정한 고성능을 위해서는 드라이버가 네이티브 XDP를 지원해야 합니다 (XDP_FLAGS_DRV_MODE). ethtool -i eth0으로 드라이버를 확인하고, ip link set dev eth0 xdp obj prog.o으로 로드합니다.
패킷 타임스탬핑 (SO_TIMESTAMPING)
정밀한 네트워크 지연 측정, PTP(Precision Time Protocol) 동기화, 금융 거래 시스템 등에서 패킷의 정확한 송수신 시각이 필요합니다. Linux는 SO_TIMESTAMPING 소켓 옵션으로 하드웨어 타임스탬프(NIC PHY 수준)부터 소프트웨어 타임스탬프(커널 네트워크 스택)까지 다양한 수준의 타임스탬핑을 지원하며, 이 정보는 sk_buff를 통해 전달됩니다.
/* include/linux/skbuff.h — 타임스탬프 관련 구조체 */
struct skb_shared_hwtstamps {
union {
ktime_t hwtstamp; /* HW 타임스탬프 (NIC PHY) */
void *netdev_data; /* 드라이버별 데이터 */
};
};
/* skb에서 HW 타임스탬프 접근 */
static inline struct skb_shared_hwtstamps *skb_hwtstamps(
struct sk_buff *skb)
{
return &skb_shinfo(skb)->hwtstamps;
}
/* NIC 드라이버: 수신 시 HW 타임스탬프 기록 */
static void my_nic_rx_hwtstamp(struct sk_buff *skb,
u64 hw_ns)
{
struct skb_shared_hwtstamps *hwts = skb_hwtstamps(skb);
hwts->hwtstamp = ns_to_ktime(hw_ns);
/* → recvmsg()에서 SOF_TIMESTAMPING_RAW_HARDWARE cmsg로 전달 */
}
/* 사용자 공간: SO_TIMESTAMPING 설정 */
int flags = SOF_TIMESTAMPING_RX_HARDWARE /* 수신 HW 타임스탬프 */
| SOF_TIMESTAMPING_TX_HARDWARE /* 전송 HW 타임스탬프 */
| SOF_TIMESTAMPING_RAW_HARDWARE /* 원시 HW 시각 (PTP 클럭) */
| SOF_TIMESTAMPING_SOFTWARE /* SW 타임스탬프 */
| SOF_TIMESTAMPING_OPT_TSONLY; /* 타임스탬프만 (페이로드 생략) */
setsockopt(fd, SOL_SOCKET, SO_TIMESTAMPING, &flags, sizeof(flags));
/* recvmsg()로 타임스탬프 수신 */
struct msghdr msg = { .msg_control = cbuf, .msg_controllen = sizeof(cbuf) };
recvmsg(fd, &msg, 0);
/* cmsg에서 타임스탬프 추출 */
struct cmsghdr *cm;
for (cm = CMSG_FIRSTHDR(&msg); cm; cm = CMSG_NXTHDR(&msg, cm)) {
if (cm->cmsg_level == SOL_SOCKET &&
cm->cmsg_type == SO_TIMESTAMPING) {
struct timespec *ts = (struct timespec *)CMSG_DATA(cm);
/* ts[0] = SW 타임스탬프 (SOF_TIMESTAMPING_SOFTWARE)
* ts[1] = 예약 (사용 안 함)
* ts[2] = HW 타임스탬프 (SOF_TIMESTAMPING_RAW_HARDWARE) */
printf("HW: %ld.%09ld\n", ts[2].tv_sec, ts[2].tv_nsec);
}
}
/* TX 타임스탬프: 전송 완료 시 errqueue에서 수신 */
recvmsg(fd, &msg, MSG_ERRQUEUE);
/* → SOF_TIMESTAMPING_TX_HARDWARE cmsg에 전송 시각 포함
* → NIC 드라이버가 TX 완료 인터럽트에서 HW 타임스탬프 기록 */
| 타임스탬프 종류 | 정밀도 | 지연 소스 | 용도 |
|---|---|---|---|
| HW 타임스탬프 (PHY) | ~1ns | NIC PTP 클럭 | PTP 동기화, 금융 트레이딩 |
| SW 타임스탬프 (커널) | ~1μs | ktime_get_real() (softirq) | 일반 지연 측정, tcpdump |
| TX SCHED | ~1μs | qdisc 진입 시점 | 큐잉 지연 측정 |
| TX ACK (TCP) | ~1μs | ACK 수신 시점 | RTT 측정 |
PTP 하드웨어 지원 확인: ethtool -T eth0으로 NIC의 HW 타임스탬핑 지원 여부를 확인합니다. hardware-transmit/hardware-receive/hardware-raw-clock이 표시되면 HW 타임스탬프를 사용할 수 있습니다. Intel i210/i225, Mellanox ConnectX-4+, Broadcom BCM57416 등이 대표적인 PTP 지원 NIC입니다.
NAPI와 GRO 상세 흐름
NAPI(New API)는 인터럽트와 폴링을 결합하여 고속 패킷 수신을 효율적으로 처리합니다. GRO(Generic Receive Offload)는 NAPI poll 내부에서 동일 플로우의 패킷들을 하나의 큰 sk_buff로 병합하여 프로토콜 스택 처리 오버헤드를 줄입니다. 이 두 메커니즘은 현대 Linux 네트워크 성능의 핵심 축입니다.
/* include/linux/netdevice.h — NAPI 구조체 */
struct napi_struct {
struct list_head poll_list; /* softirq 폴링 리스트 */
unsigned long state; /* NAPI_STATE_SCHED 등 */
int weight; /* 한 번 poll에서 처리할 최대 패킷 수 (기본 64) */
int defer_hard_irqs_count;
unsigned long gro_bitmask; /* GRO 활성 프로토콜 비트맵 */
int (*poll)(struct napi_struct *, int); /* 드라이버 poll 함수 */
struct list_head rx_list; /* GRO 병합 완료 skb 리스트 */
int rx_count; /* rx_list 내 skb 수 */
struct gro_list gro_hash[GRO_HASH_BUCKETS]; /* GRO 해시 테이블 */
};
/* 드라이버 poll 함수 패턴 */
static int my_driver_poll(struct napi_struct *napi, int budget)
{
int work_done = 0;
while (work_done < budget) {
struct sk_buff *skb = my_rx_one(napi);
if (!skb)
break;
/* GRO에 전달: 동일 플로우 병합 시도 */
napi_gro_receive(napi, skb);
work_done++;
}
if (work_done < budget) {
/* budget을 다 쓰지 않음 → 패킷이 없음 → IRQ 재활성화 */
napi_complete_done(napi, work_done);
/* → gro_list flush → IRQ unmask */
}
/* budget 소진 → softirq가 다시 poll 호출 예정 */
return work_done;
}
/* GRO 내부: 병합 판단 로직 (net/core/gro.c) */
static enum gro_result dev_gro_receive(struct napi_struct *napi,
struct sk_buff *skb)
{
/* 1. 프로토콜별 GRO 콜백 호출 (inet_gro_receive 등) */
/* 2. gro_hash[]에서 동일 플로우 검색 (5-tuple 매칭) */
/* 3. 매칭 결과에 따라: */
if (same_flow && !flush) {
/* GRO_MERGED: 기존 skb에 새 패킷을 frag로 추가
* → skb_shinfo(p)->frag_list에 연결
* → p->len += skb->len (super-packet 크기 증가)
* → NAPI_GRO_CB(p)->count++ */
return GRO_MERGED;
}
if (same_flow && flush) {
/* 플로우는 같지만 병합 불가 (PSH 플래그, 순서 불일치 등)
* → 기존 skb를 flush하고 새 skb를 gro_hash에 등록 */
napi_gro_complete(napi, pp);
}
/* GRO_HELD: 새 플로우 → gro_hash에 등록하고 대기
* → 같은 플로우의 후속 패킷이 올 때까지 보류 */
list_add(&skb->list, &napi->gro_hash[hash].list);
return GRO_HELD;
}
/* GRO 병합 완료 → 일반 스택으로 전달 */
static void napi_gro_complete(struct napi_struct *napi,
struct sk_buff *skb)
{
/* 프로토콜별 GRO complete 콜백 */
/* → TCP: tcp_gro_complete() — 헤더 보정, 체크섬 설정
* → skb->ip_summed = CHECKSUM_UNNECESSARY (병합된 패킷) */
/* gro_normal_one(): rx_list에 추가 */
gro_normal_one(napi, skb, NAPI_GRO_CB(skb)->count);
/* → rx_count >= gro_normal_batch(8) 이면 배치 전달:
* gro_normal_list() → netif_receive_skb_list()
* → 한 번의 함수 호출로 여러 skb를 스택에 전달 */
}
| GRO 파라미터 | 기본값 | 조절 방법 | 영향 |
|---|---|---|---|
| NAPI weight | 64 | netif_napi_add(dev, napi, poll, weight) | poll당 처리 패킷 수. 높으면 throughput↑, latency↑ |
| gro_flush_timeout | 0 (즉시) | sysctl net.core.gro_flush_timeout | 0이 아니면 타이머로 flush → 병합 기회 증가 |
| gro_normal_batch | 8 | sysctl net.core.gro_normal_batch | 배치 전달 크기. 높으면 처리량↑, 지연↑ |
| netdev_budget | 300 | sysctl net.core.netdev_budget | softirq당 전체 NAPI 처리 패킷 상한 |
| busy_poll | 0 (off) | sysctl net.core.busy_poll | 소켓별 busy polling 시간 (μs) |
# GRO 병합 효과 확인
$ ethtool -S eth0 | grep gro
rx_gro_packets: 1234567 # GRO 처리된 패킷 수
rx_gro_bytes: 987654321 # GRO 처리된 바이트
# NAPI 통계 확인
$ cat /proc/net/softnet_stat
# 컬럼: processed, dropped, time_squeeze, ..., cpu_collision, received_rps, flow_limit_count
# time_squeeze > 0: budget/time 부족으로 처리 중단 → netdev_budget 증가 고려
# Busy polling 활성화 (저지연 용도)
$ sysctl -w net.core.busy_poll=50 # 50μs 폴링
$ sysctl -w net.core.busy_read=50 # 읽기 시 50μs 폴링
# GRO flush 타임아웃 설정 (병합 기회 증가)
$ sysctl -w net.core.gro_flush_timeout=20000 # 20μs
# per-NAPI 설정 (커널 6.x+)
$ echo 100 > /sys/class/net/eth0/napi/0/gro_flush_timeout
$ echo 16 > /sys/class/net/eth0/napi/0/defer_hard_irqs
GRO와 Netfilter 상호작용: GRO로 병합된 super-packet은 skb->len이 64KB에 달할 수 있습니다. 이 상태로 Netfilter를 통과하면 conntrack 등이 정상 동작하지만, iptables -m length 같은 패킷 길이 기반 규칙은 예상과 다르게 동작할 수 있습니다. 필요시 ethtool -K eth0 gro off로 비활성화하거나, nftables의 @th 표현식으로 개별 세그먼트 길이를 확인하세요.
GSO/TSO 분할 메커니즘 상세
GSO(Generic Segmentation Offload)는 커널이 대용량 sk_buff를 NIC의 MTU에 맞는 작은 세그먼트로 분할하는 메커니즘입니다. NIC이 TSO(TCP Segmentation Offload)를 지원하면 하드웨어가 분할하고, 미지원 시 커널의 skb_segment()가 소프트웨어로 처리합니다. 이 과정에서 skb_shared_info의 GSO 필드가 핵심 역할을 합니다.
/* skb_shared_info의 GSO 관련 필드 */
struct skb_shared_info {
unsigned short gso_size; /* 세그먼트 크기 (MSS) */
unsigned short gso_segs; /* 세그먼트 수 */
unsigned short gso_type; /* GSO 타입 비트맵 */
/* ... */
};
/* GSO 타입 상수 */
#define SKB_GSO_TCPV4 (1 << 0) /* TCP/IPv4 분할 */
#define SKB_GSO_TCPV6 (1 << 4) /* TCP/IPv6 분할 */
#define SKB_GSO_UDP_L4 (1 << 17) /* UDP L4 분할 (4.18+) */
#define SKB_GSO_GRE (1 << 6) /* GRE 터널 내부 분할 */
#define SKB_GSO_GRE_CSUM (1 << 7) /* GRE+체크섬 */
#define SKB_GSO_UDP_TUNNEL (1 << 9) /* VXLAN/Geneve */
#define SKB_GSO_UDP_TUNNEL_CSUM (1 << 10) /* 터널+체크섬 */
#define SKB_GSO_PARTIAL (1 << 13) /* 부분 GSO (외부 헤더만 HW) */
/* skb_segment(): SW GSO 분할 핵심 함수 (net/core/skbuff.c) */
struct sk_buff *skb_segment(struct sk_buff *head_skb,
netdev_features_t features)
{
struct sk_buff *segs = NULL;
unsigned int mss = skb_shinfo(head_skb)->gso_size;
unsigned int doffset = head_skb->data - skb_mac_header(head_skb);
unsigned int offset = doffset;
unsigned int tnl_hlen, headroom;
unsigned int len, nfrags;
/* 각 세그먼트에 대해: */
do {
struct sk_buff *nskb;
int hsize, size;
/* 1. 새 skb 할당 */
nskb = alloc_skb(hsize + doffset + headroom, GFP_ATOMIC);
/* 2. L2+L3+L4 헤더 복사 (공통 헤더) */
skb_copy_from_linear_data(head_skb, skb_put(nskb, doffset),
doffset);
/* 3. 페이로드를 mss 크기만큼 복사/참조 */
if (!sg && !nskb->remcsum_offload) {
/* linear 복사 */
skb_copy_from_linear_data_offset(head_skb, offset,
skb_put(nskb, size), size);
} else {
/* SG: page fragment 참조 (zero-copy) */
skb_fill_page_desc(nskb, i, frag->bv_page,
frag->bv_offset, frag_size);
}
/* 4. 각 세그먼트 고유 필드 설정 */
skb_shinfo(nskb)->gso_size = 0; /* 더 이상 GSO 아님 */
skb_shinfo(nskb)->gso_segs = 0;
skb_shinfo(nskb)->gso_type = 0;
/* 5. IP 헤더: tot_len 갱신, id 순차 증가 */
/* 6. TCP 헤더: seq 순차 증가, 마지막 seg에만 PSH */
offset += size;
} while (offset < head_skb->len);
return segs; /* 분할된 skb 체인 (next 포인터 연결) */
}
/* GSO 분할 트리거 지점: dev_queue_xmit() → validate_xmit_skb() */
static struct sk_buff *validate_xmit_skb(struct sk_buff *skb,
struct net_device *dev, bool *again)
{
netdev_features_t features = dev->features;
if (skb_is_gso(skb)) {
/* NIC이 해당 GSO 타입을 지원하는지 확인 */
if (skb_gso_ok(skb, features))
return skb; /* HW TSO: 그대로 전달 */
/* SW GSO: 커널에서 분할 */
struct sk_buff *segs = skb_gso_segment(skb, features);
consume_skb(skb); /* 원본 super-packet 해제 */
return segs;
}
return skb;
}
/* GSO 관련 유틸리티 함수 */
static inline bool skb_is_gso(const struct sk_buff *skb) {
return skb_shinfo(skb)->gso_size;
}
static inline unsigned int skb_gso_network_seglen(const struct sk_buff *skb) {
/* gso_size + L4 헤더 + L3 헤더 = 실제 세그먼트의 IP 총 길이 */
return skb_shinfo(skb)->gso_size +
skb_network_header_len(skb) + skb_transport_header_len(skb);
}
| GSO 타입 | 프로토콜 | 커널 버전 | 특이사항 |
|---|---|---|---|
SKB_GSO_TCPV4 | TCP/IPv4 | 2.6+ | 가장 기본적인 TSO. 대부분 NIC이 HW 지원 |
SKB_GSO_TCPV6 | TCP/IPv6 | 2.6+ | IPv6 확장 헤더가 있으면 SW fallback 가능 |
SKB_GSO_UDP_L4 | UDP | 4.18+ | UDP GSO (USO). QUIC 등에서 활용. HW 지원 희소 |
SKB_GSO_GRE | GRE 터널 | 3.10+ | 외부 GRE 헤더 + 내부 TCP 분할 |
SKB_GSO_UDP_TUNNEL | VXLAN/Geneve | 3.12+ | 외부 UDP + 내부 TCP 분할 |
SKB_GSO_PARTIAL | 다양 | 4.7+ | 외부 헤더만 HW, 내부는 SW. tunnel GSO 최적화 |
SKB_GSO_SCTP | SCTP | 4.15+ | SCTP 청크 기반 분할 |
GSO vs TSO 성능 비교: HW TSO는 CPU 비용이 0에 가깝습니다(DMA 한 번으로 64KB 전송). SW GSO는 skb_segment()에서 세그먼트 수만큼 메모리 할당+헤더 복사가 필요하지만, 그래도 사용자 공간에서 sendmsg()를 45번 호출하는 것보다 훨씬 효율적입니다. 시스콜 오버헤드를 한 번으로 줄이는 것이 GSO의 핵심 이점입니다. ethtool -k eth0 | grep segmentation으로 HW 지원 여부를 확인하세요.
skb_shared_info: frags[] vs frag_list 상세
sk_buff의 비선형 데이터는 두 가지 방식으로 표현됩니다: frags[](page fragment 배열)과 frag_list(skb 체인). 이 두 구조는 목적과 사용 상황이 완전히 다르며, 혼동하면 심각한 버그가 발생합니다.
| 특성 | frags[] (page fragments) | frag_list (skb chain) |
|---|---|---|
| 저장 형태 | skb_frag_t 배열 (page+offset+size) | struct sk_buff 연결 리스트 |
| 최대 개수 | MAX_SKB_FRAGS (보통 17) | 제한 없음 |
| DMA SG | 직접 SG 매핑 가능 | 불가 — linearize 또는 변환 필요 |
| 오버헤드 | frag당 16바이트 (page+offset+size) | skb당 ~240바이트 (전체 sk_buff) |
| 주요 사용처 | NIC RX (skb_add_rx_frag), sendfile, splice | GRO 병합, IP defrag, GSO 분할 결과 |
| 데이터 접근 | skb_frag_page(), skb_frag_off() | skb_walk_frags(skb, frag_skb) |
| len/data_len | data_len = frags 총합 | data_len = frag_list skb들의 len 총합 |
/* frags[] 접근 패턴 */
struct skb_shared_info *si = skb_shinfo(skb);
for (int i = 0; i < si->nr_frags; i++) {
skb_frag_t *f = &si->frags[i];
struct page *page = skb_frag_page(f);
unsigned int off = skb_frag_off(f);
unsigned int sz = skb_frag_size(f);
/* kmap_local_page(page) + off 로 데이터 접근 */
}
/* frag_list 순회 패턴 */
struct sk_buff *frag_iter;
skb_walk_frags(skb, frag_iter) {
/* frag_iter는 frag_list의 각 skb */
process_fragment(frag_iter->data, frag_iter->len);
}
/* 전체 skb 데이터를 순차 복사하는 범용 함수 */
/* skb_copy_bits(): linear + frags[] + frag_list 모두 처리 */
int skb_copy_bits(const struct sk_buff *skb, int offset,
void *to, int len)
{
/* 1. linear 영역에서 복사 */
/* 2. frags[]에서 복사 */
/* 3. frag_list의 각 skb에서 재귀적으로 복사 */
}
/* MAX_SKB_FRAGS 계산 */
#define MAX_SKB_FRAGS (65536 / PAGE_SIZE + 1)
/* PAGE_SIZE=4096 → MAX_SKB_FRAGS=17
* 64KB 데이터를 frags로 표현하는 데 필요한 최대 페이지 수
* +1은 페이지 경계 걸침 고려 */
frags[]와 frag_list 혼용 주의: 하나의 skb에 frags[]와 frag_list가 동시에 존재할 수 있습니다. skb->data_len은 두 영역의 합산입니다. skb_linearize()는 모든 비선형 데이터(frags[] + frag_list)를 linear 영역으로 합치므로, 대용량 패킷에서 호출하면 거대한 연속 메모리 할당이 필요해 실패할 수 있습니다. GRO로 병합된 64KB super-packet에 skb_linearize()를 호출하는 것은 안티패턴입니다.
VLAN 태그 처리와 sk_buff
Linux 커널은 VLAN 태그를 두 가지 방식으로 처리합니다: 하드웨어 가속(HW VLAN acceleration)과 소프트웨어 처리. NIC이 VLAN 태그를 추출/삽입하는 HW 가속 방식이 더 효율적이며, 대부분의 현대 NIC이 지원합니다.
/* sk_buff의 VLAN 필드 */
struct sk_buff {
__be16 vlan_proto; /* VLAN 프로토콜 (0x8100 또는 0x88a8) */
__u16 vlan_tci; /* TCI: PCP(3) + DEI(1) + VID(12) */
/* vlan_present는 6.x에서 vlan_all로 통합 */
};
/* NIC 드라이버: HW VLAN 가속 — 수신 시 */
static void my_nic_rx(struct napi_struct *napi, u16 rx_vlan)
{
struct sk_buff *skb = napi_alloc_skb(napi, 256);
/* ... DMA 데이터 복사 (VLAN 태그 없는 프레임) ... */
if (rx_vlan) {
/* NIC이 추출한 VLAN 태그를 skb 메타에 저장 */
__vlan_hwaccel_put_tag(skb, htons(ETH_P_8021Q), rx_vlan);
/* → skb->vlan_proto = ETH_P_8021Q
* → skb->vlan_tci = rx_vlan
* → 패킷 데이터에는 VLAN 태그 없음 */
}
napi_gro_receive(napi, skb);
}
/* VLAN 태그 확인/추출 */
if (skb_vlan_tag_present(skb)) {
u16 vid = skb_vlan_tag_get_id(skb); /* VID (0~4095) */
u16 prio = skb_vlan_tag_get_prio(skb); /* PCP (0~7) */
}
/* VLAN 태그 추가/제거 (소프트웨어) */
skb_vlan_push(skb, htons(ETH_P_8021Q), vid | (prio << 13));
/* → 패킷 데이터에 4B VLAN 태그 삽입, headroom 필요 */
skb_vlan_pop(skb);
/* → 패킷에서 VLAN 태그 제거, skb 메타로 이동 */
/* QinQ (802.1ad): 이중 VLAN 태그 */
/* 외부 VLAN: skb->vlan_proto = ETH_P_8021AD, skb->vlan_tci = outer */
/* 내부 VLAN: 패킷 데이터 내 ETH_P_8021Q 태그로 존재 */
skb_vlan_push(skb, htons(ETH_P_8021AD), outer_vid);
/* → 외부 S-tag + 내부 C-tag 이중 태그 구성 */
TCP의 sk_buff 분할/병합/재전송
TCP는 sk_buff를 가장 정교하게 활용하는 프로토콜입니다. 전송 큐의 skb를 MSS 단위로 분할하고, 수신 경로에서 인접 세그먼트를 병합하며, 재전송 시 skb를 재활용합니다. 이 과정에서 TCP_SKB_CB()를 통한 cb[] 활용이 핵심입니다.
/* tcp_fragment(): 하나의 skb를 두 개로 분할
* 용도: MSS 변경, SACK 기반 부분 재전송, cwnd 축소 시
* net/ipv4/tcp_output.c */
int tcp_fragment(struct sock *sk, enum tcp_queue tcp_queue,
struct sk_buff *skb, u32 len, unsigned int mss_now,
gfp_t gfp)
{
struct sk_buff *buff;
int old_factor;
/* 새 skb 할당 (뒷부분 데이터용) */
buff = sk_stream_alloc_skb(sk, 0, gfp, 0);
/* 페이로드 분할: skb의 len 이후 데이터를 buff로 이동 */
skb_split(skb, buff, len);
/* TCP_SKB_CB 갱신: 시퀀스 번호 분할 */
TCP_SKB_CB(buff)->seq = TCP_SKB_CB(skb)->seq + len;
TCP_SKB_CB(buff)->end_seq = TCP_SKB_CB(skb)->end_seq;
TCP_SKB_CB(skb)->end_seq = TCP_SKB_CB(buff)->seq;
/* GSO 세그먼트 수 재계산 */
tcp_set_skb_tso_segs(skb, mss_now);
tcp_set_skb_tso_segs(buff, mss_now);
/* 전송 큐에서 skb 뒤에 buff 삽입 */
skb_append(skb, buff, &sk->sk_write_queue);
return 0;
}
/* tcp_try_coalesce(): 인접 수신 skb를 하나로 병합
* 용도: RX 경로에서 연속 세그먼트 병합 → 소켓 큐 skb 수 감소
* net/ipv4/tcp_input.c */
static bool tcp_try_coalesce(struct sock *sk,
struct sk_buff *to,
struct sk_buff *from,
bool *fragstolen)
{
/* from의 데이터를 to에 병합 가능한지 확인 */
if (TCP_SKB_CB(from)->seq != TCP_SKB_CB(to)->end_seq)
return false; /* 연속이 아님 */
if (!skb_try_coalesce(to, from, fragstolen, &delta))
return false; /* 메모리/frag 제한 초과 */
/* skb_try_coalesce: from의 frags를 to의 frags[]에 추가
* → from은 해제 가능, to->len 증가 */
TCP_SKB_CB(to)->end_seq = TCP_SKB_CB(from)->end_seq;
TCP_SKB_CB(to)->ack_seq = TCP_SKB_CB(from)->ack_seq;
return true;
}
/* tcp_collapse(): OOO(Out-of-Order) 큐에서 중복/겹침 제거
* 용도: OOO 큐의 skb가 과도하게 쌓일 때 메모리 절약
* net/ipv4/tcp_input.c */
static void tcp_collapse(struct sock *sk,
struct sk_buff_head *list,
struct rb_root *root,
struct sk_buff *head,
struct sk_buff *tail,
u32 start, u32 end)
{
/* start~end 범위의 skb들을 하나의 skb로 합침
* → 겹치는 시퀀스 번호는 제거
* → OOO 큐의 메모리 사용량 감소
* → tcp_prune_ofo_queue()에서 메모리 압박 시 호출 */
}
/* TCP 재전송: 기존 skb 재활용 */
int __tcp_retransmit_skb(struct sock *sk, struct sk_buff *skb, int segs)
{
/* 1. skb가 clone 상태면 pskb_copy()로 헤더 독립화 */
if (skb_cloned(skb)) {
struct sk_buff *nskb = pskb_copy(skb, GFP_ATOMIC);
/* 원본을 큐에서 교체 */
}
/* 2. TCP 헤더 재구성 (seq, ack, window, timestamp) */
tcp_transmit_skb(sk, skb, 1, GFP_ATOMIC);
/* clone_it=1: 재전송 큐에 남기면서 clone 전송 */
}
| TCP skb 연산 | 함수 | 트리거 조건 | skb 변화 |
|---|---|---|---|
| 분할 | tcp_fragment() | MSS 축소, SACK partial retx | 1개 skb → 2개 (seq 분할) |
| 수신 병합 | tcp_try_coalesce() | 연속 세그먼트 수신 | 2개 skb → 1개 (frags 합체) |
| OOO 압축 | tcp_collapse() | OOO 큐 메모리 압박 | N개 skb → 1개 (데이터 병합) |
| 재전송 | __tcp_retransmit_skb() | RTO, SACK, TLP | 기존 skb clone 후 재전송 |
| GSO 생성 | tcp_write_xmit() | cwnd 허용, TSQ 미달 | 여러 MSS를 하나의 GSO skb로 |
BPF/TC의 __sk_buff 컨텍스트
eBPF 프로그램(TC classifier, socket filter)은 커널의 struct sk_buff에 직접 접근하지 않고, 안전한 래퍼인 struct __sk_buff를 통해 접근합니다. BPF 검증기(verifier)가 이 구조체의 필드 접근을 커널 내부 sk_buff 필드로 변환합니다.
/* include/uapi/linux/bpf.h — BPF 프로그램이 보는 skb 뷰 */
struct __sk_buff {
__u32 len; /* skb->len */
__u32 pkt_type; /* skb->pkt_type */
__u32 mark; /* skb->mark (읽기/쓰기) */
__u32 queue_mapping; /* skb->queue_mapping */
__u32 protocol; /* skb->protocol */
__u32 vlan_present; /* skb_vlan_tag_present(skb) */
__u32 vlan_tci; /* skb->vlan_tci */
__u32 vlan_proto; /* skb->vlan_proto */
__u32 priority; /* skb->priority (읽기/쓰기) */
__u32 ingress_ifindex; /* skb->skb_iif */
__u32 ifindex; /* skb->dev->ifindex */
__u32 tc_index; /* skb->tc_index */
__u32 cb[5]; /* skb->cb[] (TC에서 사용) */
__u32 hash; /* skb->hash */
__u32 tc_classid; /* skb->tc_classid (쓰기) */
__u32 data; /* skb->data 포인터 (패킷 시작) */
__u32 data_end; /* skb->data + skb_headlen(skb) */
__u32 napi_id; /* skb->napi_id */
__u32 family; /* sk->sk_family */
__u32 data_meta; /* skb->data - skb_metadata_len */
__u32 flow_keys; /* flow dissector 결과 */
__u64 tstamp; /* skb->tstamp (읽기/쓰기) */
__u32 wire_len; /* 원래 와이어 길이 (GSO 이전) */
__u32 gso_segs; /* skb_shinfo(skb)->gso_segs */
__u64 hwtstamp; /* skb_hwtstamps(skb)->hwtstamp */
};
/* BPF 검증기: __sk_buff 필드 접근 → 실제 skb 오프셋 변환
* net/core/filter.c — bpf_convert_ctx_access() */
static u32 bpf_convert_ctx_access(...)
{
switch (si->off) {
case offsetof(struct __sk_buff, len):
/* __sk_buff.len → skb->len 직접 매핑 */
*insn++ = BPF_LDX_MEM(BPF_W, si->dst_reg, si->src_reg,
offsetof(struct sk_buff, len));
break;
case offsetof(struct __sk_buff, data):
/* __sk_buff.data → skb->data 포인터 로드 */
*insn++ = BPF_LDX_MEM(BPF_FIELD_SIZEOF(struct sk_buff, data),
si->dst_reg, si->src_reg,
offsetof(struct sk_buff, data));
break;
}
}
/* TC-BPF 프로그램에서 skb 패킷 데이터 직접 접근 */
SEC("tc")
int tc_filter(struct __sk_buff *skb)
{
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
/* 패킷 데이터 직접 접근 (bounds check 필수!) */
struct ethhdr *eth = data;
if (data + sizeof(*eth) > data_end)
return TC_ACT_OK;
/* skb 필드 수정 */
skb->mark = 42; /* → skb->mark = 42 (netfilter/tc 마킹) */
skb->priority = 7; /* → skb->priority = 7 (QoS) */
/* 패킷 데이터 수정: bpf_skb_store_bytes() 헬퍼 사용 */
__u8 new_ttl = 64;
bpf_skb_store_bytes(skb, ETH_HLEN + offsetof(struct iphdr, ttl),
&new_ttl, sizeof(new_ttl), 0);
/* 헤더 축소/확장: encap/decap */
bpf_skb_adjust_room(skb, -14, BPF_ADJ_ROOM_MAC, 0);
/* → skb_pull(14) 효과: L2 헤더 제거 */
return TC_ACT_OK;
}
/* 주요 BPF skb 헬퍼 함수 */
/* bpf_skb_load_bytes() — 오프셋에서 N바이트 로드 (비선형 안전) */
/* bpf_skb_store_bytes() — 오프셋에 N바이트 저장 */
/* bpf_skb_pull_data() — pskb_may_pull() 래퍼 */
/* bpf_skb_change_head() — headroom 변경 (encap) */
/* bpf_skb_change_tail() — tailroom 변경 */
/* bpf_skb_adjust_room() — MAC/NET 레벨 크기 조정 */
/* bpf_skb_vlan_push/pop() — VLAN 태그 추가/제거 */
/* bpf_skb_change_proto() — L3 프로토콜 변경 (IPv4↔IPv6) */
/* bpf_skb_cgroup_id() — cgroup ID 조회 */
/* bpf_skb_get_tunnel_key() — 터널 메타데이터 조회 */
/* bpf_redirect() — 다른 인터페이스로 리다이렉트 */
/* bpf_clone_redirect() — clone 후 리다이렉트 */
direct packet access vs 헬퍼: data/data_end를 통한 직접 접근은 linear 영역만 가능합니다. 비선형 데이터(frags/frag_list)에 접근하려면 bpf_skb_pull_data(skb, offset)로 먼저 linearize하거나, bpf_skb_load_bytes() 헬퍼를 사용해야 합니다. 직접 접근이 더 빠르지만, GRO로 병합된 대용량 패킷은 헤더만 linear이므로 페이로드 파싱 시 헬퍼가 필요합니다.
Flow Dissector와 RSS/RPS 해시
Flow dissector는 skb에서 프로토콜 헤더를 파싱하여 플로우 키(5-tuple 등)를 추출하는 커널 프레임워크입니다. 추출된 키는 skb->hash에 저장되어 RSS(Receive Side Scaling), RPS(Receive Packet Steering), GRO 병합, 소켓 lookup 등 다양한 곳에서 플로우 기반 분배에 사용됩니다.
/* include/net/flow_dissector.h — 플로우 키 구조체 */
struct flow_keys {
struct flow_dissector_key_control control;
struct flow_dissector_key_basic basic; /* n_proto, ip_proto */
struct flow_dissector_key_addrs addrs; /* saddr, daddr */
struct flow_dissector_key_ports ports; /* sport, dport */
/* VLAN, GRE, MPLS 키도 포함 가능 */
};
/* skb->hash 계산 (lazy — 처음 접근 시 계산) */
static inline __u32 skb_get_hash(struct sk_buff *skb)
{
if (!skb->l4_hash && !skb->sw_hash)
__skb_get_hash(skb); /* flow dissector 실행 */
return skb->hash;
}
/* RPS: SW 기반 CPU 분배 (net/core/dev.c) */
static int get_rps_cpu(struct net_device *dev,
struct sk_buff *skb,
struct rps_dev_flow **rflowp)
{
u32 hash = skb_get_hash(skb);
/* hash를 CPU 수로 나눠 대상 CPU 결정 */
u32 cpu = reciprocal_scale(hash, cpumask_weight(rps_mask));
return cpu;
}
| 해시 소스 | 설정 방법 | 성능 | 커스터마이즈 |
|---|---|---|---|
| NIC RSS (HW) | ethtool -X eth0 hkey/hfunc | 최고 (HW 처리) | 해시 키, 해시 함수, indirection table |
| RPS (SW) | /sys/class/net/eth0/queues/rx-0/rps_cpus | 양호 (softirq) | CPU 비트맵 |
| RFS (Flow Steering) | /proc/sys/net/core/rps_sock_flow_entries | 양호 | 앱이 실행 중인 CPU로 스티어링 |
| XPS (TX) | /sys/class/net/eth0/queues/tx-0/xps_cpus | TX 큐 선택 | CPU→TX 큐 매핑 |
Encapsulation/Tunnel과 sk_buff
터널 프로토콜(GRE, VXLAN, Geneve, WireGuard)은 sk_buff에 외부 헤더를 추가(encap)하거나 제거(decap)합니다. 이 과정에서 headroom 관리, 헤더 포인터 재설정, inner/outer 프로토콜 구분이 핵심입니다.
/* VXLAN encapsulation 흐름 (drivers/net/vxlan/vxlan_core.c) */
static void vxlan_xmit_one(struct sk_buff *skb, ...)
{
int headroom = sizeof(struct iphdr) /* 20B outer IP */
+ sizeof(struct udphdr) /* 8B outer UDP */
+ sizeof(struct vxlanhdr) /* 8B VXLAN */
+ LL_RESERVED_SPACE(dst->dev); /* outer L2 */
/* 1. headroom 확보 (clone이면 독립화) */
if (skb_cow_head(skb, headroom)) {
kfree_skb(skb);
return;
}
/* 2. skb->inner_* 헤더 포인터 저장 (decap 시 복원용) */
skb_set_inner_protocol(skb, skb->protocol);
skb_set_inner_network_header(skb, skb_network_offset(skb));
skb_set_inner_transport_header(skb, skb_transport_offset(skb));
/* 3. encapsulation 플래그 설정 */
skb->encapsulation = 1;
/* 4. VXLAN 헤더 추가 */
struct vxlanhdr *vxh = (struct vxlanhdr *)__skb_push(skb, sizeof(*vxh));
vxh->vx_flags = htonl(VXLAN_HF_VNI);
vxh->vx_vni = vxlan_vni_field(vni);
/* 5. 외부 UDP 헤더 */
udp_set_csum(skb, ...);
/* 6. 외부 IP 헤더 → ip_tunnel_xmit() */
iptunnel_xmit(..., skb, ...);
/* → skb_push(IP 헤더) → skb_reset_network_header()
* → ip_local_out() → Netfilter OUTPUT → dev_queue_xmit() */
}
/* Decapsulation: 외부 헤더 제거 후 inner 헤더 복원 */
static int vxlan_rcv(struct sock *sk, struct sk_buff *skb)
{
/* 1. VXLAN 헤더 파싱 및 VNI 추출 */
struct vxlanhdr *vxh = vxlan_hdr(skb);
/* 2. 외부 헤더 제거 */
__skb_pull(skb, sizeof(struct vxlanhdr));
skb_reset_network_header(skb); /* inner IP로 재설정 */
/* 3. inner 패킷으로 프로토콜 재설정 */
skb->protocol = eth_type_trans(skb, vxlan->dev);
skb->encapsulation = 0;
/* 4. 일반 스택으로 재진입 */
netif_rx(skb);
}
/* sk_buff의 inner 헤더 포인터 */
struct sk_buff {
sk_buff_data_t inner_transport_header; /* 내부 L4 */
sk_buff_data_t inner_network_header; /* 내부 L3 */
sk_buff_data_t inner_mac_header; /* 내부 L2 */
__be16 inner_protocol; /* 내부 프로토콜 */
__u8 encapsulation:1; /* 캡슐화 여부 */
};
터널과 GSO 상호작용: skb->encapsulation = 1이면 GSO/체크섬 오프로드가 inner 패킷 기준으로 동작합니다. NIC이 NETIF_F_GSO_UDP_TUNNEL을 지원하면 HW가 외부 UDP + 내부 TCP를 한 번에 분할합니다. 미지원 NIC에서는 SKB_GSO_PARTIAL을 사용하여 외부 헤더만 SW로, 내부 분할은 HW로 처리하는 하이브리드 방식이 가능합니다(4.7+).
sk_buff 할당 내부 (kmem_cache)
sk_buff의 메모리 할당은 일반 kmalloc()이 아닌 전용 SLAB 캐시(skbuff_head_cache)를 사용합니다. 이는 빈번한 할당/해제에 최적화되어 있으며, fclone(fast clone) 메커니즘으로 clone 비용을 더 줄입니다.
/* net/core/skbuff.c — sk_buff SLAB 캐시 초기화 */
static struct kmem_cache *skbuff_head_cache;
static struct kmem_cache *skbuff_fclone_cache;
void __init skb_init(void)
{
/* 일반 sk_buff 캐시 */
skbuff_head_cache = kmem_cache_create(
"skbuff_head_cache",
sizeof(struct sk_buff), /* ~240바이트 */
0, /* 정렬 */
SLAB_HWCACHE_ALIGN | SLAB_PANIC,
NULL);
/* fclone 캐시: sk_buff 2개 + fclone_ref를 하나의 슬랩 객체로 */
skbuff_fclone_cache = kmem_cache_create(
"skbuff_fclone_cache",
sizeof(struct sk_buff_fclones), /* sk_buff*2 + ref */
0,
SLAB_HWCACHE_ALIGN | SLAB_PANIC,
NULL);
}
/* fclone 구조체: clone 전용 최적화 */
struct sk_buff_fclones {
struct sk_buff skb1; /* 원본 sk_buff */
struct sk_buff skb2; /* 사전 할당된 clone sk_buff */
refcount_t fclone_ref; /* 공유 참조 카운트 */
};
/* __alloc_skb: 내부 할당 로직 */
struct sk_buff *__alloc_skb(unsigned int size, gfp_t gfp_mask,
int flags, int node)
{
struct sk_buff *skb;
u8 *data;
if (flags & SKB_ALLOC_FCLONE) {
/* fclone 모드: 2개의 sk_buff를 한 번에 할당
* TCP 전송 경로에서 사용: 재전송 시 clone 필요 예상 */
struct sk_buff_fclones *fclones;
fclones = kmem_cache_alloc_node(skbuff_fclone_cache,
gfp_mask, node);
skb = &fclones->skb1;
skb->fclone = SKB_FCLONE_ORIG; /* 원본 표시 */
fclones->skb2.fclone = SKB_FCLONE_CLONE; /* clone 슬롯 */
} else {
/* 일반 모드: sk_buff 1개만 할당 */
skb = kmem_cache_alloc_node(skbuff_head_cache,
gfp_mask, node);
skb->fclone = SKB_FCLONE_UNAVAILABLE;
}
/* 데이터 버퍼 할당 (별도) */
size = SKB_DATA_ALIGN(size);
data = kmalloc_reserve(size + sizeof(struct skb_shared_info),
gfp_mask, node, &pfmemalloc);
skb->head = data;
skb->data = data;
skb->truesize = SKB_TRUESIZE(size);
refcount_set(&skb->users, 1);
return skb;
}
/* fclone으로 빠른 clone (별도 할당 불필요) */
struct sk_buff *skb_clone(struct sk_buff *skb, gfp_t gfp_mask)
{
struct sk_buff *n;
if (skb->fclone == SKB_FCLONE_ORIG) {
/* fclone 슬롯이 사용 가능하면 할당 없이 즉시 clone */
struct sk_buff_fclones *fclones =
container_of(skb, struct sk_buff_fclones, skb1);
n = &fclones->skb2;
if (refcount_inc_not_zero(&fclones->fclone_ref)) {
/* 할당 없이 clone 완료! → kmem_cache_alloc 비용 절약 */
goto do_clone;
}
}
/* fclone 불가: 일반 할당 */
n = kmem_cache_alloc(skbuff_head_cache, gfp_mask);
do_clone:
/* sk_buff 메타데이터 복사 (데이터 버퍼 공유) */
__copy_skb_header(n, skb);
n->cloned = 1;
skb->cloned = 1;
atomic_inc(&skb_shinfo(skb)->dataref);
return n;
}
/* NAPI per-CPU 캐시 (napi_alloc_skb 최적화) */
/* NAPI 수신 경로에서는 skbuff_head_cache 대신
* per-CPU page fragment cache를 사용하여 allocation lock 경합을 회피
* → napi_alloc_cache (struct page_frag_cache)
* → 같은 page에서 연속 skb의 data 버퍼를 할당
* → TLB miss, cache miss 최소화 */
| 할당 방식 | 캐시 | 크기 | 사용처 |
|---|---|---|---|
| 일반 skb | skbuff_head_cache | ~240B | 대부분의 skb 할당 |
| fclone skb | skbuff_fclone_cache | ~490B | TCP TX (clone 예상 시) |
| 데이터 버퍼 | kmalloc slab | 가변 | linear 데이터 영역 |
| NAPI 수신 | per-CPU page frag | PAGE_SIZE | NAPI poll 내 고속 할당 |
| page_pool | per-pool 캐시 | PAGE_SIZE | 고성능 NIC 드라이버 (6.x+) |
fclone 효과: TCP 전송 경로에서 sk_stream_alloc_skb()는 SKB_ALLOC_FCLONE 플래그로 skb를 할당합니다. 이는 재전송 시 skb_clone()이 별도 메모리 할당 없이 사전 할당된 슬롯을 사용하게 합니다. 고부하 TCP 서버에서 재전송율이 높을 때 kmem_cache_alloc() 호출 수를 크게 줄여 성능을 개선합니다. slabinfo -s | grep skbuff로 캐시 사용 통계를 확인할 수 있습니다.
네트워크 네임스페이스와 sk_buff
Linux 네트워크 네임스페이스는 독립된 네트워크 스택(인터페이스, 라우팅, iptables, 소켓)을 제공합니다. sk_buff는 skb->dev를 통해 네임스페이스에 소속되며, veth, bridge 등을 통해 네임스페이스를 넘나들 때 sk_buff의 처리가 변화합니다.
/* sk_buff가 속한 네트워크 네임스페이스 확인 */
static inline struct net *dev_net(const struct net_device *dev)
{
return read_pnet(&dev->nd_net);
}
/* skb->dev를 통해 네임스페이스 참조 */
struct net *net = dev_net(skb->dev);
/* → net->ipv4.ip_forward (포워딩 설정)
* → net->ct.nf_conntrack_hash (conntrack 해시)
* → net->loopback_dev (lo 인터페이스)
* → 모두 네임스페이스별 독립 */
/* veth: 네임스페이스 간 패킷 전달 */
/* drivers/net/veth.c — veth_xmit() */
static netdev_tx_t veth_xmit(struct sk_buff *skb,
struct net_device *dev)
{
struct veth_priv *priv = netdev_priv(dev);
struct net_device *rcv = rcu_dereference(priv->peer);
/* rcv는 다른 네임스페이스의 veth peer 디바이스 */
/* skb->dev를 peer 디바이스로 교체 → 네임스페이스 전환 */
skb->dev = rcv;
/* L2 헤더 재처리 */
skb->protocol = eth_type_trans(skb, rcv);
/* → 이제 skb는 rcv가 속한 네임스페이스에서 처리됨
* → rcv 네임스페이스의 Netfilter, 라우팅, 소켓 lookup 적용 */
if (likely(veth_forward_skb(rcv, skb, priv, rq, rcv_xdp) == NET_RX_SUCCESS))
return NETDEV_TX_OK;
/* veth_forward_skb → netif_rx() 또는 napi_gro_receive()
* → rcv 네임스페이스의 네트워크 스택에 진입 */
}
/* 네임스페이스 경계에서 주의할 skb 처리 */
/* 1. conntrack: 네임스페이스별 독립 → skb->_nfct 초기화 필요할 수 있음 */
/* 2. skb->mark: 네임스페이스 간 보존됨 → 의도하지 않은 정책 적용 주의 */
/* 3. skb->sk: NULL이 아니면 소켓 네임스페이스와 dev 네임스페이스 불일치 가능 */
컨테이너 네트워킹과 skb: Docker/Kubernetes의 Pod 네트워킹은 veth 쌍을 통해 구현됩니다. 호스트 네임스페이스의 veth에서 dev_queue_xmit(skb)를 호출하면 skb->dev가 컨테이너 네임스페이스의 peer veth로 교체되어 netif_rx()로 재진입합니다. 이 과정에서 XDP는 veth 드라이버에서 실행되어 컨테이너로 진입하기 전에 패킷을 필터링/리다이렉트할 수 있습니다 (Cilium의 veth XDP 모드).
관련 문서
sk_buff와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.