들어가며
웹 서버의 p99 지연이 갑자기 튀거나, 모니터링에서 패킷 드롭 카운터가 올라가기 시작할 때, 많은 엔지니어가 애플리케이션 코드부터 의심합니다. 하지만 실제로는 패킷이 애플리케이션에 도달하기 전, 커널 안에서 이미 수십 단계를 거칩니다. NIC 하드웨어, DMA, 인터럽트, softirq, 프로토콜 스택, 소켓 큐 — 이 중 어느 한 곳이라도 병목이면 애플리케이션이 아무리 빨라도 소용이 없습니다.
이 글에서는 패킷 하나가 네트워크 카드에 도착해서 애플리케이션의 read 호출에 도달하기까지의 전체 여정을 따라갑니다. 각 단계에서 무엇이 일어나는지, 어디서 패킷이 버려질 수 있는지, 그리고 각 지점을 어떻게 관측하고 튜닝하는지를 실무 관점에서 정리합니다. 쿠버네티스 노드 튜닝, 저지연 서비스 운영, 네트워크 장애 분석을 하는 분들께 실질적인 지도가 되었으면 합니다.
전체 그림 — 수신 경로 대형 다이어그램
먼저 전체 경로를 한눈에 봅니다. 아래 다이어그램은 패킷 수신(RX) 경로의 핵심 단계입니다.
[물리 네트워크]
|
v
+---------------------------+
| NIC 하드웨어 | 1. 프레임 수신, FCS 검증
| (RSS 해시로 RX 큐 선택) | 2. RSS: 5-tuple 해시 -> 큐 결정
+---------------------------+
|
v DMA (CPU 개입 없음)
+---------------------------+
| RX 링버퍼 (디스크립터) | 3. 커널이 미리 할당한 메모리에
| ethtool -g 로 크기 확인 | 패킷 데이터를 직접 기록
+---------------------------+
|
v 하드웨어 인터럽트 (IRQ)
+---------------------------+
| 하드 IRQ 핸들러 (짧음) | 4. "패킷 왔음"만 표시하고
| napi_schedule() 호출 | 즉시 리턴 (수 마이크로초)
+---------------------------+
|
v NET_RX_SOFTIRQ
+---------------------------+
| softirq / ksoftirqd | 5. NAPI 폴링 시작
| net_rx_action() | budget 만큼 패킷을 모아서 처리
+---------------------------+
|
v
+---------------------------+
| NAPI poll (드라이버) | 6. 링버퍼에서 패킷을 꺼내
| sk_buff 생성, GRO 병합 | sk_buff로 변환, GRO로 병합
+---------------------------+
|
v
+---------------------------+
| netif_receive_skb() | 7. 프로토콜 분기점
| (tcpdump/AF_PACKET 탭, | XDP generic, tc ingress도
| tc ingress 훅 위치) | 이 근처에서 동작
+---------------------------+
|
v
+---------------------------+
| IP 계층 (ip_rcv) | 8. 체크섬, 라우팅 판단,
| netfilter PREROUTING/ | netfilter 훅 통과,
| INPUT 훅 | 로컬 수신이면 위로 전달
+---------------------------+
|
v
+---------------------------+
| TCP 계층 (tcp_v4_rcv) | 9. 소켓 룩업, 시퀀스 처리,
| 혼잡 제어, ACK 생성 | 순서 재조립, ACK 응답
+---------------------------+
|
v
+---------------------------+
| 소켓 수신 큐 | 10. sk_receive_queue에 적재,
| (sk_rcvbuf 한도) | 대기 중인 프로세스 깨움
+---------------------------+
|
v
+---------------------------+
| 애플리케이션 read/recv | 11. 시스템 콜로 유저 공간에
| (epoll 이벤트 발생) | 데이터 복사
+---------------------------+
이 11단계 중 패킷이 버려질 수 있는 지점은 크게 네 곳입니다. 링버퍼가 가득 찼을 때(2~3단계), softirq가 밀렸을 때(5단계), netfilter 규칙에 의해(8단계), 그리고 소켓 버퍼가 가득 찼을 때(10단계)입니다. 뒤에서 각각의 진단법을 다룹니다.
sk_buff — 패킷의 커널 내 분신
커널 안에서 패킷은 sk_buff(socket buffer, 줄여서 skb)라는 구조체로 표현됩니다. 패킷 데이터 자체와, 그 데이터를 해석하기 위한 메타데이터를 함께 들고 다니는 구조입니다.
/* include/linux/skbuff.h 에서 발췌한 개념적 구조 */
struct sk_buff {
struct sk_buff *next; /* 큐 연결용 */
struct sock *sk; /* 소속 소켓 */
struct net_device *dev; /* 수신/송신 디바이스 */
unsigned int len; /* 데이터 길이 */
__u16 transport_header; /* L4 헤더 오프셋 */
__u16 network_header; /* L3 헤더 오프셋 */
__u16 mac_header; /* L2 헤더 오프셋 */
sk_buff_data_t tail;
sk_buff_data_t end;
unsigned char *head; /* 버퍼 시작 */
unsigned char *data; /* 현재 계층의 데이터 시작 */
};
핵심 아이디어는 두 가지입니다.
첫째, 패킷 데이터는 한 번만 메모리에 두고, 각 프로토콜 계층은 head/data/tail 포인터만 조정합니다. 이더넷 계층이 처리를 끝내면 data 포인터를 14바이트 앞으로 밀어 IP 헤더를 가리키게 하는 식입니다. 계층을 오르내릴 때마다 데이터를 복사하지 않는 것이 성능의 핵심입니다.
둘째, skb는 클론이 가능합니다. tcpdump가 패킷을 캡처할 때 데이터 전체를 복사하는 것이 아니라, 같은 데이터를 가리키는 skb 메타데이터만 복제합니다. 그래서 캡처 부하가 생각보다 작습니다(물론 공짜는 아닙니다).
인터럽트에서 NAPI까지 — 인터럽트 완화의 역사
패킷이 도착할 때마다 인터럽트를 발생시키면 어떻게 될까요? 10GbE에서 64바이트 패킷이 초당 약 1488만 개 도착할 수 있습니다. 패킷마다 인터럽트를 걸면 CPU는 인터럽트 처리만 하다가 끝납니다. 이것이 인터럽트 라이브락(livelock)입니다.
NAPI(New API)는 이 문제를 인터럽트와 폴링의 하이브리드로 풉니다.
트래픽 적을 때: 인터럽트 모드 (지연 최소화)
패킷 도착 -> IRQ -> napi_schedule -> 폴링 -> 큐 비면 IRQ 재활성화
트래픽 많을 때: 폴링 모드 (처리량 최대화)
IRQ는 꺼진 상태 유지 -> softirq가 budget 한도 내에서
계속 링버퍼를 폴링 -> 패킷이 계속 있으면 IRQ를 켜지 않음
동작 규칙을 정리하면 이렇습니다.
1. 첫 패킷이 도착하면 하드 IRQ가 발생하고, 드라이버는 해당 큐의 IRQ를 끈 뒤 NAPI 폴링을 예약합니다.
2. softirq 컨텍스트에서 net_rx_action이 실행되어 한 번에 최대 budget(기본 300, 디바이스당 64) 개의 패킷을 처리합니다.
3. 링버퍼를 다 비웠으면 IRQ를 다시 켜고 인터럽트 모드로 돌아갑니다. 아직 패킷이 남았으면 IRQ를 끈 채로 다음 softirq 라운드에서 계속 처리합니다.
4. softirq가 너무 오래 점유하면 ksoftirqd 커널 스레드로 넘겨 스케줄러의 공정성 안에서 처리합니다.
관련 파라미터는 다음과 같습니다.
한 번의 net_rx_action에서 처리할 전체 패킷 수 상한
sysctl net.core.netdev_budget # 기본 300
한 번의 net_rx_action에 쓸 수 있는 시간 상한 (마이크로초)
sysctl net.core.netdev_budget_usecs # 기본 2000
budget 소진으로 처리가 중단된 횟수 (3번째 컬럼: time_squeeze)
cat /proc/net/softnet_stat
softnet_stat의 세 번째 컬럼(time_squeeze)이 지속적으로 증가한다면 softirq가 budget 안에 일을 못 끝내고 있다는 뜻으로, budget 증가나 CPU 분산(RPS)을 검토해야 합니다.
하드웨어 수준의 인터럽트 완화도 있습니다. NIC이 패킷 몇 개를 모으거나 일정 시간 기다렸다가 인터럽트를 한 번만 거는 기능입니다.
인터럽트 coalescing 설정 확인/변경
ethtool -c eth0
ethtool -C eth0 rx-usecs 50 rx-frames 64
저지연이 중요하면 rx-usecs를 줄이고, 처리량이 중요하면 늘립니다.
adaptive-rx on 이면 NIC이 트래픽 패턴에 따라 자동 조정합니다.
ethtool -C eth0 adaptive-rx on
멀티코어 분산 — RSS, RPS, RFS
코어 하나로는 고속 NIC의 트래픽을 다 처리할 수 없습니다. 패킷 처리를 여러 코어로 나누는 세 가지 메커니즘이 있습니다.
| 기술 | 동작 위치 | 분산 기준 | 특징 |
| --- | --- | --- | --- |
| RSS | NIC 하드웨어 | 5-tuple 해시로 RX 큐 선택 | 가장 효율적, 큐 수는 하드웨어 제한 |
| RPS | 커널 소프트웨어 | 해시로 처리 CPU 선택 | RSS 없는 NIC이나 큐 부족 시 보완 |
| RFS | 커널 소프트웨어 | 애플리케이션이 도는 CPU로 유도 | 캐시 지역성 최적화, RPS의 확장 |
RSS는 NIC이 패킷의 소스/목적지 IP와 포트를 해시해서 여러 RX 큐 중 하나로 보내는 하드웨어 기능입니다. 각 큐는 별도 IRQ를 가지므로 IRQ를 코어별로 분산하면 자연스럽게 멀티코어 처리가 됩니다.
RX 큐 개수 확인 및 변경
ethtool -l eth0
ethtool -L eth0 combined 8
큐별 IRQ 확인
grep eth0 /proc/interrupts
IRQ를 특정 CPU에 고정 (irqbalance를 끄고 수동 제어할 때)
echo 2 > /proc/irq/125/smp_affinity_list # IRQ 125를 CPU2에
RPS는 RSS의 소프트웨어 버전입니다. 하드 IRQ를 받은 CPU가 해시를 계산해 패킷 처리를 다른 CPU의 backlog 큐로 넘깁니다.
eth0의 rx-0 큐 패킷을 CPU 0-3에 분산 (비트마스크 f = 0b1111)
echo f > /sys/class/net/eth0/queues/rx-0/rps_cpus
RPS backlog 큐 크기 (드롭 방지)
sysctl -w net.core.netdev_max_backlog=16384
RFS는 한 단계 더 나아가, 해당 플로우의 데이터를 소비하는 애플리케이션이 실행 중인 CPU로 패킷을 보냅니다. 패킷 데이터가 이미 그 CPU의 캐시에 올라갈 가능성이 높아져 지연이 줄어듭니다.
전역 플로우 테이블 크기
sysctl -w net.core.rps_sock_flow_entries=32768
큐별 플로우 수 = 전역값 / 큐 수
echo 4096 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
실무 감각으로는 이렇습니다. 최신 서버 NIC은 큐가 충분하므로 RSS + IRQ 어피니티만으로 대부분 해결됩니다. RPS/RFS는 가상화 환경의 virtio-net처럼 큐가 부족하거나, 특정 코어로 트래픽이 쏠릴 때 보완재로 씁니다.
오프로드 — GRO, GSO, TSO
세그먼트 단위 처리 비용을 줄이는 또 다른 축은 "커널 스택은 큰 덩어리로 처리하고, 분할/병합은 경계에서 한다"는 오프로드 전략입니다.
| 기술 | 방향 | 수행 주체 | 내용 |
| --- | --- | --- | --- |
| TSO | 송신 | NIC 하드웨어 | 커널이 64KB급 덩어리를 주면 NIC이 MSS로 분할 |
| GSO | 송신 | 커널 소프트웨어 | TSO 미지원 시 드라이버 직전에 소프트웨어 분할 |
| GRO | 수신 | 커널(NAPI 단계) | 같은 플로우의 연속 세그먼트를 큰 skb로 병합 |
GRO 덕분에 1500바이트 패킷 40개가 6만 바이트짜리 skb 하나로 합쳐져 IP/TCP 계층을 한 번만 통과합니다. 스택 통과 비용이 패킷 수가 아니라 "묶음 수"에 비례하게 되는 것입니다.
오프로드 상태 확인
ethtool -k eth0 | grep -E "generic-receive|generic-segmentation|tcp-segmentation"
켜고 끄기
ethtool -K eth0 gro on gso on tso on
주의할 점이 있습니다. GRO는 패킷을 잠깐 들고 있다가 병합하므로 극단적 저지연 워크로드에서는 끄는 경우도 있습니다. 또 라우터/브리지처럼 패킷을 포워딩하는 장비에서 GRO 후 재분할(GSO)이 일어나면 패킷 경계가 바뀌어 미묘한 문제를 만들 수 있습니다. tcpdump에서 MTU보다 큰 패킷이 보이는 것은 대부분 GRO/TSO 때문이며 정상입니다.
송신 경로 — qdisc와 큐잉
송신은 수신의 역순이지만, 큐잉 규율(qdisc)이라는 중요한 단계가 추가됩니다.
애플리케이션 write/send
|
v
+---------------------+
| 소켓 송신 버퍼 | sk_sndbuf 한도, TCP라면 혼잡 윈도우와
| (sk_write_queue) | 수신자 윈도우가 전송 속도를 결정
+---------------------+
|
v
+---------------------+
| TCP/IP 계층 | 헤더 작성, 라우팅, netfilter OUTPUT
+---------------------+
|
v
+---------------------+
| qdisc (큐잉 규율) | fq_codel(기본), fq, pfifo_fast 등
| tc 로 제어 | 여기서 페이싱, 셰이핑, AQM 수행
+---------------------+
|
v
+---------------------+
| 드라이버 TX 링버퍼 | DMA로 NIC에 전달
+---------------------+
|
v
| NIC -> 물리 네트워크 (TSO 분할은 여기서)
qdisc는 단순한 FIFO가 아닙니다. 현대 리눅스의 기본값인 fq_codel은 플로우별 공정 큐잉과 CoDel AQM(능동 큐 관리)을 결합해 버퍼블로트를 억제합니다. BBR을 쓴다면 페이싱을 지원하는 fq 계열이 권장됩니다(최신 커널은 TCP 내장 페이싱도 지원합니다).
현재 qdisc 확인
tc qdisc show dev eth0
fq로 교체 (BBR 페이싱과 궁합)
tc qdisc replace dev eth0 root fq
qdisc 통계: 드롭, backlog 확인
tc -s qdisc show dev eth0
송신 쪽에서 또 하나 알아둘 것은 qdisc 우회 큐 한도인 TX 링버퍼 크기와, TCP small queue(TSQ)입니다. TSQ는 한 소켓이 qdisc/드라이버 큐를 독점하지 못하게 소켓당 in-flight 바이트를 제한합니다.
XDP — 스택에 들어가기 전에 처리한다
XDP(eXpress Data Path)는 eBPF 프로그램을 드라이버의 가장 이른 지점, 즉 sk_buff가 만들어지기도 전에 실행하는 기술입니다.
일반 경로:
NIC -> DMA -> [skb 할당] -> GRO -> netif_receive_skb -> netfilter
-> IP -> TCP -> 소켓 (단계마다 비용 누적)
XDP 경로:
NIC -> DMA -> [XDP 프로그램 실행]
|-- XDP_DROP: 즉시 폐기 (skb 할당조차 없음)
|-- XDP_TX: 같은 NIC으로 즉시 반사
|-- XDP_REDIRECT: 다른 NIC/CPU/AF_XDP 소켓으로
+-- XDP_PASS: 일반 스택으로 통과
XDP가 빠른 이유는 단순합니다. 일반 경로에서 패킷 하나당 발생하는 비용 — skb 할당과 초기화, 메모리 회수, 프로토콜 계층 통과, netfilter 평가 — 을 전부 건너뛰기 때문입니다. DDoS 차단처럼 "대부분의 패킷을 버리는" 워크로드에서는 일반 스택 대비 수 배에서 십 배 이상의 처리량 차이가 납니다. Cilium, Katran(페이스북의 L4 로드밸런서), Cloudflare의 DDoS 방어가 모두 XDP 기반입니다.
모드는 세 가지입니다. 드라이버가 지원하는 native 모드, NIC이 직접 실행하는 offloaded 모드, 그리고 스택 진입 후 실행되어 성능 이점이 적은 generic(skb) 모드입니다. 성능 목적이라면 native 지원 드라이버인지부터 확인해야 합니다.
XDP 프로그램 적재 상태 확인
ip link show dev eth0 # prog/xdp 표시 여부
bpftool net list
소켓 버퍼와 핵심 sysctl 튜닝
소켓 큐는 패킷 여정의 종착역이자 마지막 드롭 지점입니다. 주요 파라미터를 표로 정리합니다.
| 파라미터 | 기본값(대략) | 의미 |
| --- | --- | --- |
| net.core.rmem_max | 212992 | 소켓 수신 버퍼 상한(바이트) |
| net.core.wmem_max | 212992 | 소켓 송신 버퍼 상한 |
| net.ipv4.tcp_rmem | 4096 131072 6291456 | TCP 수신 버퍼 min default max, 자동 튜닝 범위 |
| net.ipv4.tcp_wmem | 4096 16384 4194304 | TCP 송신 버퍼 min default max |
| net.core.netdev_max_backlog | 1000 | RPS/비NAPI 경로의 CPU별 backlog 큐 길이 |
| net.core.somaxconn | 4096 | accept 큐(완료된 연결) 길이 상한 |
| net.ipv4.tcp_max_syn_backlog | 1024 | SYN 큐(미완료 연결) 길이 |
| net.ipv4.tcp_congestion_control | cubic | 혼잡 제어 알고리즘 |
| net.ipv4.tcp_notsent_lowat | 4294967295 | 미전송 데이터 임계(저지연 튜닝용) |
대역폭-지연 곱(BDP)이 큰 환경, 예를 들어 RTT 100ms에 10Gbps 경로라면 이론상 약 125MB의 윈도우가 필요합니다. tcp_rmem의 max를 충분히 키우지 않으면 처리량이 윈도우에 묶입니다.
고대역폭/고RTT 환경 예시 (/etc/sysctl.d/90-network.conf)
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
net.ipv4.tcp_rmem = 4096 262144 134217728
net.ipv4.tcp_wmem = 4096 262144 134217728
net.core.netdev_max_backlog = 16384
net.core.somaxconn = 8192
중요한 함정 하나. tcp_rmem/tcp_wmem의 max는 rmem_max/wmem_max와 별개로 동작합니다(TCP 자동 튜닝은 tcp_rmem을 따릅니다). 반면 애플리케이션이 setsockopt로 SO_RCVBUF를 직접 지정하면 rmem_max에 잘리고, 동시에 TCP 자동 튜닝이 꺼집니다. "버퍼를 키운다고 setsockopt를 박아 넣었더니 오히려 느려졌다"는 사례의 전형적인 원인입니다.
TCP 내부 한 걸음 — 혼잡 제어와 BBR
TCP의 전송 속도는 수신자 윈도우와 혼잡 윈도우(cwnd) 중 작은 쪽이 결정합니다. 혼잡 제어는 모듈로 구현되어 교체할 수 있습니다.
| 알고리즘 | 신호 | 특성 |
| --- | --- | --- |
| CUBIC(기본) | 패킷 손실 | 손실 기반, 버퍼를 채우는 경향, 무난한 기본값 |
| BBR | 전송률과 RTT 측정 | 손실에 둔감, 버퍼블로트 회피, 장거리/손실 환경에 강함 |
| DCTCP | ECN 마킹 | 데이터센터 전용, 스위치 ECN 설정 필요 |
BBR은 경로의 병목 대역폭과 최소 RTT를 지속적으로 추정해서, 손실이 나기 전에 그 추정값에 맞춰 페이싱합니다. 무선 구간이나 국제 구간처럼 손실이 혼잡과 무관하게 발생하는 환경에서 CUBIC보다 훨씬 안정적인 처리량을 냅니다.
사용 가능한 혼잡 제어 모듈 확인
sysctl net.ipv4.tcp_available_congestion_control
BBR 활성화 (fq qdisc와 함께 권장)
modprobe tcp_bbr
sysctl -w net.ipv4.tcp_congestion_control=bbr
sysctl -w net.core.default_qdisc=fq
연결별 실제 사용 알고리즘과 상태 확인
ss -tin | grep -E "bbr|cubic"
ss -ti 출력에서 cwnd, rtt, retrans, pacing_rate를 보면 연결 한 개의 건강 상태를 거의 다 파악할 수 있습니다. retrans가 꾸준히 늘면 경로 손실, cwnd가 작게 고정되어 있으면 혼잡 제어나 버퍼 제한을 의심합니다.
컨테이너 네트워킹 — 패킷의 추가 여정
컨테이너 환경에서는 위 경로에 단계가 더 붙습니다. 전형적인 브리지 + veth 구성의 수신 경로입니다.
NIC -> 호스트 스택 (IP/netfilter, NAT/conntrack)
-> 브리지 또는 라우팅 판단
-> veth 호스트측 끝에 송신
-> veth 컨테이너측 끝에서 "다시 수신" (softirq 재진입)
-> 컨테이너 netns의 IP/TCP 스택 통과
-> 컨테이너 내 소켓
veth 쌍은 한쪽에 보낸 패킷이 반대쪽에 수신되는 가상 케이블입니다. 문제는 이 "다시 수신" 때문에 프로토콜 스택 통과, netfilter 평가가 추가로 일어난다는 점입니다. 여기에 kube-proxy의 iptables 규칙과 conntrack까지 더해지면 네임스페이스 경계당 수 마이크로초에서 수십 마이크로초의 오버헤드가 쌓입니다. Cilium 같은 eBPF 기반 CNI가 netfilter 경로를 우회하고 veth 간 리다이렉트를 최적화하는 것이 바로 이 비용을 줄이기 위해서입니다.
진단할 때는 "어느 네임스페이스의 어느 인터페이스에서" 문제가 나는지 좁히는 것이 우선입니다.
컨테이너 netns에 들어가 인터페이스 통계 보기
nsenter -t 12345 -n ip -s link
nsenter -t 12345 -n ss -tin
conntrack 테이블 포화 확인 (가득 차면 새 연결이 드롭됨)
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
dmesg | grep conntrack
관측 도구 모음
각 단계를 들여다보는 도구를 계층별로 정리합니다.
1. NIC/드라이버 수준: 하드웨어 카운터 (드롭, 오류, 큐별 통계)
ethtool -S eth0 | grep -iE "drop|err|miss|fifo"
ip -s link show eth0 # rx_dropped, rx_missed 등
2. softirq 수준: budget 소진, backlog 드롭
cat /proc/net/softnet_stat # 1열 처리수, 2열 드롭, 3열 time_squeeze
3. 프로토콜 수준: 스택 내부 카운터의 보고
nstat -az | grep -iE "drop|retrans|listen|prune|collapse"
cat /proc/net/snmp # IP/TCP/UDP 기본 카운터
cat /proc/net/netstat # TcpExt 확장 카운터
4. 소켓 수준: 연결별 상태
ss -tinm # TCP 내부 상태 + 메모리(skmem)
ss -lnt # 리슨 소켓의 accept 큐 사용량(Recv-Q/Send-Q)
5. 커널 함수 수준: eBPF
커널이 패킷을 버리는 모든 지점과 사유 추적 (BCC)
dropwatch 또는 bcc의 tcpdrop
bpftrace -e 'tracepoint:skb:kfree_skb { @[args->reason] = count(); }'
특히 커널의 kfree_skb 트레이스포인트는 드롭 사유(reason) 필드를 제공하므로, "어디서 왜 버려졌는가"를 추측이 아니라 측정으로 답할 수 있습니다.
진단 시나리오 1 — 패킷 드롭 추적
상황: 모니터링에서 노드의 rx_dropped가 증가하고 애플리케이션 타임아웃이 간헐적으로 발생합니다. 바깥(NIC)에서 안(소켓)으로 단계별로 좁힙니다.
1단계, NIC/링버퍼를 봅니다.
ethtool -S eth0 | grep -iE "rx.*(drop|miss|no_buf|fifo)"
ethtool -g eth0 # 링버퍼 현재/최대 크기
rx_missed나 fifo 계열 카운터가 늘고 있다면 링버퍼에서 못 받아낸 것입니다. 링버퍼를 키우고(ethtool -G eth0 rx 4096), 인터럽트 coalescing과 IRQ 분산을 점검합니다.
2단계, softirq 단계를 봅니다.
awk '{print NR-1, "drops:", strtonum("0x"$2), "squeeze:", strtonum("0x"$3)}' \
/proc/net/softnet_stat
2열(드롭)이 늘면 netdev_max_backlog 부족, 3열(squeeze)이 늘면 budget 부족 또는 특정 CPU 과부하입니다. RPS로 분산하거나 backlog/budget을 키웁니다.
3단계, 프로토콜/소켓 단계를 봅니다.
nstat -az | grep -iE "ListenDrops|ListenOverflows|PruneCalled|RcvCollapsed"
ss -lnt # 리슨 소켓 Recv-Q가 Send-Q(somaxconn 한도)에 붙어 있는지
ListenOverflows가 늘면 accept 큐 포화입니다. 애플리케이션의 accept 처리 속도, somaxconn과 리슨 backlog 인자를 함께 봅니다. PruneCalled/RcvCollapsed는 수신 버퍼 압박 신호로 tcp_rmem을 검토합니다.
4단계, 그래도 못 찾으면 kfree_skb 사유를 직접 셉니다.
bpftrace -e 'tracepoint:skb:kfree_skb /args->reason > 2/
{ @[args->reason] = count(); } interval:s:5 { print(@); clear(@); }'
NETFILTER_DROP이면 방화벽 규칙, NO_SOCKET이면 잘못된 목적지나 레이스, SOCKET_RCVBUFF면 수신 버퍼 부족 — 사유 코드가 바로 다음 행동을 알려줍니다.
진단 시나리오 2 — 지연(latency) 분석
상황: 처리량은 정상인데 p99 응답 시간이 주기적으로 튑니다. 지연은 "어느 구간에서 시간이 쌓이는가"의 문제이므로 구간별로 자릅니다.
1단계, 네트워크 구간인지 호스트 구간인지 분리합니다.
ss -ti '( dport = :443 )' # rtt 평균/편차, retrans 확인
ping -c 100 target-host # 기본 RTT와 편차(mdev)
ss의 rtt가 ping보다 훨씬 크고 변동이 심하면 호스트(송수신 큐, 스케줄링) 쪽 가능성이 큽니다. retrans가 함께 늘면 경로 손실로 재전송 타임아웃이 지연을 만드는 경우입니다.
2단계, 호스트 내부라면 softirq 지연과 큐 적체를 봅니다.
softirq 처리 지연 분포 (BCC)
softirqs -d 10 1
큐 적체: qdisc backlog와 TX 드롭
tc -s qdisc show dev eth0
소켓 버퍼 적체: Recv-Q/Send-Q가 0이 아닌 연결
ss -tnp | awk '$2>0 || $3>0'
Recv-Q가 쌓여 있다면 커널은 데이터를 줬는데 애플리케이션이 못 읽는 것입니다. 이 경우 네트워크가 아니라 애플리케이션 스레드 부족이나 CPU 경합(스케줄링 지연)이 원인입니다. perf sched나 runqlat(BCC)로 런큐 지연을 확인합니다.
3단계, 주기성이 있다면 범인을 시간축으로 잡습니다. GC 주기, cron, 백업 트래픽, CPU 절전 상태(C-state) 진입 등이 흔한 원인입니다. cpupower idle-info로 깊은 C-state 지연을 확인하고, 저지연이 필수면 커널 부트 파라미터나 PM QoS로 제한합니다.
함정과 안티패턴
- 모든 sysctl을 한 번에 바꾸는 것. 한 번에 하나씩 바꾸고 측정해야 인과를 알 수 있습니다.
- 인터넷의 "마법 튜닝 모음"을 그대로 적용하는 것. somaxconn 65535 같은 값은 워크로드에 따라 메모리 낭비이거나 문제를 가릴 수 있습니다.
- setsockopt로 SO_RCVBUF를 고정해 TCP 자동 튜닝을 끄는 것(위에서 설명).
- tcpdump에서 거대 패킷을 보고 "MTU 설정 오류"로 오판하는 것. GRO/TSO의 정상 동작입니다.
- 컨테이너 환경에서 호스트 netns의 카운터만 보는 것. 문제의 절반은 컨테이너 netns 안에 있습니다.
- conntrack을 잊는 것. NAT를 쓰는 노드의 고연결 워크로드에서 nf_conntrack_max 포화는 단골 장애 원인입니다.
- irqbalance와 수동 IRQ 어피니티를 동시에 쓰는 것. 서로 설정을 덮어씁니다.
운영 체크리스트
- [ ] ethtool -S의 드롭/미스 카운터를 모니터링에 수집하고 있는가
- [ ] /proc/net/softnet_stat의 드롭과 squeeze를 노드 지표로 보고 있는가
- [ ] nstat의 ListenOverflows, RetransSegs를 알람 대상으로 두었는가
- [ ] RX 큐 수와 IRQ 어피니티가 CPU 토폴로지에 맞게 분산되어 있는가
- [ ] 링버퍼 크기가 트래픽 버스트에 충분한가 (ethtool -g)
- [ ] BDP 계산 기반으로 tcp_rmem/tcp_wmem max를 산정했는가
- [ ] 혼잡 제어(cubic/bbr)와 qdisc(fq/fq_codel) 조합을 의도적으로 선택했는가
- [ ] conntrack 사용량과 max를 모니터링하는가
- [ ] 컨테이너 netns 내부 지표를 수집할 수단(nsenter, eBPF)이 있는가
- [ ] 변경은 한 번에 하나, 전후 측정과 함께 기록하는가
마치며
패킷의 여정을 따라가 보면, 리눅스 네트워킹 스택은 "인터럽트를 줄이고(NAPI, coalescing), 묶어서 처리하고(GRO/TSO), 여러 코어로 나누고(RSS/RPS/RFS), 필요하면 스택 자체를 건너뛰는(XDP)" 일관된 설계 철학으로 진화해 왔음을 알 수 있습니다. 성능 문제를 만났을 때 이 지도를 떠올리며 NIC에서 소켓까지 단계별 카운터를 확인하면, 감이 아니라 측정으로 병목을 찾을 수 있습니다. 다음 글에서는 이 여정의 CPU 쪽 짝꿍인 스케줄러를 다룹니다.
참고 자료
- 커널 네트워킹 스케일링 문서 (RSS/RPS/RFS): https://www.kernel.org/doc/html/latest/networking/scaling.html
- NAPI 공식 문서: https://docs.kernel.org/networking/napi.html
- 세그멘테이션 오프로드 (GSO/GRO/TSO): https://docs.kernel.org/networking/segmentation-offloads.html
- AF_XDP 문서: https://docs.kernel.org/networking/af_xdp.html
- tcp(7) man 페이지 — sysctl 전체 목록: https://man7.org/linux/man-pages/man7/tcp.7.html
- ss(8) man 페이지: https://man7.org/linux/man-pages/man8/ss.8.html
- eBPF 공식 사이트: https://ebpf.io
- BCC 도구 모음 (tcpdrop, runqlat 등): https://github.com/iovisor/bcc
- Cilium BPF/XDP 레퍼런스 가이드: https://docs.cilium.io/en/stable/reference-guides/bpf/
- BBR 논문 (ACM Queue): https://queue.acm.org/detail.cfm?id=3022184
- CUBIC RFC 9438: https://datatracker.ietf.org/doc/html/rfc9438
- tc(8) man 페이지: https://man7.org/linux/man-pages/man8/tc.8.html
현재 단락 (1/296)
웹 서버의 p99 지연이 갑자기 튀거나, 모니터링에서 패킷 드롭 카운터가 올라가기 시작할 때, 많은 엔지니어가 애플리케이션 코드부터 의심합니다. 하지만 실제로는 패킷이 애플리케이션...