리눅스에서 초당 100만 개의 UDP 패킷을 수신하는 프로그램을 작성하기 위해 멀티-큐 NIC, 해시 기반 분산, NUMA, SO_REUSEPORT 등을 활용해 성능 한계를 탐구한다.
URL: https://blog.cloudflare.com/how-to-receive-a-million-packets/
Title: 초당 100만 패킷을 수신하는 방법
2015-06-16
6 min read

지난주, 가벼운 대화 중에 동료가 이렇게 말하는 걸 우연히 들었습니다. “리눅스 네트워크 스택은 느려요! 코어당 초당 5만 패킷(pps) 이상은 기대할 수 없죠!”
생각이 많아졌습니다. 실용적인 애플리케이션에서 코어당 50kpps가 아마 한계라는 데에는 동의하지만, 리눅스 네트워킹 스택이 실제로 할 수 있는 능력은 어느 정도일까요? 좀 더 재미있게 다시 물어봅시다:
리눅스에서 초당 100만 개의 UDP 패킷을 수신하는 프로그램을 작성하는 건 얼마나 어려울까?
이 질문에 답하는 과정이 현대 네트워킹 스택 설계를 이해하는 좋은 урок(교훈)이 되었으면 합니다.
CC BY-SA 2.0image by Bob McCaffrey
먼저 다음을 가정합니다:
초당 바이트(Bps)를 측정하는 것보다 초당 패킷(pps)을 측정하는 것이 훨씬 흥미롭습니다. Bps는 더 나은 파이프라이닝과 더 긴 패킷을 보내는 것으로 쉽게 높일 수 있지만, pps를 올리는 것은 훨씬 어렵습니다.
pps에 관심이 있으므로, 실험에서는 짧은 UDP 메시지를 사용합니다. 정확히는 UDP 페이로드 32바이트입니다. 이는 이더넷 계층에서 74바이트를 의미합니다.
실험에는 물리 서버 두 대를 사용합니다: “receiver(수신기)”와 “sender(송신기)”.
두 서버 모두 2GHz Xeon 6코어 프로세서 2개를 탑재했습니다. 하이퍼스레딩(HT)을 활성화하면 각 박스에서 24개의 프로세서로 보입니다. 또한 Solarflare의 멀티-큐 10G 네트워크 카드를 사용하며, 수신 큐 11개가 설정되어 있습니다. 이는 뒤에서 더 설명하겠습니다.
테스트 프로그램의 소스 코드는 여기에서 확인할 수 있습니다: udpsender, udpreceiver.
UDP 패킷에는 포트 4321을 사용하겠습니다. 시작하기 전에 트래픽이 iptables에 의해 방해받지 않도록 해야 합니다:
receiver$ iptables -I INPUT 1 -p udp --dport 4321 -j ACCEPT
receiver$ iptables -t raw -I PREROUTING 1 -p udp --dport 4321 -j NOTRACK
명시적으로 정의한 IP 주소 몇 개는 나중에 유용해집니다:
receiver$ for i in `seq 1 20`; do \
ip addr add 192.168.254.$i/24 dev eth2; \
done
sender$ ip addr add 192.168.254.30/24 dev eth3
먼저 가장 단순한 실험부터 해봅시다. 순진하게 송수신했을 때 얼마나 많은 패킷이 전달될까요?
송신기 의사 코드:
fd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
fd.bind(("0.0.0.0", 65400)) # 비결정성을 줄이기 위해 소스 포트를 고정
fd.connect(("192.168.254.1", 4321))
while True:
fd.sendmmsg(["\x00" * 32] * 1024)
일반적인 send 시스템 콜을 쓸 수도 있었지만, 효율적이지 않습니다. 커널로의 컨텍스트 스위칭에는 비용이 들기 때문에 가능하면 피하는 것이 좋습니다. 다행히 최근 리눅스에 유용한 시스템 콜이 추가되었습니다: sendmmsg. 이를 사용하면 한 번에 많은 패킷을 보낼 수 있습니다. 여기서는 한 번에 1,024개 패킷을 보내겠습니다.
수신기 의사 코드:
fd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
fd.bind(("0.0.0.0", 4321))
while True:
packets = [None] * 1024
fd.recvmmsg(packets, MSG_WAITFORONE)
마찬가지로 recvmmsg는 흔히 쓰는 recv 시스템 콜의 더 효율적인 버전입니다.
실제로 실행해 봅시다:
sender$ ./udpsender 192.168.254.1:4321
receiver$ ./udpreceiver1 0.0.0.0:4321
0.352M pps 10.730MiB / 90.010Mb
0.284M pps 8.655MiB / 72.603Mb
0.262M pps 7.991MiB / 67.033Mb
0.199M pps 6.081MiB / 51.013Mb
0.195M pps 5.956MiB / 49.966Mb
0.199M pps 6.060MiB / 50.836Mb
0.200M pps 6.097MiB / 51.147Mb
0.197M pps 6.021MiB / 50.509Mb
순진한 방식으로는 197k~350k pps 정도가 나옵니다. 나쁘지 않지만 변동성이 꽤 큽니다. 이는 커널이 우리 프로그램을 코어 사이에서 이리저리 옮기기 때문입니다. 프로세스를 CPU에 고정(pin)하면 도움이 됩니다:
sender$ taskset -c 1 ./udpsender 192.168.254.1:4321
receiver$ taskset -c 1 ./udpreceiver1 0.0.0.0:4321
0.362M pps 11.058MiB / 92.760Mb
0.374M pps 11.411MiB / 95.723Mb
0.369M pps 11.252MiB / 94.389Mb
0.370M pps 11.289MiB / 94.696Mb
0.365M pps 11.152MiB / 93.552Mb
0.360M pps 10.971MiB / 92.033Mb
이제 커널 스케줄러가 프로세스를 지정된 CPU에 유지합니다. 이는 프로세서 캐시 지역성(locality)을 개선하고 숫자를 더 일관되게 만들어 줍니다. 우리가 원하던 바입니다.
순진한 프로그램으로 370k pps를 내는 것은 나쁘지 않지만, 여전히 목표인 1Mpps에는 꽤 멉니다. 더 많이 받으려면 먼저 더 많이 보내야 합니다. 두 스레드에서 독립적으로 보내면 어떨까요?
sender$ taskset -c 1,2 ./udpsender \
192.168.254.1:4321 192.168.254.1:4321
receiver$ taskset -c 1 ./udpreceiver1 0.0.0.0:4321
0.349M pps 10.651MiB / 89.343Mb
0.354M pps 10.815MiB / 90.724Mb
0.354M pps 10.806MiB / 90.646Mb
0.354M pps 10.811MiB / 90.690Mb
수신 측 숫자는 늘지 않았습니다. ethtool -S를 보면 패킷이 실제로 어디로 갔는지 알 수 있습니다:
receiver$ watch 'sudo ethtool -S eth2 |grep rx'
rx_nodesc_drop_cnt: 451.3k/s
rx-0.rx_packets: 8.0/s
rx-1.rx_packets: 0.0/s
rx-2.rx_packets: 0.0/s
rx-3.rx_packets: 0.5/s
rx-4.rx_packets: 355.2k/s
rx-5.rx_packets: 0.0/s
rx-6.rx_packets: 0.0/s
rx-7.rx_packets: 0.5/s
rx-8.rx_packets: 0.0/s
rx-9.rx_packets: 0.0/s
rx-10.rx_packets: 0.0/s
이 통계에 따르면 NIC는 RX 큐 #4로 약 350kpps를 성공적으로 전달했습니다. rx_nodesc_drop_cnt는 Solarflare 전용 카운터로, NIC가 커널에 450kpps를 전달하지 못했음을 의미합니다.
때로는 왜 패킷이 전달되지 않았는지 명확하지 않을 때도 있지만, 이 경우는 매우 분명합니다. RX 큐 #4는 CPU #4로 패킷을 전달합니다. 그리고 CPU #4는 더 이상 일을 할 수 없습니다. 350kpps를 읽는 것만으로 완전히 바쁘기 때문입니다. htop에서는 이렇게 보입니다:

