使用 eBPF 调试 WebRTC SFU:找出你的媒体服务器在哪里 “吞掉” 数据包

作者:Hector Zelaya
原文:https://webrtc.ventures/2026/08/ebpf-webrtc-sfu-packet-loss-debugging/

当你的 SFU 上 WebRTC 质量出现劣化(抖动飙升、画面冻结、音频断断续续)时,监控系统只能告诉你 “出了问题”,却无法告诉你问题出在谁身上。是网络在丢包?还是服务器负载过高,来不及及时从 socket 读取数据包?前者的修复方案是扩展基础设施,后者的应对则是与网络状况抗衡。eBPF 提供了内核级可观测性,帮你找到答案。

eBPF(extended Berkeley Packet Filter)是一项 Linux 内核技术,它能在内核内部直接运行小型、沙箱化的程序,而无需修改内核源代码或加载自定义模块。这让你可以挂接到内核函数上,提取任何应用层工具都无法触及的度量数据,并在问题变得用户可见之前就将其捕获。

这篇文章将展示如何使用 eBPF 测量 WebRTC SFU 的时间消耗在哪里,从而把服务器延迟与网络抖动区分开。为了演示工作原理,我们会测量一个具体指标:RTP 数据包停留时间(dwell time),即每个 UDP 数据包在被你的应用程序读取之前,在内核 socket 接收队列中等待的时长。这是 eBPF 所能实现的内核级可观测性的一个示例,它能以亚微秒精度回答 “服务器还是网络” 的问题,而且往往是在 getStats() 仍然报告 “一切正常” 的时候。

getStats () 能告诉你什么,不能告诉你什么

WebRTC 的 getStats() API 提供的是接收端聚合指标:jitter(抖动)、packetsLost(丢包数)、roundTripTime(往返时延)。这些指标对判断质量确实劣化至关重要,但不足以定位原因出在哪里

考虑两种场景:

  1. 一个数据包穿越互联网只用了 5ms,但在你的 SFU 的 socket 队列里等待了 12ms 才被服务器读取。
  2. 一个数据包经由拥塞的对等互联链路传输,额外引入了 12ms 的网络抖动,但你的服务器在数据包到达后微秒级内就完成了处理。

两者都会在 getStats() 上产生噪声,但底层原因(以及修复方法)完全不同。

在故障早期,这两种迹象可能显得截然不同。负载较高的服务器可能表现为有抖动但零丢包(即队列在填充但尚未溢出);而丢包严重的网络可能表现为有丢包但抖动很小(即数据包要么准时到达,要么根本不出现)。然而,在持续压力下,这些 “干净” 的特征并不成立。到那时,仅靠 getStats() 无法区分服务器侧与网络侧的问题。

真正缺失的,是一个能补充应用层统计的内核级测量:socket 队列停留时间。这项数据之所以关键,是因为 CPU 饥饿往往首先表现为尾延迟(tail latency)尖峰,远早于任何聚合利用率指标越过报警阈值。因此,你的 CPU 看起来只有 60% 利用率,看似一切正常,但数据包却正在排队,因为 SFU 的事件循环无法及时调用 recvmsg()

主机侧延迟到底藏在哪里

下面是一个 UDP 数据包在你的 SFU 上的简化路径:

使用 eBPF 调试 WebRTC SFU:找出你的媒体服务器在哪里 "吞掉" 数据包
数据包进入服务器并由SFU处理的简化视图

在 100 名参与者 × 3 层 simulcast × 30fps 的场景下,你面对的是每秒 9,000 个入站数据包。你的事件循环预算非常紧张。如果在这个速率下任何环节产生 2ms 的延迟,接收端的抖动缓冲就会增长,用户就能听出问题了。

eBPF:完成这项任务的正确工具

eBPF 允许你把小程序挂接到内核函数的入口和出口点上。由于它在应用层之下运行,同一个跟踪器无论你的 SFU 是用 Go、C++、Rust 还是 Node.js 编写都适用,也不管它是 Pion、mediasoup、Janus、LiveKit 还是其他实现。

每次探针调用的开销约为 100–200ns。在每秒 9,000 个数据包、使用两个探针的情况下,每秒大约消耗 3.6ms 的 CPU,也就是单核的 0.36%。对于一个诊断工具来说,这可以忽略不计。

eBPF 程序:测量停留时间

内核侧的程序非常紧凑。我们挂接 __udp_enqueue_schedule_skb(数据包带着有效的 socket 指针进入 socket 接收队列的时刻)和 udp_recvmsg(应用程序读取数据包的时刻)。两者之差就是驻留时间。

