如何构建大规模的端到端加密的群组视频通话

目录

正文

如何构建大规模的端到端加密的群组视频通话

原文地址
目前作者正在学习SFU相关的技术,偶然见看到一篇帖子,讲了很多原理性的知识,翻译一遍理解更加深刻,感兴趣的同学可以看原帖,或者看本人翻译的版本,自知水平有限,有很多意译的地方,若有错误还请指正。本文已经获得原作者的翻译授权

译文:

Signal 在一年之前发布了端到端加密的群组视频通话服务,从那时起,我们把通话者的数量从5人一直扩展到了40个。因为没有现成的软件即能够确保所有通信都进行端到端加密,同时够保证通话的规模,我们构建了自己的开源Signal通话服务来完成这项工作。这篇文章将更加详细的描述它的工作原理。

回到顶部

SFU

在一次群组通话中,每一方都需要把自己的音视频转数据发给通话的其他参与者。有三种通用架构来实现这一要求:

因为Signal必须支持端到端加密并且可以扩展到大规模通话,因此我们选择最后一种方案。执行选择性转发的服务器通常被称为选择性转发单元或者SFU。

我们现在只关注单个通话者的媒体数据流:其将媒体数据通过SFU发送给多个通话接收者,像下面这样:

在SFU中这部分的简化版的代码如下:

let socket = std::net::UdpSocket::bind(config.server_addr);  
let mut clients = ...;  // changes over time as clients join and leave
loop {
  let mut incoming_buffer = [0u8; 1500];
  let (incoming_size, sender_addr) = socket.recv_from(&mut incoming_buffer);
  let incoming_packet = &incoming_buffer[..incoming_size];

  for receiver in &clients {
     // Don't send to yourself
     if sender_addr != receiver.addr {
       // Rewriting the packet is needed for reasons we'll describe later.
       let outgoing_packet = rewrite_packet(incoming_packet, receiver);
       socket.send_to(&outgoing_packet, receiver.addr);
     }
  }
}
回到顶部

Signal的开源SFU方案

当对群组通话进行支持的的时候,我们评估了很多开源的SFU方案,但是只有其中的两个拥有足够的拥塞控制(接下来会看到,这很关键)。我们对其中一个进行了修改并进行群组通话,很快发现即使进行了大量的修改,由于CPU使用率高的问题,我们无法将其可靠的扩展到8个以上的通话者。为了能够支持更多的通话者,我们用RUST重新实现了一个SFU,它现在已经为Signal所有的群组通话服务了9个月的时间,轻松的扩展到了40个通话者(未来可能更多),并且代码足够易读,可以作为基于WebRTC协议(ICE, SRTP, transport-cc, 和googcc)的SFU的参考实现。

现在让我们更深入地了解 SFU 中最难的部分。你可能已经猜到了,它比上面的简单循环更加复杂。

回到顶部

SFU中最难的部分

SFU 最困难的部分是在网络条件不断变化的同时将正确分辨率的视频转发给每个通话者。

难点如下:

解决方案是把我们即将单独讨论的几种技术结合起来:

回到顶部

Simulcast和包重写

为了让 SFU 能够在不同分辨率之间切换,每个通话者必须同时向SFU发送多层(分辨率)数据。这叫做Simulcast(大小流)。我们现在只关注一个通话者的数据被转发给两个接收者,看上去像是两个接收者接收的数据会在不同时间点进行小(small)和中(medium)层的切换。

但是当 SFU 在不同层之间切换时,接收者会看到什么? 是会看到在一层上进行分辨率的切换还是看到多层,每层在开和关之间切换?看起来很小的区别,但是对SFU扮演的角色有很大影响。对于一些视频编码器(例如VP9和AV1)来说这很容易,因为层的切换以一种叫做SVC的方式被内置到了编码器中。因为我们现在仍然使用VP8来适配大量设备,而VP8不支持SVC,所以需要在SFU中实现将3层转换为1层。