과거 네트워크 카드는 하드웨어와 커널 사이에서 패킷을 주고받는 단일 RX 큐만 가지고 있었습니다. 이 설계의 명백한 한계는 단일 CPU가 처리할 수 있는 양보다 더 많은 패킷을 전달할 수 없다는 점입니다.
멀티코어 시스템을 활용하기 위해 NIC는 여러 RX 큐를 지원하기 시작했습니다. 설계는 간단합니다. 각 RX 큐를 별도의 CPU에 고정(pin)하고, 모든 RX 큐로 패킷을 전달함으로써 NIC는 모든 CPU를 활용할 수 있습니다. 하지만 질문이 생깁니다. 특정 패킷이 주어졌을 때 NIC는 어떤 RX 큐로 넣을지 어떻게 결정할까요?

라운드 로빈 밸런싱은 단일 연결 내에서 패킷 재정렬(reordering)을 유발할 수 있으므로 적절하지 않습니다. 대안은 패킷에서 해시를 계산해 RX 큐 번호를 결정하는 것입니다. 해시는 보통 (src IP, dst IP, src port, dst port) 튜플에서 계산됩니다. 이렇게 하면 단일 플로우의 패킷은 항상 정확히 같은 RX 큐로 가므로, 단일 플로우 내에서 패킷 재정렬이 발생하지 않습니다.
우리 경우 해시는 다음처럼 쓰일 수 있습니다:
RX_queue_number = hash('192.168.254.30', '192.168.254.1', 65400, 4321) % number_of_queues
해시 알고리즘은 ethtool로 설정할 수 있습니다. 우리 환경에서는 다음과 같습니다:
receiver$ ethtool -n eth2 rx-flow-hash udp4
UDP over IPV4 flows use these fields for computing Hash flow key:
IP SA
IP DA
이는 “IPv4 UDP 패킷에 대해 NIC는 (src IP, dst IP) 주소만 해싱한다”는 뜻입니다. 즉:
RX_queue_number = hash('192.168.254.30', '192.168.254.1') % number_of_queues
이는 포트 번호를 무시하므로 꽤 제한적입니다. 많은 NIC는 해시를 커스터마이징할 수 있습니다. 다시 ethtool을 사용해 (src IP, dst IP, src port, dst port) 튜플로 해싱하도록 선택할 수 있습니다:
receiver$ ethtool -N eth2 rx-flow-hash udp4 sdfn
Cannot change RX network flow hashing options: Operation not supported
불행히도 우리 NIC는 이를 지원하지 않습니다. (src IP, dst IP) 해싱으로 제한됩니다.
지금까지 모든 패킷은 오직 하나의 RX 큐로 흐르고 하나의 CPU만 사용합니다. 이를 기회로 서로 다른 CPU의 성능을 벤치마크해 봅시다. 우리 수신기 호스트에는 두 개의 별도 프로세서 뱅크가 있고, 각각은 다른 NUMA 노드입니다.
단일 스레드 수신기를 우리 환경에서 흥미로운 네 개의 CPU 중 하나에 고정할 수 있습니다. 네 가지 옵션은 다음과 같습니다:
RX 큐와 같은 NUMA 노드에 있으나 다른 CPU에서 수신기를 실행합니다. 위에서 본 것처럼 성능은 약 360kpps입니다.
RX 큐와 정확히 같은 CPU에서 수신기를 실행하면 최대 ~430kpps까지 가능합니다. 하지만 변동성이 커집니다. NIC가 패킷으로 과부하되면 성능이 0으로 떨어지기도 합니다.
RX 큐를 처리하는 CPU의 HT(하이퍼스레드) 쌍에서 수신기를 실행하면 성능은 일반의 절반인 약 200kpps입니다.
RX 큐와 다른 NUMA 노드의 CPU에서 수신기를 실행하면 ~330kpps입니다. 다만 수치가 그리 일관되지는 않습니다.
다른 NUMA 노드에서 실행할 때 10% 페널티가 그리 나쁘지 않아 보일 수 있지만, 규모가 커지면 문제는 더 심해집니다. 어떤 테스트에서는 코어당 250kpps만 겨우 끌어낼 수 있었습니다. NUMA를 가로지르는 모든 테스트에서 변동성도 컸습니다. 높은 처리량에서는 NUMA 노드 간 성능 페널티가 더욱 두드러집니다. 한 테스트에서는 “나쁜” NUMA 노드에서 수신기를 실행했을 때 4배 성능 페널티가 나오기도 했습니다.
우리 NIC의 해싱 알고리즘이 매우 제한적이므로, 패킷을 여러 RX 큐로 분산시키는 유일한 방법은 많은 IP 주소를 사용하는 것입니다. 서로 다른 목적지 IP로 패킷을 보내는 방법은 다음과 같습니다:
sender$ taskset -c 1,2 ./udpsender 192.168.254.1:4321 192.168.254.2:4321
ethtool로 보면 패킷이 서로 다른 RX 큐로 가는 것이 확인됩니다:
receiver$ watch 'sudo ethtool -S eth2 |grep rx'
rx-0.rx_packets: 8.0/s
rx-1.rx_packets: 0.0/s
rx-2.rx_packets: 0.0/s
rx-3.rx_packets: 355.2k/s
rx-4.rx_packets: 0.5/s
rx-5.rx_packets: 297.0k/s
rx-6.rx_packets: 0.0/s
rx-7.rx_packets: 0.5/s
rx-8.rx_packets: 0.0/s
rx-9.rx_packets: 0.0/s
rx-10.rx_packets: 0.0/s
수신 측은 다음과 같습니다:
receiver$ taskset -c 1 ./udpreceiver1 0.0.0.0:4321
0.609M pps 18.599MiB / 156.019Mb
0.657M pps 20.039MiB / 168.102Mb
0.649M pps 19.803MiB / 166.120Mb
좋습니다! RX 큐를 처리하는 코어 2개와 애플리케이션을 실행하는 코어 1개로 ~650k pps가 가능합니다!
세 개 또는 네 개의 RX 큐로 트래픽을 보내면 이 수치는 더 올릴 수 있지만, 곧 애플리케이션이 또 다른 한계에 부딪힙니다. 이번에는 rx_nodesc_drop_cnt는 증가하지 않지만 netstat의 “receiver errors”가 증가합니다:
receiver$ watch 'netstat -s --udp'
Udp:
437.0k/s packets received
0.0/s packets to unknown port received.
386.9k/s packet receive errors
0.0/s packets sent
RcvbufErrors: 123.8k/s
SndbufErrors: 0
InCsumErrors: 0
이는 NIC가 패킷을 커널로 전달할 수는 있지만, 커널이 애플리케이션에 패킷을 전달하지 못한다는 뜻입니다. 이 경우 커널은 440kpps만 전달할 수 있고, 나머지 390kpps + 123kpps는 애플리케이션이 충분히 빠르게 받지 못해 드롭됩니다.
수신 애플리케이션도 스케일아웃해야 합니다. 하지만 순진하게 하나의 소켓에서 여러 스레드로 수신하는 방식은 잘 동작하지 않습니다:
sender$ taskset -c 1,2 ./udpsender 192.168.254.1:4321 192.168.254.2:4321
receiver$ taskset -c 1,2 ./udpreceiver1 0.0.0.0:4321 2
0.495M pps 15.108MiB / 126.733Mb
0.480M pps 14.636MiB / 122.775Mb
0.461M pps 14.071MiB / 118.038Mb
0.486M pps 14.820MiB / 124.322Mb
단일 스레드 프로그램보다 수신 성능이 떨어졌습니다. 이는 UDP 수신 버퍼 쪽의 락 경합(lock contention) 때문입니다. 두 스레드가 같은 소켓 디스크립터를 사용하므로 UDP 수신 버퍼를 둘러싼 락을 놓고 과도하게 경쟁합니다. 이 문제는 이 논문에 더 자세히 설명되어 있습니다.
하나의 디스크립터에서 여러 스레드로 수신하는 것은 최적이 아닙니다.
다행히 최근 리눅스에 추가된 우회 방법이 있습니다: SO_REUSEPORT 플래그입니다. 이 플래그를 소켓 디스크립터에 설정하면, 리눅스는 여러 프로세스가 같은 포트에 바인드하는 것을 허용합니다. 사실상 원하는 만큼의 프로세스가 바인드할 수 있고, 부하는 그들 사이에 분산됩니다.
SO_REUSEPORT를 사용하면 각 프로세스가 별도의 소켓 디스크립터를 갖게 됩니다. 따라서 각 프로세스는 전용 UDP 수신 버퍼를 소유하게 되고, 앞서 겪었던 경합 문제가 사라집니다:
receiver$ taskset -c 1,2,3,4 ./udpreceiver1 0.0.0.0:4321 4 1
1.114M pps 34.007MiB / 285.271Mb
1.147M pps 34.990MiB / 293.518Mb
1.126M pps 34.374MiB / 288.354Mb
이제야 좀 그럴듯합니다! 처리량이 괜찮게 나옵니다!
추가로 살펴보면 더 개선할 여지가 있습니다. 수신 스레드를 4개 시작했지만, 부하가 균등하게 분산되지 않습니다:

