ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用DPDK自研10G网络损伤仪:多核丢包、排队整形与背景流量

用DPDK自研10G网络损伤仪:多核丢包、排队整形与背景流量 做弱网测试这行绕不开网络损伤仪。花几十万买商用设备换来的是精确但也换来各种黑盒限制用开源方案跑千兆还好一上10G链路就扛不住。我最后选择自己用DPDK写一套把多核丢包、排队整形和背景流量三块揉进同一个数据面。这篇文章把实现细节和踩过的坑都摊开讲适合三类人测试团队想搭弱网实验室的、中间件开发想了解高性能数据面的、以及单纯想知道损伤仪内部到底怎么工作的人。我不讲玄乎的架构直接从一个很扎心的性能计算开始然后按模块拆解。每个部分都会给出关键代码思路和操作要点数据是基于我实际跑过的测试环境不同硬件会有些出入但方法论是通用的。1. 自研网络损伤仪的几个硬需求1.1 为什么必须提多核丢包网络损伤仪的本质其实不复杂报文从入口进来你按规则决定放行、丢弃、加延迟然后从出口发出去。难点全在速率。10Gbps链路上的最小以太网包64字节算上前导码和帧间隙约84字节每秒约有1488万包单核处理预算平均只有67纳秒。别说是做流表匹配和排队整形光是完整走一遍收包、解析、转发这个预算就已经极其紧张。更别提丢包动作本身需要生成随机数、做概率判断每一项都在烧指令周期。所以高性能损伤仪必然要走多核每个核分担一部分流量才能让吞吐叠上去。我第一版其实特别天真直接在Linux上用AF_PACKET原始套接字收包当时只跑千兆单核勉强撑住。后来测试团队要在10G链路上模拟移动网络抖动流量一压上去整机CPU直接100%配置丢10%实测丢到40%多完全不能用。那次教训让我意识到网络损伤仪的性能问题不是靠零碎优化能解决的必须在架构上把多核作为前提来设计。1.2 排队整形和背景流量解决什么问题排队整形解决的是速率和延迟可控。你要限制一条流只能跑10Mbps可以用一个10Mbps速率补充令牌的桶超出的包要么在队列里等待要么丢弃你要模拟50ms网络延迟本质上也是把包在队列空间里按住一段时间再发出去。这些都属于排队整形范畴。背景流量则是制造其它业务占着链路的效果。真实网络里的音视频通话从来不是独占一条物理链路旁边总在跑文件传输、Web请求、视频缓存。被测系统需要在这样的干扰下表现出真实水平所以损伤仪不仅要会损伤特定流还要能往里掺入背景流量。这三件事不是独立做的多核丢包保证基础吞吐排队整形影响时序和带宽背景流量叠加进去之后才能模拟出接近现实的环境。2. 多核丢包从性能瓶颈到落地实现2.1 先算一笔账确定瓶颈在哪做任何性能敏感的数据面第一步都是算账。以10Gbps、纯64字节小包为例场景速率每秒包数单核每包预算10Gbps 64B线速1488万 pps67ns每核分1/4线速分摊372万 pps268ns每核分1/8线速分摊186万 pps537ns单看67纳秒这个数可能没概念。我举几个反例一次内存随机访问大约100纳秒一次原子加锁操作在几十纳秒量级一次系统调用上去再下来至少要几百纳秒。也就是说如果用syscall收包单核连线速的零头都跑不满。所以DPDK这种轮询式收发是必须的但即使轮询丢包逻辑也不能做得太重。我的设计目标是比线速预留30%以上余量这样丢包率测定才准确。按4个worker核计算每个核大约要处理372万pps每包给到268纳秒预算足够做一次哈希、一次RNG判断和一次发包操作。这个预算虽然紧但可行。2.2 按流哈希分发保证顺序不乱多核面临的第一件事是怎么把进来的包分到不同核上。这里最容易犯的错误是用端口号或者目的IP这种单维字段做分配以为均匀就行结果同一条流的前后包被打到不同核处理完的顺序就乱了。TCP对乱序很敏感测试结果会直接失真。正确做法是按五元组哈希让同一条流的所有包始终落在同一个worker核上。方向上要求对称流也保持一致的话可以在哈希前把源和目的字段按字典序排一下。我用的是DPDK的rte_hash或简化版jhash哈希结果对worker数量取模。在DPDK部署时更推荐直接利用网卡的RSS能力配置RETA表把哈希桶映射到指定队列每个队列绑定一个worker核这样收包路径上就不需要额外的软件分发。但要注意很多支持RSS的网卡对VXLAN等隧道报文解析不完整哈希会退化成按外层IP计算。我们经常要模拟跨机房链路隧道包占大头所以我在软件层面做了一层哈希兜底收包队列先按RSS尽量分散转发前再算一次内层五元组做流一致性标记。static inline uint32_t flow_hash(const struct rte_ipv4_hdr *ip, uint16_t sport, uint16_t dport) { uint32_t key[3]; key[0] ip-src_addr ^ ip-dst_addr; key[1] ((uint32_t)sport 16) | dport; key[2] ip-next_proto_id; return rte_jhash_32b(key, 3, 0) HASH_MASK; }2.3 丢包算法选型从独立概率到突发模型丢包实现看起来简单每来一个包生成一个随机数小于阈值就丢。但实际工程里有几种不同模型适用场景完全不一样。独立随机丢包是最常用的每个包以固定概率p被丢弃适合模拟无线链路上的随机衰落。实现时可以用一次随机数判定也可以用每N个包丢一个的固定比例。后者在一些测试标准里也叫周期性丢包适合模拟路由器缓存在特定速率下溢出这类场景。突发丢包则需要用Gilbert-Elliott状态机来模拟。它维护两个状态好状态和坏状态。在好状态下不丢包但有一定概率转移到坏状态在坏状态下每个包都丢也有一定概率恢复。通过调节两个转移概率p和q可以控制突发长度和整体丢包率。移动网络里的信号波动、TCP拥塞窗口触发的丢包突发性都很强用固定概率模拟并不真实。enum { CH_GOOD, CH_BAD }; static inline int gilbert_drop(struct gilbert_state *st) { if (st-state CH_BAD) { st-state (rand_ratio(st-rng) st-p_recover) ? CH_GOOD : CH_BAD; return 1; } st-state (rand_ratio(st-rng) st-p_enter_bad) ? CH_BAD : CH_GOOD; return 0; }随机数生成这块我踩过一个不小的坑。第一版直接用了libc的rand丢包率一上10%多核下丢包曲线就出现肉眼可见的锯齿。原因是rand内部有全局锁多核并发调用时所有线程在锁上排队随机数序列还容易在fork或线程初始化时重复。后来改成每核一个独立的xorshift64*状态速度和序列质量都够了。static uint64_t rng_state; static inline uint64_t xorshift64star(void) { uint64_t x rng_state; x ^ x 12; x ^ x 25; x ^ x 27; rng_state x; return x * 0x2545F4914F6CDD1DULL; }2.4 无锁队列与批量收发的实现细节多核之间传递数据我选择用DPDK的rte_ring无锁队列。它的核心优势是支持单生产者单消费者SPSC和多生产者多消费者MPMC在无锁情况下能保持很高的吞吐。架构上主收包核从网卡队列收包按哈希压入对应worker核的rte_ringworker核从自己的ring取包做丢包判定然后发到下一跳或丢弃。为了减少核间通信量我配置了burst模式一次从网卡收64个包批量入队worker也一次从ring里取32或64个包避免逐包操作带来的同步开销。while (1) { uint16_t nb_rx rte_eth_rx_burst(port, queue_id, pkts, BURST_SIZE); for (i 0; i nb_rx; i) { worker flow_hash(pkts[i]) % num_workers; if (rte_ring_enqueue_mpmc(worker_rings[worker], pkts[i]) ! 0) { rte_pktmbuf_free(pkts[i]); overflow_drops; } } }ring满的时候不要阻塞直接把包丢弃并计数。这样上游压力再大也不会连锁拖死整个数据面。后面你会发现这个策略和排队整形里的尾丢弃是同一个思想宁可丢包不能让队列无限膨胀。关于批量处理我强烈建议BURST_SIZE不要设太小。64个包一轮和4个包一轮相比CPU缓存利用率和循环开销差异非常明显。实测在Intel E5-2680 v4平台上64字节包用8核处理10Gbps线速BURST_SIZE64时能达到接近线速的转发BURST_SIZE4时只能跑到大约60%差距巨大。3. 排队整形令牌桶、时间轮与队列调度3.1 令牌桶的参数设定与实现带宽限制最经典的实现是令牌桶。它有两个参数桶容量burst和令牌补充速率rate。桶里最多攒burst个字节每秒钟补充rate个字节的令牌。包进入时如果桶里令牌数大于等于包长就从桶里扣掉对应字节并通过否则这个包超速了通常是入队等待或丢弃。为什么不用简单的计数器限速计数器的问题是瞬时突发不好控制。令牌桶允许一次吞掉burst大小的突发流量然后在一段时间内保持平均速率不超过rate这样既能限制长期平均带宽又允许短时间的突发更贴近真实业务和TCP的行为。struct token_bucket { uint64_t tokens; uint64_t last_tsc; uint64_t rate_bps; uint64_t burst; uint64_t tsc_hz; }; static inline int bucket_pass(struct token_bucket *b, uint32_t pkt_len) { uint64_t now rte_get_tsc_cycles(); uint64_t elapsed now - b-last_tsc; uint64_t add elapsed * b-rate_bps / b-tsc_hz; b-tokens min(b-burst, b-tokens add); b-last_tsc now; if (b-tokens pkt_len) { b-tokens - pkt_len; return 0; } return -1; }周期性补充令牌最怕的是精度差。有人用定时器每10ms补充一次这样在低速率下会出现明显抖动比如限1Mbps时每10ms才放125KB完全不像平滑流。正确做法是在每次包到达时用当前CPU时间戳计算自上次以来应该补充多少令牌这样包到达间隔再随机也能平滑累加。注意这里要用DPDK的rte_get_tsc_cycles不要用clock_gettime。3.2 用时间轮做固定延迟和抖动延迟模拟的本质是把包按住一段时间再发。最简单粗暴的保存方式是为每个包记录一个发包时间然后丢进排序链表但这在高速下根本不行插入和查找都是O(n)。业界常用的是时间轮timing wheel。维护一个固定大小的环形数组每个槽位代表一个时间片比如1ms。包到达时根据当前时间和要模拟的延迟算出它该落在哪个槽位挂到那个槽的链表中。驱动线程每1ms移动一次当前指针把指到的槽位里的所有包发出去。#define WHEEL_BITS 20 #define WHEEL_SIZE (1u WHEEL_BITS) #define TICK_US 1000 struct wheel_slot { struct rte_ring *queue; }; static uint64_t cur_slot; static inline void enqueue_delayed(struct rte_mbuf *pkt, uint64_t delay_us) { uint64_t slot (cur_slot delay_us / TICK_US) (WHEEL_SIZE - 1); rte_ring_enqueue(wheel[slot].queue, pkt); }槽位数量要覆盖最大延迟。1ms粒度、2秒最大延迟需要2000个槽用2^20个槽可以覆盖约17分钟内存占用也不大。粒度方面实测下来1ms对普通网络模拟够用但如果你要模拟5G URLLC那种微秒级抖动就得把TICK降到100us甚至更低代价是CPU消耗上升。抖动模拟更简单在固定延迟基础上叠加一个随机量。这里要注意抖动模型如果只是简单地在[0, max_jitter]之间均匀随机出来的效果偏激进。更接近真实的抖动是高斯分布或截断正态分布不过很多测试标准接受均匀分布。我没在代码里引入复杂的分布计算优先保证每包处理时间可控否则在高速下省下的延迟精度会被处理开销吃掉。3.3 队列调度策略严格优先级还是加权当多个队列同时有包要发送时调度策略决定了等待顺序。最直接的是严格优先级高优先级队列永远先发低优先级只有在高优先级空了才有机会。这种策略实现简单适合模拟QoS里语音包优先于普通数据的场景但代价是低优先级流可能饥饿。公平一点的方案是加权轮询给每个队列配一个权重每轮按权重比例出包。比如三个队列权重是4:2:1每轮从三个队列分别取4、2、1个包。这个能保证所有流量都获得带宽但实现时要注意权重不是越高越好——权重太高的队列发送时会造成突刺。我的做法是严格优先级加权轮询混合配置高优先级队列严格优先低优先级队列之间用加权轮询。为什么混合因为完全严格优先级在背景流量大的场景下低优先级队列几乎拿不到机会容易让低优先级的TCP流进入超时重传造成不必要的抖动。混合以后既保住语音这类对延迟敏感的包又让文件传输等低优先级流不至于完全饿死。3.4 整形与丢包叠加时的先后顺序损伤仪经常同时配置限速和随机丢包。顺序很重要。我的建议是先做带宽整形再做随机丢包。原因在于限速导致的丢包通常代表拥塞丢包比如路由器缓存满了被迫丢弃随机丢包代表信道错误。这两种丢包在现实中是叠加关系先让流量经过整形器把超过带宽的包剔掉剩下的每个包再独立概率丢一次这样结果更接近真实网络。顺序反过来的话随机丢包会先把一部分包丢掉整形器看到的流量形态失真尾部丢弃的包又叠加在随机丢包之上最终丢包率远高于两个参数的简单叠加测试难以复现。我这里吃过大亏建议在设计参数面板时就把流程固定好不要给用户自由调整的机会否则没人说得清结果是怎么来的。4. 背景流量注入从流量生成到混合调度4.1 背景流量的用途决定实现方式背景流量在不同场景下目的不一样直接决定实现方式。一种场景是单纯想压满链路看目标流在拥塞下表现如何那只需要满速率填充流量另一种场景是想模拟多种业务共存比如背景流量里既有大包文件传输又有小包游戏数据那对流量的包长分布、协议特征都有要求。两种场景我都遇到过。测音视频弱网时单纯压满带宽已经不够因为真实用户同时开着视频会议和文件下载包里既有小包也有大包。团队给测试指标提的需求往往是背景流量带宽占比可配、包大小分布可配、协议可配。这就需要一个可编程的流量生成器。4.2 合成流量生成与pcap回放的取舍实现背景流量注入常见方案有两种一是用DPDK Pktgen这类工具从独立端口打流二是自己写个生成器。Pktgen命令一行就能生成指定速率、指定大小的UDP包上手快适合做纯压力测试。但它的灵活性有限比如要生成按时间变化的突发流量曲线或者模拟不同应用层的包序特征写起来就很痛苦。pcap回放是另一个方向只需要准备一个抓包文件用软件按原始时间戳发送。优点是流量形态真实缺点是回放速率难控制还要处理时间戳精度问题。我的方案是自研一个轻量级生成器读配置生成多条流的包模板由一个独立核按指定pps和包长产生背景流量再通过rte_ring送进整形队列。这样背景流可以完全参与排队整形和目标流共用带宽效果最真实。4.3 背景流如何进入整形队列背景流不能绕开整形器直接混到出口否则测试就没有意义。模拟的场景是目标流和背景流共享同一条逻辑链路链路带宽是有限的整形器负责控制总带宽。所以背景流也必须经过令牌桶和队列和目标流一起竞争带宽。实现上入口处有两条路径一条是收到实际网络包另一条是从背景流生成核接收的包。两条路径汇合到同一个调度器调度器根据五元组或配置规则区分目标流和背景流再分别施加不同的丢包参数。这里优先级的设计直接影响结果目标流如果被设成高优先级即使在背景流量很大的情况下它的时延和带宽也能得到保障如果都是普通优先级就会产生竞争丢包。测试时要明确自己到底想验证哪种情况。static inline int classify_pkt(struct rte_mbuf *pkt) { // 返回 0 表示目标流1 表示背景流 if (is_target_flow(pkt)) return 0; return 1; }4.4 CPU隔离与核间通信的注意事项背景流量生成本身也是CPU密集型操作如果和转发worker挤在同一个核上互相抢执行时间会造成目标流的时延抖动测试结果全被污染。我的做法是用独立物理核跑生成器并且设置CPU亲和性不让系统调度器把它挪走。Linux上可以用taskset或用DPDK的eal参数锁定lcore。DPDK还提供 --lcores 参数灵活绑定可以指定 worker 只跑在某个物理核上。生成器和worker之间的数据处理我用的是rte_ring。这里有一个容易忽略的问题生成核和worker核共享的统计计数器如果每次都原子更新会引入严重的cache line竞争。我的对策是每个核维护本地计数副本定时汇总打印而不是每次包到达都更新全局计数器。你可能觉得一线程更新一个全局计数器没什么但在14Mpps下一次共享内存原子操作足够让性能掉一截。5. 实测结果与排坑记录5.1 性能实测单核到多核的扩展性我测试用的环境是双路E5-2680 v4Intel X710 10G网卡DPDK 19.11。纯收包转发、不做丢包的基准数据和启用多核丢包后的数据对比如下配置64B包转发率说明单核转发4.1 Mpps已优化无锁且批量收发2核丢包7.6 Mpps丢包逻辑加入RNG开销可见4核丢包14.2 Mpps接近10G线速14.88Mpps4核丢包10G整形13.8 Mpps令牌桶和时间轮增加少量开销2核到4核的扩展性接近线性说明瓶颈确实在CPU处理能力而不是锁或内存带宽。但我也注意到加到6核以后增益就明显衰减原因是哈希分发不均部分核可能争抢同一条大流量流。这种场景可以考虑更细粒度的流切分不过会增加乱序风险需要谨慎。5.2 丢包精度与随机数种子的坑丢包率配置10%实测10.05%这种误差可以接受但如果某次配置10%实测到14%先查随机数种子。我遇到过跑在相同机器上、每次重启后丢包曲线完全一样的情况原因是种子固定了短时间窗口里的丢包模式是重复的。测试需要稳定复现时固定种子是优点但做统计测试时反而会带来偏差。另外随机数状态要放在worker核私有的内存里不能多个核共享一个状态。否则每个包丢或者不丢的决策和核的调度时机强相关结果依赖内核调度完全不可控。排查这个问题时我是靠同时打印每个核的丢包计数发现核与核之间偏差很大才定位到的。5.3 延迟不准的隐蔽原因延迟模拟目标200ms实测P99到了210ms以上怎么看都超标。最开始怀疑时间轮粒度太大后来把EDIT粒度降到100us还是没好转。最后发现是时间轮槽位链表里某个大包组突发批量发送导致瞬间拥塞包的出口时间延后。这个现象在高速率下尤其明显。解决办法是出口再做一层平滑整形从时间轮取出的包进入一个小型队列按固定速率出队而不是一次性全丢给网卡。代价是CPU多一次队列操作但延迟准确性提升明显。另一个隐蔽问题是用默认的rte_ring做槽位队列时如果ring设置得太小延迟较大的包可能在槽位阶段被溢出丢弃丢包率无形中升高。时间轮场景里的ring大小要预留足够余量不能按普通转发队列的标准来。5.4 关于设计取舍的个人经验整个项目做完我的体会是网络损伤仪最难的不是某个单一算法而是多个功能叠加后仍然保持性能和确定性。多核丢包解决基本吞吐排队整形解决带宽与时延背景流量解决场景真实度但三者叠加时任何一处的抖动都可能干扰其它部分。如果你也要做类似的东西我建议先把每个模块单独调准再合并联调。单独调准是最容易排查问题的阶段合在一起后问题会互相掩盖很难定位。我的习惯是每配置一个场景都会抓包对比看目标流的实际丢包率、时延分布和带宽是否和面板一致确保链路里每个环节都在按预期工作之后才放心把结果给测试团队用。最后分享一个排查技巧我在数据面每个关键节点都会放一个累计计数器和本地时间戳分别统计收包数、哈希分发数、丢包数、整形器出队数和发送数。踩坑的时候这五个计数一对比基本能确定问题出在哪个环节不需要反复加日志重新编译。对这类高速数据面项目来说可观测性比任何花哨功能都重要。
返回列表