这就类似于视频流应用程序会根据你的网络状况来向你传输不同质量的视频。你看到的是单个视频流在不同分辨率之间进行切换,而后台在做的是程序在接收存储在服务器上的同一个视频的不同码率的视频数据。就像视频流服务器一样,SFU会发给你同一个视频的不同分辨率的视频数据,但是不同的是,它不会存储数据并且必须实时完成,这个过程被叫做包重写(packet rewriting)。

包重写会对媒体数据包中的时间戳(timestamp),序列号(sequence number),其它类似的IDs进行修改,这些字段用于标记包在媒体时间线上的位置。它将来自许多独立媒体时间线(每层一个)的数据包转换为一个统一的媒体时间线(一层)。使用RTP和VP8时必须重写的ID如下:

如果我们更改 WebRTC 库使得不同层之间使用兼容的时间戳和pictureIDs,那么理论上来说只重写RTP SSRCs和序列号就可以了。但是,我们已经有很多客户在使用不兼容的IDs,因此我们需要重写所有这些 ID 以保持向后兼容。而且由于重写这些ID的实现与重写RTP序列号基本是相同的,所以实现起来并不难。

要将给定视频流的多个传入层转换为单个传出层,SFU根据以下规则重写数据包:

例如,如果我们有两个带有 SSRC A 和 B 的输入层,并且在两个数据包之后发生了一次切换,数据包重写可能看起来像这样:

简化版的代码如下:

 let mut selected_ssrc = ...;  // Changes over time as bitrate allocation happens
let mut previously_forwarded_incoming_ssrc = None;
// (RTP seqnum, RTP timestamp, VP8 Picture ID, VP8 TL0PICIDX)
let mut max_outgoing_ids = (0, 0, 0, 0);
let mut first_incoming_ids = (0, 0, 0, 0);
let mut first_outgoing_ids = (0, 0, 0, 0);
for incoming in incoming_packets {
  if selected_ssrc == incoming.ssrc {
    let just_switched = Some(incoming.ssrc) != previously_forwarded_incoming_ssrc;
    let outgoing_ids = if just_switched {
      // There is a gap of 1 seqnum to signify to the decoder that the
      // previous frame was (probably) incomplete.
      // That's why there's a 2 for the seqnum.
      let outgoing_ids = max_outgoing + (2, 1, 1, 1);
      first_incoming_ids = incoming.ids;
      first_outgoing_ids = outgoing_ids;
      outgoing_ids
    } else {
      first_outgoing_ids + (incoming.ids - first_incoming_ids)
    }

    yield outgoing_ids;

    previous_outgoing_ssrc = Some(incoming.ssrc);
    max_outgoing_ids = std::cmp::max(max_outgoing_ids, outgoing_ids);
  }
}

数据包重写与端到端加密是兼容的,因为在端到端的媒体数据被加密之后,发送者才会将重写的IDs和时间戳添加到数据包中(更多内容见下文)。这类似于TCP使用TLS加密时,其是如何将TCP序列号和时间戳添加到数据包中的。这意味着 SFU 可以查看这些时间戳和 ID,但这些值并不比 TCP 序列号和时间戳更让人感兴趣。换句话说,SFU只会只用这些字段发送媒体数据,而不会干别的事情。

回到顶部

拥塞控制

拥塞控制是一种用于确定在网络中发送多少数据的机制:不要过多也不要过少。它的历史悠久,大多数都是TCP的拥塞控制。不幸的是,TCP 的拥塞控制算法通常不适用于视频通话,因为它们往往会导致延迟增加,从而造成通话体验不佳(有时称为“滞后”)。为了为视频通话提供良好的拥塞控制,WebRTC 团队创建了 googcc,这是一种拥塞控制算法,可以保证在不增加延迟的前提下确定发送数据的正确数量。

拥塞控制机制通常依赖于从包接收方到包发送方的反馈机制。 googcc被设计为与transport-cc共同工作,transport-cc协议中的接收方定期将消息发送回发送方,例如,“我在时间 Z1 收到数据包 X1;在时间 Z2收到数据包 X2,……”。然后发送方将这些信息与自己的时间戳结合起来,便可以知道:“我在 Y1 时间发送了数据包 X1,它在 Z1 被接收到;我在时间 Y2 发送了数据包 X2,然后在 Z2 收到了它……”。

