Contents

왜 더 좋은 NIC로 바꿨는데, 네트워크 성능이 안 좋아지죠?

그러게나 말이에요. 왜 사람 힘들게 만들까

미리 말씀드리지만, 저는 네트워크 엔지니어가 아니라 백엔드 엔지니어로서 짧은 시간 안에 이슈를 체크 및 리포트 중이라 조금이라도 깊게 들어갔을 때 틀렸을 수 있으니 크로스체크 부탁드립니다.

개요

무슨 일이 있었나

어쩌다 보니 현재 있는 조직에서 특정 서비스가 Tx 성능이 피크 타임에 부족하다는 의견이 있었고, 그로 인해 해당 서비스의 서버 그룹에 대해 NIC Bonding을 하는 것으로 결정되었습니다. 그래서 Broadcom의 10G NIC 2개를 묶어서 20G로 만들고, 서비스에 투입했습니다.

문제는 실서비스 운영 중에 발생했습니다. 초기부터 괜찮았던 것으로 보입니다. 과거 이력에 대해선 저도 1년 이상 지난 시점에 같이 보기 시작한 거라 얼핏 들은 것 뿐입니다. 그래서 어떤 문제였는가? 특정 조건이 성립하면 트래픽이 적음에도 불구하고 서비스에 지연이 발생하면서, Rx 패킷에 대한 드랍이 발생한다는 것이었습니다.

Rx 패킷에 대한 드랍?

배경 경로

Rx 패킷이 드랍되는 건 ifconfig를 통해 Rx Dropped 카운트가 증가함으로 확인되었습니다. 이미 발생한 곳도, 재차 테스트를 했던 곳도 명확하게 이 수치가 증가되어 있었습니다. 따라서 처음에는 저도 Rx 패킷 드랍에 대해서 주로 고려했습니다. 그럼 Rx 패킷 드랍은 언제 발생할까요?

일반적인 리눅스 서버일 경우에는 서버 장비가 Rx 패킷을 받기 위해 다음 경로를 통합니다:

  • Cable
  • NIC Rx Ring Buffer
  • Kernel Backlog
  • Kernel Socket Buffer
  • User Application Memory

각각의 경로에서 패킷 유실이 발생했을 때는 보통 이렇게 표현됩니다:

  • NIC Rx Ring Buffer => ethtool -S 상의 rx_discards
  • Kernel Backlog => cat /proc/net/softnet_stat의 두번째 열
  • Kernel Socket Buffer => netstat -sprune, overrun

기존에 확인된 ifconfigRx Dropped는 추가 확인을 통해 ethtool -S 상의 rx_discards로 확인되었습니다. 즉, 커널이 제때 가져가지 못해(Processing bottleneck), NIC 하드웨어 버퍼가 가득 찼다는 뜻입니다. 그리고 저희는 여기서 multiqueue라는 개념을 알아야 합니다.

multiqueue

최신 리눅스 서버는 성능이 매우 좋습니다. NIC로 10G 정도의 트래픽을 처리할 정도가 되면, 코어도 8개 이상 배치될 가능성이 높죠. 그리고 네트워크 패킷은 중간중간 매우 많은 인터럽트를 발생시킵니다. 그리고 이 인터럽트와 데이터 복제 등을 단일 코어가 담당했죠. 기존엔 코어가 해봐야 몇개 안 되었으니까요.

그래서 multiqueue(이하 mq) 기능이 추가되었습니다. 만약 NIC가 이 기능을 지원하면 저희는 위에서 확인된 경로 중 Rx Ring Buffer와 Kernel Backlog 구간(서로 다른 하드웨어의 연결 지점)을 각 코어 별로 병렬로 수행할 수 있게 됩니다.

이 방식은 기본적으로 여러 코어가 인터럽트를 동시에 낚아채며 일하는 방식을 사용할 수도 있습니다만, 최근에는 affinity를 이용해서 특정 코어에 특정 큐를 할당할 수 있습니다. 그리고 이 큐는 Rx/Tx에 대응되는 인터럽트를 공유합니다.

이 개념이 필요한 이유는 바로 ethtool -S가 해당 mq의 하위 큐 별로 rx_discards를 알려주기 때문입니다. 그리고 특정 2개의 큐에서만 rx_discards가 발생함을 확인할 수 있었습니다.

