softirq/NAPI와 RPS/RSS 패킷 분산

NIC가 패킷을 받을 때마다 매번 하드웨어 인터럽트를 걸면, 트래픽이 조금만 몰려도 CPU가 인터럽트 처리에만 매달리는 인터럽트 폭풍이 발생한다. 리눅스 네트워크 스택은 NAPI(poll 기반 수신)로 이 문제를 완화하는데, NAPI만으로는 NIC 큐가 1개뿐인 VM이나 특정 코어에 수신 처리가 몰리는 상황까지 해결하지 못한다. 이 글에서는 NAPI가 인터럽트를 억제하는 방식과 그 부하를 /proc/net/softnet_stat으로 확인하는 법, 그리고 RSS/RPS로 수신 처리를 여러 코어에 분산하는 방법을 정리한다.

NAPI가 인터럽트 폭풍을 막는 방식

첫 패킷 도착 시 hard IRQ가 한 번 발생하면 드라이버가 즉시 NIC 인터럽트를 끄고, 큐가 빌 때까지 softirq 컨텍스트에서 poll 방식으로 배치 처리한다. 한 poll에서 처리할 상한은 다음 sysctl로 정해진다.

$ sysctl net.core.netdev_budget net.core.netdev_budget_usecs net.core.netdev_max_backlog
net.core.netdev_budget = 300
net.core.netdev_budget_usecs = 2000
net.core.netdev_max_backlog = 1000
파라미터의미
netdev_budgetsoftirq 한 번의 poll 사이클에서 처리할 패킷 개수 상한(디바이스 전체 합산)
netdev_budget_usecs개수와 별개로 poll에 쓸 수 있는 시간 상한(마이크로초)
netdev_max_backlogRPS나 non-NAPI 드라이버가 쓰는 per-CPU 수신 큐의 최대 길이

softirq 부하 확인 — /proc/net/softnet_stat

$ cat /proc/net/softnet_stat
0000001f 00000000 00000001 00000000 00000000 00000000 00000000 00000000 00000000
000000a0 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
0000007c 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
00000024 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
0000000c 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
0000032c 00000000 0000003a 00000000 00000000 00000000 00000000 00000000 00000000

줄마다 CPU 하나에 대응하며(6코어라 6줄), 값은 전부 16진수 누적 카운터다. 자주 보는 앞쪽 세 컬럼만 정리하면 다음과 같다.

컬럼의미
1번째이 CPU가 처리한 패킷 수(processed)
2번째netdev_max_backlog를 넘겨 드롭된 패킷 수(dropped)
3번째netdev_budget/시간 상한을 다 쓰고도 큐가 안 비어 다음 스케줄로 넘긴 횟수(time_squeeze)

위 예시에서 CPU5의 3번째 컬럼이 0x3a(58)로 다른 코어보다 눈에 띄게 높다. time_squeeze가 계속 늘어난다면 해당 코어가 budget 안에서 다 처리하지 못할 만큼 수신 트래픽을 몰아서 받고 있다는 신호이고, netdev_budget 상향이나 아래에서 다룰 RPS로 분산을 검토할 시점이다.

RSS의 한계 — 큐가 1개뿐인 NIC

RSS(Receive Side Scaling)는 NIC 하드웨어가 여러 RX 큐를 갖고 플로우 해시로 큐를 나눠 큐마다 별도 IRQ를 걸어주는 방식이다. 그런데 이 6코어 VM의 NIC(enp0s3)는 애초에 큐 설정을 조회하는 것부터 실패한다.

$ ethtool -l enp0s3
netlink error: Operation not supported

VM에서 흔히 붙는 단일 큐 가상 NIC은 RSS 자체가 불가능한 하드웨어 구조라는 뜻이다. 이런 환경에서 수신 처리를 여러 코어로 나누려면 하드웨어가 아닌 커널 소프트웨어 계층인 RPS를 써야 한다.

RPS로 소프트웨어 분산하기

RPS(Receive Packet Steering)는 큐 하나로 들어온 패킷이라도 5-tuple 해시를 계산해 여러 CPU의 backlog 큐로 나눠 넣고, 각 CPU의 softirq가 나눠서 처리하게 만든다. 큐당 rps_cpus 비트마스크로 설정한다.

# 기본값: 비활성화(전부 0)
$ cat /sys/class/net/enp0s3/queues/rx-0/rps_cpus
00

# CPU 0~5 전체를 후보로 등록
$ echo 3f | sudo tee /sys/class/net/enp0s3/queues/rx-0/rps_cpus
$ cat /sys/class/net/enp0s3/queues/rx-0/rps_cpus
3f

이 값을 켠 뒤 iperf3 -P 4(4개 병렬 flow)로 loopback 부하를 걸며 mpstat -P ALL로 코어별 %soft를 관찰하면 여러 코어에 걸쳐 softirq 처리가 나타난다.

Average:     CPU    %usr   %nice    %sys %iowait   %irq   %soft  %idle
Average:     all    0.29    0.00    5.69    0.46   0.00    3.54   90.01
Average:       0    0.29    0.00    5.83    0.87   0.00    2.92   90.09
Average:       1    0.24    0.00    4.05    1.19   0.00    2.38   92.14
Average:       2    0.35    0.00    7.72    0.00   0.00    3.51   88.42
Average:       3    0.32    0.00    9.00    0.00   0.00    3.54   87.14
Average:       4    0.57    0.00    4.02    0.00   0.00    1.72   93.68
Average:       5    0.00    0.00    2.12    0.00   0.00    8.99   88.89

다만 4개 iperf3 flow가 서로 다른 코어에서 실행되며 생긴 자연 분산도 섞여 있어 RPS 단독 효과를 깔끔히 분리하지는 못했다(loopback이 너무 빨라 1초 단위 mpstat로는 단일 flow의 분산 과정을 포착하기 어려웠음). 실제 NIC의 지속적인 단일 flow 트래픽으로 검증하는 편이 더 명확하다.

주의사항

항목내용
RPS는 캐시 지역성 비용이 있다다른 코어로 패킷을 넘기는 과정에서 캐시 미스가 늘어날 수 있다. 코어 수가 적고 NIC 처리량이 이미 충분하면 굳이 켤 필요 없음
RFS와 함께 쓰면 효과적RPS만 켜면 애플리케이션이 실행 중인 코어와 다른 코어에서 패킷이 처리될 수 있다. rps_sock_flow_entries/rps_flow_cnt로 RFS(Receive Flow Steering)를 함께 설정하면 소켓을 쓰는 프로세스가 있는 코어로 우선 배정한다
time_squeeze 확인이 먼저RPS를 켜기 전에 /proc/net/softnet_stat의 3번째 컬럼이 실제로 늘고 있는지부터 확인할 것. 병목이 아닌데 켜면 캐시 지역성만 잃는다
재부팅 시 초기화rps_cpus도 sysfs 값이라 재부팅하면 초기화된다. 영구 적용은 udev 규칙이나 systemd 서비스로 부팅 스크립트화 필요

마무리

NAPI는 인터럽트 폭풍을 막아주지만 수신 처리를 여러 코어로 나눠주지는 않는다. 하드웨어 멀티큐(RSS)가 없는 환경에서는 RPS로 소프트웨어 분산을 대신할 수 있고, /proc/net/softnet_stattime_squeeze가 늘고 있는지를 먼저 확인한 뒤 필요한 만큼만 켜는 게 맞는 순서다.

참고

Kernel.org – Scaling in the Linux Networking Stack

답글 남기기