두 스레드가 모든 일을 처리하고 나머지 두 스레드는 패킷을 전혀 받지 못했습니다. 이는 해시 충돌 때문인데, 이번에는 SO_REUSEPORT 계층에서 발생한 것입니다.
추가 테스트를 해보니, 단일 NUMA 노드에서 RX 큐와 수신 스레드를 완벽히 정렬(aligned)하면 1.4Mpps까지도 가능했습니다. 수신기를 다른 NUMA 노드에서 실행하면 수치는 떨어져, 잘해봐야 1Mpps를 달성했습니다.
요약하면, 완벽한 성능을 원한다면 다음이 필요합니다:
트래픽이 여러 RX 큐와 SO_REUSEPORT 프로세스에 균등하게 분산되도록 보장해야 합니다. 실제로는 연결(또는 플로우) 수가 충분히 많다면 부하는 보통 잘 분산됩니다.
커널에서 패킷을 실제로 “주워” 올 만큼의 여분 CPU 용량이 있어야 합니다.
더 어렵게 만드는 요소로, RX 큐와 수신 프로세스가 모두 단일 NUMA 노드에 있어야 합니다.
리눅스 머신에서 1Mpps 수신이 기술적으로 가능하다는 것을 보여주긴 했지만, 이 애플리케이션은 수신한 패킷에 대해 실제 처리를 전혀 하지 않았습니다. 트래픽 내용조차 들여다보지 않았습니다. 실용적인 애플리케이션에서 추가 작업 없이 이런 성능을 기대하긴 어렵습니다.
이런 종류의 저수준 고성능 패킷 처리에 관심이 있나요? CloudFlare는 런던, 샌프란시스코, 싱가포르에서 채용 중입니다.
Cloudflare의 커넥티비티 클라우드는 기업 전체 네트워크를 보호하고, 고객이 인터넷 규모 애플리케이션을 효율적으로 구축하도록 돕고, 어떤 웹사이트나 인터넷 애플리케이션이든 가속하며, DDoS 공격을 방어하고, 해커를 차단하며, Zero Trust 여정에도 도움을 줄 수 있습니다.
어떤 기기에서든 1.1.1.1에 접속해 인터넷을 더 빠르고 안전하게 만들어 주는 무료 앱을 시작해 보세요.
더 나은 인터넷을 만들기 위한 우리의 미션을 더 알고 싶다면 여기서 시작하세요. 새로운 커리어 방향을 찾고 있다면 채용 공고도 확인해 보세요.