이는 몇가지 특이점을 시사해줍니다:

  1. NIC 자체가 문제인 것은 아니다.
  2. CPU 자체가 문제인 것은 아니다.
  3. 어플리케이션 자체가 문제인 것은 아니다.
  4. 왜? 문제가 되는 큐와 그 코어를 제외하곤 모든 게 정상적으로 돌아갔었으니까.

그럼 여기서 왜 Rx가 특정 큐에서만 밀릴 수 있는 지 확인이 필요했습니다. 이에 대한 가까운 답으로 가장 의심되었던 것은 mq의 특성 중 하나인 하나의 큐가 Rx/Tx에 대응되는 인터럽트를 공유한다는 점입니다. 쉽게 말해서, 만약 Tx가 너무 많아서 특정 큐가 바쁘게 되면 해당 큐로 들어오는 Rx는 제때 처리되지 못할 수 있다는 것입니다.

즉, Tx 재전송(Requeue)을 처리하느라 해당 코어의 CPU 자원(특히 SoftIRQ)이 고갈되어, 동일한 코어에서 처리해야 할 Rx Ring의 패킷을 제때 커널로 퍼올리지(Polling) 못해 NIC 하드웨어 버퍼에서 넘친 것입니다.

이에 대해서는 두가지 명령어로 확인할 수 있습니다만, 실시간으로 확인해야하는 단점이 있습니다:

  • /proc/interrupts: 현재 인터럽트가 각 코어 당 몇회씩 수행되고 있는 지 확인할 수 있습니다.
  • mpstat: 각 코어가 어떤 일에 힘을 쓰고 있는 지 확인할 수 있습니다. 이 경우에선 %irq, %soft를 확인하면 됩니다.

그래도 혹시?

만약 브로드컴 NIC를 사용하면서 별 다른 징조 없이 Rx 패킷에 대한 드랍 카운트가 서서히 올라간다면, TPA 기능을 꺼보는 것도 추천드립니다. 다만, 이 기능을 끌 경우 SoftIRQ가 증가하여 CPU 사용량이 증가할 수 있으니 사양에 따라 결정해야합니다.

ethtool -K <interface> lro off rx-gro-hw off

Tx 패킷에 대한 재시도!

배경 경로

이제 Tx 패킷의 여정에 대해 알아봐야 합니다. Tx 패킷에 대해선 이미 Rx에서 mq를 설명했으니 포함해서 진행하겠습니다:

  • User Application Memory
  • Kernel Socket Buffer
  • Multiqueue
    • Leaf Qdisc (SW Queue)
    • NIC Tx Ring Buffer
  • Cable

여기서 새로운 Leaf Qdisc가 추가되었는데, 이는 mq 내에 존재하는 소프트웨어적인 큐입니다. 이후 즉시 Tx Ring Buffer로 패킷이 이동하여 전송됩니다. 백로그와 그 역할을 유사하다고 생각하시면 됩니다.

여기까지 Rx와 Tx에 대한 대략적인 구성도입니다.

+---------------------+
|   User Application  |
+----------+----------+
           | (Tx Data)
           v
+---------------------+
|    Socket Buffer    |
+----------+----------+
           |
           v
+---------------------+        (1) Requeue Loop & Lock Contention
|  Qdisc (pfifo_fast) | <===================+
+----------+----------+                     |
           | (2) Tx Packet                  | (3) NETDEV_TX_BUSY
           v                                |
+-------------------------------------------+---------+
|      Driver / SoftIRQ (Specific CPU Core)           | <--- CPU Resource Exhausted!
+----------+--------------------------------+---------+
           |                                ^
           | (Tx)                           | (Rx Poll Attempt - Fail)
           v                                | x
+---------------------+           +---------+-----------+
|    Tx Ring Buffer   |           |    Rx Ring Buffer   |
+----------+----------+           +---------+-----------+
           |                                ^
           |                                |
  [ Network Wire ]                 [ Network Wire ]
                                            |
                                            | (Rx Packet Arrived)
                                    (Packet Dropped here due to Full Buffer)

이제 Tx 경로에서도 각 구간의 유실 여부를 확인할 수 있는 팩터를 알아보겠습니다:

  • Kernel Socket Buffer: 어플리케이션에서 에러(EAGAIN)가 발생합니다. 그리고 netstat -sss -s를 통해 각 섹션의 에러 카운트로 확인할 수도 있습니다.
  • Multiqueue
    • Leaf Qdisc (SW Queue): tc -s qdisc show dev에서 두가지 요소로 확인할 수 있습니다. dropped로 기록되면 큐가 가득참으로 버려진 패킷 수이며, requeue는 버리지 않고 다시 큐에 삽입하여 처리한 횟수를 의미합니다.
    • NIC Tx Ring Buffer: ethtool -S에서 tx_droppedtx_discards로 나타납니다.