入队探针在内核每次将 UDP 数据包放入 socket 接收队列时触发。它过滤出 RTP 端口范围的数据包,并将纳秒级时间戳记录到每个 socket 的循环 FIFO 中,标记该数据包停留时间的起点。

SEC("kprobe/__udp_enqueue_schedule_skb")
int BPF_KPROBE(kprobe_udp_enqueue, struct sock *sk, struct sk_buff *skb)
{
    __u64 sock_key = (__u64)sk;

    // Filter: only trace packets destined for RTP port range (10000–60000).
    __u16 dport = BPF_CORE_READ(sk, __sk_common.skc_num);
    if (!is_rtp_port(dport))
        return 0;

    // Lookup or create the per-socket timestamp FIFO.
    struct sock_ts_queue *queue = bpf_map_lookup_elem(&sock_queues, &sock_key);
    if (!queue) { /* ... initialize new queue entry ... */ }

    // Record the enqueue timestamp — this marks the START of dwell.
    __u32 idx = queue->tail & (MAX_QUEUE_DEPTH - 1);
    queue->timestamps[idx] = bpf_ktime_get_ns();
    queue->tail++;

    return 0;
}

返回探针在 udp_recvmsg 完成时触发,即应用程序消费掉一个数据包的时刻。它从 FIFO 中取出最早的时间戳,用当前时间减去它以计算驻留时间,并通过环形缓冲区将测量结果发送到用户空间。

SEC("kretprobe/udp_recvmsg")
int BPF_KRETPROBE(kretprobe_udp_recvmsg, int ret)
{
    // Correlate this return with the socket pointer saved on entry.
    __u64 pid_tgid = bpf_get_current_pid_tgid();
    __u64 *sock_key_ptr = bpf_map_lookup_elem(&active_recvmsg, &pid_tgid);
    if (!sock_key_ptr)
        return 0;

    __u64 sock_key = *sock_key_ptr;
    bpf_map_delete_elem(&active_recvmsg, &pid_tgid);

    struct sock_ts_queue *queue = bpf_map_lookup_elem(&sock_queues, &sock_key);
    if (!queue || queue->head >= queue->tail)
        return 0;

    // Dequeue the oldest timestamp — this is the END of dwell.
    __u32 head_idx = queue->head & (MAX_QUEUE_DEPTH - 1);
    __u64 enqueue_ts = queue->timestamps[head_idx];
    queue->head++;

    // Compute dwell time: current time minus enqueue time.
    __u64 now = bpf_ktime_get_ns();
    __u64 dwell_ns = now - enqueue_ts;

    // Emit the measurement to userspace via ring buffer.
    struct dwell_event *evt = bpf_ringbuf_reserve(&events, sizeof(*evt), 0);
    if (!evt)
        return 0;
    evt->dwell_ns = dwell_ns;
    evt->sock_cookie = (__u32)sock_key;
    evt->queue_len = queue->tail - queue->head;
    bpf_ringbuf_submit(evt, 0);

    return 0;
}

Go 用户空间:聚合与上报

在用户空间一侧,我们使用 cilium/ebpf 加载 BPF 程序并从环形缓冲区读取事件。事件被聚合成每个 socket 的直方图,包含 9 个桶,范围从 <10µs 到>50ms。

Record 函数接收每个驻留事件,更新每个 socket 的累计统计(计数、总和、最大值),并将驻留时间归类到直方图桶中。这是热路径,每读取一个数据包调用一次,因此它使用简单的锁和 O (1) 桶查找。

// BucketBounds defines histogram edges in microseconds.
// Produces 9 buckets: [0,10) [10,50) ... [50000,+∞)
var BucketBounds = [8]uint64{10, 50, 100, 500, 1000, 5000, 10000, 50000}

