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

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

译文:

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

SFU

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

  • Full mesh: 每个通话者都将媒体数据(音视频数据)直接转发给其它通话参与者。这仅能工作在小规模通话的场景下,大规模通话就不行了:大多数人的网络速度还不足以支撑同时发送40路视频数据。
  • Server mixing: 每个通话者将各自的媒体数据发送到服务器。在服务端进行混流后(也就是多路媒体数据被处理成一路媒体数据)发送给每个参与者。这种方案适用于非加密的大规模通话场景,需要端到端加密的话就不兼容了,因为服务端需要查看和更改媒体数据。
  • Selective Forwarding:每个参与者将媒体数据发送给服务端。服务端仅将这些数据转发给其它参与者而不需要查看或者更改媒体数据。这既能满足大规模通话场景,也能和端到端加密保持兼容。

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

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

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

在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 最困难的部分是在网络条件不断变化的同时将正确分辨率的视频转发给每个通话者。

难点如下:

  • 每个通话者的网络连接容量在时刻变化并且不易感知。如果SFU发送数据过多会造成额外延迟;如果发送数据太少,会降低通话质量。因此,SFU必须不断的并且小心的调整发送的数据量以使其恰到好处。
  • SFU不能够修改它转发的媒体数据,它只能够从通话者发给它的媒体数据中进行选择。如果将SFU的发送选项限制为要么发送可用的最高分辨率视频数据,要么不发送数据,则很难适应各种网络条件。所以每个参与者必须向 SFU 发送多种分辨率的视频使其可以在它们之间不断小心的进行切换。

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

  • Simulcast(通常被称作大小流)和包重写可以使得视频在不同分辨率中进行切换。
  • 使用拥塞控制来决定要发送数据的正确数量。
  • 使用速率分配(Rate Allocation)来决定在一个budget中要发送什么数据。

Simulcast和包重写

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

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

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

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

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

  • RTP SSRC:用于标识一个连续的RTP包的流。每个simulcast层使用唯一的SSRC进行标识。要将多层(例如,1、2 和 3)转换为一层,我们必须将此值更改(重写)为一个相同的值(例如,1)。

  • RTP序列号(sequence number):表示同一个SSRC的数据包的顺序。因为每一层都有不同数量的数据包,所以不可能在不改变(重写)序列号的情况下转发来自多个层的数据包。例如,如果我们先转发一层的数据 [7, 8, 9], 接下来转发另一层的数据 [8, 9, 10, 11] ,我们不能使用序列号 [7, 8, 9, 8, 9, 10, 11].(译者注:原作者这里貌似漏了一个8)来进行发送。相反,我们必须将它们重写为 [7, 8, 9, 10, 11, 12, 13] (译者注:同一个SSRC,不同的数据包,在一个buffer范围之内不能使用相同的sequence nunber )。

  • RTP 时间戳:表示相对于基准时间进行视频渲染的时间。因为我们使用的 WebRTC 库为每一层选择不同的基准时间,层之间的时间戳不兼容,我们必须更改(重写)一层的时间戳以匹配另一层的时间戳。

  • VP8 picture ID 和 TL0PICIDX:标识能够组成一个视频帧的一组数据包,以及视频帧之间的依赖关系。接收者需要此信息才能在渲染之前解码视频帧。与RTP时间戳类似,我们使用的WebRTC库为每一层选择不同的PictureID集,我们在组合层时必须重写它们。

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

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

  • 传出层的SSRC通常为最小传入层的SSRC。
  • 如果传入数据包的 SSRC并不是当前选择的,则不要转发。
  • 如果传入数据包是层间切换后的第一个数据包,则更改ID以表示这是传出时间线上的最新位置(迄今为止转发的最大位置之后的第一个位置)。
  • 如果传入的数据包是切换后后到的数据包(它没有刚刚切换),则根据前一规则中切换发生的时间,更改 ID 以表示时间线上的相同的相对位置。

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

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

简化版的代码如下:

 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;
        }
      }
    }
  }
  }

组装起来

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

  • SFU 使用 googcc 和 transport-cc 来确定它应该向每个参与者发送多少数据。
  • SFU 使用确定的发送速率来选择要转发的视频分辨率(层)。
  • SFU 为每个视频流将多层的数据包重写为一层。

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

端到端加密

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

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

  • 未加入群组通话的人在加入之前必须无法解密媒体数据。如果不是这样,可以获取加密媒体数据的人(例如通过破坏 SFU)将能够在他们加入群组通话之前就知道通话中发生的事情,更糟的是,他们从未加入也能知道。
  • 离开群组通话的人必须无法解密此群组通话的媒体数据。如果不是这样,可以得到加密媒体数据的人就可以知道他们离开后通话中发生了什么。

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

  • 当客户端加入通话时,它会生成一个密钥并通过 Signal 消息(它们本身是端到端加密的)将其发送到通话的所有其他客户端,并使用该密钥加密媒体数据然后转发给SFU。
  • 每当任何用户加入或离开通话时,通话中的每个客户端都会生成一个新密钥并将其发送给通话中的所有客户端。然后它在 3 秒后开始使用该密钥(允许客户端留出一段时间用于接收新密钥)。

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

12-19 03:40