그리고 각 패킷 드랍이 발생된 서버에서 확인할 수 있었던 요소는 tc -s qdisc show devrequeue였습니다. 많은 케이스에선 무려 24억회라는 큰 수치가 기록되어 있었습니다. 그리고 이 수치는 XPS(Transmit Packet Steering) 덕인지 매우 균등하게 각 큐에 할당되어 있습니다.

한 순간이지만, requeue가 매우 많이 발생할 때 모든 큐가 큰 부하를 받았다는 것이 됩니다. 그럼 왜 특정 큐만 Rx 패킷 드랍이 발생한 걸까요? 그리고 왜 Tx 패킷은 재시도가 매우 많이 발생했음에도 단 하나의 드랍도 발생하지 않았을까요?

그럼 왜 Tx 재전송 시도가 많았지?

여기서부터는 추측과 가정이 포함되어 있습니다.

의외로 Linux 5.15 이하와 bnxt_en 1.10.2 미만에 대해서 적지 않은 Tx 관련 이슈가 있었습니다. 이에 대해 모두 작성하면 좋겠지만, 제가 목격한 내용을 기반으로 이해되는 가능성 높은 것만 작성하겠습니다. 먼저 centos7에서 기본 설정으로 사용하고 있는 Qdiscpfifo_fast의 동작 원리를 확인할 필요가 있습니다.

pfifo_fast는 다음과 같은 순서로 패킷을 전송합니다:

  1. 3개의 밴드를 구성하여 우선 순위 순으로 0, 1, 2번째 밴드에 패킷을 큐잉
  2. 큐에 패킷이 채워지면 순서대로 전송
  3. 이때 패킷 전송 순서는 무조건 0번 밴드가 비워지면 1번 밴드의 전송이 시작되는 방식
  4. 각 밴드 내의 패킷 정렬은 이름에 있듯이 FIFO로 작동

간단한 만큼 이 큐는 매우 빠른 속도를 자랑합니다. 정상적으로 동작한다면 CPU 부하가 적다는 장점 또한 가지고 있죠. 단점은 모든 우선순위 큐가 그렇듯이 기아 현상이 발생할 수 있으며, 큐가 크기가 커지면 전송까지의 지연이 늘어나는 점도 있습니다.

그렇다면 이 pfifo_fast에서 Qdisc가 패킷을 꺼내어 드라이버로 전송했는데, NETDEV_TX_BUSY가 반환되면 어떻게 동작할까요? 그 순서는 다음과 같습니다:

  1. 해당 패킷이 원래 있던 자리인 큐의 맨 앞에 되돌려 놓습니다. (requeueing)
  2. 그리고 Qdisc는 해당 큐를 현재 사용할 수 없다고 마크합니다.
  3. 그 후 NIC가 비교적 한가해질 때까지, 즉 Tx Ring Buffer가 비워질 때까지 대기합니다.
  4. NIC는 버퍼가 비워지면 다시 Qdisc의 메서드를 호출하여 다시 전송할 것을 요청합니다.

그럼 만약 드라이버가 버퍼가 가득차지 않았음에도 NETDEV_TX_BUSY를 반환하거나, 비워지지 않았음에도 재전송 가능이라고 알려주거나, 자신의 상태를 제대로 파악하지 못 한다면 어떻게 될까요? Qdisc는 지속적인 NETDEV_TX_BUSY와 재전송 요청을 받고 혼란에 빠지게 될 것입니다. 그리고 저희는 무수한 requeue 카운트와 잦은 Qdisc Lock 경합(하나의 우선순위 큐를 공유하기에 발생하는 Global Lock에 대한 경합)과 많은 인터럽트에서 비롯된 높은 단일 혹은 소수 코어의 부하를 목격하게 됩니다.

그럼 왜 WATCHDOG이 반응하지 않았냐고 합리적으로 의심할 수 있습니다. 여기에 대한 답으로 저는 Tx 패킷에 대한 리큐잉은 있어도, Tx 패킷에 대한 드랍은 없었다는 사실을 근거로 사용하고 싶습니다. 즉, 해당 시점에 전송된 대량의 패킷에 리큐잉이 한번 이상 적용은 되었지만, 결과적으로 모두 전송은 성공했으며, 하나하나가 5초 내에는 처리되었다고 주장합니다.