func (sc *StatsCollector) Record(cookie uint32, dwellNs uint64,
    sourceIP string, sourcePort, destPort uint16) {
    sc.mu.Lock()
    defer sc.mu.Unlock()

    // Find or create the per-socket accumulator (keyed by kernel socket cookie).
    acc, ok := sc.sockets[cookie]
    if !ok {
        acc = &socketAccumulator{
            sourceIP: sourceIP, sourcePort: sourcePort,
            destPort: destPort, cookie: cookie,
        }
        sc.sockets[cookie] = acc
    }

    // Update running totals for avg/max computation.
    acc.packets++
    acc.totalDwell += dwellNs
    if dwellNs > acc.maxDwell {
        acc.maxDwell = dwellNs
    }

    // Classify into histogram bucket (ns → µs, then linear scan of 8 bounds).
    dwellUs := dwellNs / 1000
    acc.buckets[bucketIndex(dwellUs)]++

跟踪器的初始化会挂接全部三个探针(enqueue 上的 kprobe、recvmsg 上的 kprobe 与 kretprobe)并打开环形缓冲区。这是一次性的设置,将内核侧的测量连接到 Go 事件循环。

func New(cfg Config) (*Tracer, error) {
    t := &Tracer{
        stats: NewStatsCollector(cfg.MaxSockets),
        cfg:   cfg,
    }

    // Load compiled BPF bytecode into the kernel.
    if err := loadBpfObjects(&t.objs, nil); err != nil {
        return nil, fmt.Errorf("failed to load BPF objects: %w", err)
    }

    // Attach kprobe to __udp_enqueue_schedule_skb — marks dwell START.
    kpUdpEnqueue, err := link.Kprobe(
        "__udp_enqueue_schedule_skb", t.objs.KprobeUdpEnqueue, nil)
    if err != nil { return nil, err }
    t.links = append(t.links, kpUdpEnqueue)

    // Attach kprobe + kretprobe to udp_recvmsg — marks dwell END.
    kpRecvmsg, _ := link.Kprobe("udp_recvmsg", t.objs.KprobeUdpRecvmsg, nil)
    krpRecvmsg, _ := link.Kretprobe("udp_recvmsg", t.objs.KretprobeUdpRecvmsg, nil)
    t.links = append(t.links, kpRecvmsg, krpRecvmsg)

    // Open ring buffer — userspace reads dwell events from here.
    t.reader, _ = ringbuf.NewReader(t.objs.Events)
    return t, nil
}

视频演示:真实服务器上的三种场景

我们将跟踪器与一个 Pion WebRTC 回声服务器配对:这是一个最简化的 SFU,将收到的视频原样回传给发送方。我们在一个 EC2 实例上运行了三种场景,同时用浏览器客户端推流视频,并将 getStats() 与 eBPF 驻留指标并排展示。

视频地址:https://youtu.be/ye6-ejeNg1Y?si=isOH5JRc3uanEnvT

  1. 场景一:基线。 回声服务器在无人工负载、无网络损伤的情况下回传视频。本地与回传视频的计数器保持同步。getStats() 报告零丢包、FPS 稳定、RTT 约 70ms、抖动为个位数。eBPF 面板显示平均驻留时间约 30µs。数据包进入队列后微秒级内即被读取。一切健康。
  2. 场景二:服务器负载。 我们启用节流,每第 5 次 socket 读取被延迟 20–50ms,模拟一个跟不上的事件循环。回传视频开始卡顿并落后。getStats() 显示抖动攀升,但丢包保持为零、FPS 维持不变。单看应用层指标,你可能不会认为这很紧急。但 eBPF 面板讲述了不同的故事:平均驻留时间跃升至 76ms,最高达 193ms,状态翻转为 ALERT。服务器在 “扣留” 数据包,而跟踪器在流媒体完全劣化之前就抓住了它。
  3. 场景三:网络损伤。 移除负载,服务器恢复到基线状态,我们添加 tc netem 规则:10ms±20ms 延迟、10% 丢包、25% 乱序。视频再次劣化。从用户角度看,它和场景二看起来一样糟糕。getStats() 亮起红灯:丢包率开始上升,RTT 攀升。但 eBPF 面板保持在 33µs 驻留时间,状态为 OK。服务器在数据包到达的瞬间就处理了它们。问题出在传输途中,而不是我们的服务器上。

如前所述,在持续的生产负载下,这些应用层信号会模糊在一起。eBPF 驻留指标就是那个 “决胜者”:驻留时间高,说明服务器有责任;驻留时间低,说明服务器是清白的。

从测量到行动

Socket 队列驻留时间只是一个指标。eBPF 为 WebRTC 工作负载还能实现数十种其他测量:内核侧丢包计数(挂接 kfree_skb,区分服务器导致的丢包与网络丢包)、运行队列延迟(测量你的 SFU 线程在能读取数据包之前等待 CPU 时间多久)、软中断(softirq)处理延迟(检测 NAPI 预算耗尽时数据包交付被推迟的情况),以及每核 IRQ 到应用的时间。模式都是一样的:入口处挂 kprobe,出口处挂 kretprobe,计算差值。

驻留时间恰好回答了 SFU 运维人员最常遇到的问题,但它底层的可观测性框架要通用得多。当驻留时间很低时,即使有用户反馈质量劣化,你也知道服务器在正常履职。当它飙升时,你往往在 getStats() 或用户可见的质量劣化到触发告警之前,就知道该去哪里排查。不再需要猜测。

本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/webrtc/71933.html

(0)

相关推荐