在Signal Calling Service中,我们以流处理的形式实现了googcc和transport-cc。流管道的输入是上述关于数据包何时发送和接收的数据,我们称之为 acks。管道的输出是应该通过网络发送多少数据的变化信息,我们称之为目标发送速率。

流程的前几步会在延迟与时间的关系图上绘制acks数据,然后计算斜率以确定延迟是增加、减少还是稳定。最后一步根据当前的斜率决定要做什么。代码的简化版本如下所示:

let mut target_send_rate = config.initial_target_send_rate;
for direction in delay_directions {
  match direction {
    DelayDirection::Decreasing => {
      // While the delay is decreasing, hold the target rate to let the queues drain.
    }
    DelayDirection::Steady => {
      // While delay is steady, increase the target rate.
      let increase = ...;
      target_send_rate += increase;
      yield target_send_rate;
    }
    DelayDirection::Increasing => {
      // If the delay is increasing, decrease the rate.
      let decrease = ...;
      target_send_rate -= decrease;
      yield target_send_rate;
    }
  }
}

这是 googcc算法的关键所在:

达到的效果是发送速率非常接近实际网络容量,同时根据延迟的变化进行调整并保持低延迟。

当然,上面代码中关于增加或者减少发送速率的部分被省略掉了,这部分很复杂,但是现在你可以看到它通常如何用于视频通话:

回到顶部

速率分配(Rate Allocation)

一旦SFU知道要发送多少数据,它现在必须确定要发送什么(要转发哪些层)。这个过程,我们称之为Rate Allocation,也就是SFU在受发送速率限制的情况下对各层数据进行选择。例如,如果每个参与者发送2层数据,总共有3个参与者,则SFU有6层数据可以选择。

如果指定的发送速率足够大,SFU可以发送我们需要的所有内容(直到每个参与者的最大层)。但如果发送速率受限制,我们必须确定发送层的优先级。为了帮助确定优先级,每个参与者通过请求最大分辨率来告诉服务器它需要什么分辨率。使用该信息,我们使用以下规则进行速率分配:

简化版本的代码如下:

   The input: a menu of video options.
   Each has a set of layers to choose from and a requested maximum resolution.
  t videos = ...;

   The output: for each video above, which layer to forward, if any
  t mut allocated_by_id = HashMap::new();
  t mut allocated_rate = 0;

   Biggest first
  deos.sort_by_key(|video| Reverse(video.requested_height));

   Lowest layers for each before the higher layer for any
  r layer_index in 0..=2 {
  for video in &videos {
    if video.requested_height > 0 {
      // The first layer which is "big enough", or the biggest layer if none are.
      let requested_layer_index = video.layers.iter().position(
         |layer| layer.height >= video.requested_height).unwrap_or(video.layers.size()-1)
      if layer_index <= requested_layer_index {
        let layer = &video.layers[layer_index];
        let (_, allocated_layer_rate) = allocated_by_id.get(&video.id).unwrap_or_default();
        let increased_rate = allocated_rate + layer.rate - allocated_layer_rate;
        if increased_rate < target_send_rate {
          allocated_by_id.insert(video.id, (layer_index, layer.rate));
          allocated_rate = increased_rate;
        }
      }
    }
  }
  }
回到顶部

组装起来

将上面的三种技术结合起来,我们可以得到一个完整的解决方案:

效果是每个参与者都可以在给定当前网络条件的情况下以最佳方式查看所有其他参与者,并且与端到端加密兼容。

回到顶部

端到端加密

说到端到端加密,简单描述它的工作原理很有必要。因为它对服务器是完全不透明的,所以代码并不在服务器中,而是在客户端。特别的,我们的实现放在RingRTC中,一个用 Rust 编写的开源视频通话库。

每个帧的内容在被分包之前都进行了加密,类似于SFrame。有趣的部分实际上是密钥分发和轮换机制,它必须对以下场景具有鲁棒性:

为了保证上面的安全属性,我们使用以下规则:

使用这些规则,每个客户端都可以控制自己的密钥分发和轮换,密钥的轮换依赖于正在通话中的人而不是被邀请的人。这意味着每个客户端都可以验证上述安全属性是否得到保证。