이에 대해 특히 Broadcom NetXstreambnxt_en에 대해 다양한 커뮤니티의 글과 의견이 있었으나 제가 확인한 가능성이 높은 이슈는 TCP Segmentation Offload(이하 TSO)와 pfifo_fast입니다.

Tx 패킷 리큐잉에 대한 대응

fq_codel

fq_codel 큐는 Qdisc 내부 큐의 또 다른 구현입니다. pfifo_fast와 달리 공정한 트래픽을 목적으로 개발되었습니다. 이 큐의 이름은 Fair Queuing Controlled Delay의 줄임말입니다.

해당 큐는 pfifo_fast와 다음 부분이 다릅니다.

  • 다양한 플로우 큐가 내부에 존재하며, 이를 통해 공정성과 낮은 지연 시간을 확보합니다.
  • 이 부분은 단순 단일 경로로 3개의 밴드에서 패킷을 꺼내어 전송하는 pfifo_fast가 멀티코어 환경에서 GlobalLock에 대한 잦은 락 경합으로 성능이 낮아지는 단점을 보완합니다.
  • 무작정 뒤에 들어오는 패킷을 드랍시키지 않고, 고유의 알고리즘을 통해 능동적으로 패킷을 드랍시킵니다.
  • 트래픽 격리 기능이 있어, 앞에 보낼 수 없는 패킷이 있을 경우에 뒤에 보낼 수 있는 패킷을 먼저 보낼 수 있습니다.

pfifo_fast를 기본 설정으로 쓰고 있는 지금 제가 fq_codel로 변경해보는 것을 추천하는 이유는 이러한 요소 때문입니다. 무작정 재시도를 꾸준히 시도하지 않으며, 가능한 패킷부터 먼저 내보내고, 한번 밀린 패킷이 무한히 밀리는 것 또한 방지합니다.

TSO

TSO는 CPU 부하를 줄여주는 매우 훌륭한 기능입니다. MTU를 벗어나는 큰 데이터를 보낼 때 적절한 크기로 패킷을 분할하는 것을 NIC에서 수행하는 기능입니다. 다만 현재 상황에서 Buffer Descriptor(이하 BD)가 문제가 될 수 있습니다.

BD는 해당 패킷의 버퍼가 어디에 존재하는지 기록하는 헤더와 유사한 기능을 합니다. 그리고 BD는 Ring Buffer에 할당됩니다. 만약 저희가 큰 데이터를 전송한다고 가정합니다. 그리고 MTU로 나누었더니 총 48개의 BD가 생성됩니다. 하지만 Tx Ring Buffer의 연속된 빈 공간의 길이가 그 보다 작으면 여유가 있음에도 실패하게 됩니다. 그리고 다시 큐잉되고, 재시도 요청을 받으면 실행하게 되겠죠.

이에 대한 가장 빠른 접근법으로 sudo ethtool -K <interface> tso off gso off으로 기능을 꺼보는 것을 추천합니다. 어플리케이션을 재시작할 필요없이 적용되어 바로 체크할 수 있습니다. GSO 기능까지 끄는 것에 대해서는 TSO와 유사하게 드라이버 직전까지 소프트웨어 적으로 큰 패킷을 가져가는 기능이므로 혹시 모르니 끄는 걸 추천합니다.

큐 및 버퍼 조정

버퍼를 늘리면 저 증상 자체가 완화될 수 있습니다. 근본적인 해결책은 아니지만 충분히 효과적으로 처리할 수 있습니다. 각 4096으로 작성된 부분은 해당 인터페이스의 최대 수치로 변경하여 적용할 수 있습니다.

ethtool -G <interface> tx 4096 rx 4096

그리고 큐 숫자에 대해서도 코어 별로 할당함으로 폭 자체를 넓혀주는 효과를 가질 수 있습니다. 이것 또한 근원적인 해결책은 아니나, 리큐잉이 되기 전까지 여유를 확보할 수 있을 것입니다.

ethtool -L <interface> combined <Number of CPU Core>

또한 ifcnfig 상에서 txqueuelen을 수정해서 기본적으로 1000인 수치를 10000으로 늘려주면 더 오래 버틸 수 있습니다.

sudo ifconfig <interface> txqueuelen 10000
판올림

centos7bnxt_en 1.10.0은 생각보다 문제가 많은 시기에 멈춰 있음을 확인했습니다. 지원 종료까지 기다리지 않고 rocky9로의 마이그레이션을 준비하는 것이 여러면에서 현명해보입니다.