如何構建大規模的端到端加密的群組影片通話

如何構建大規模的端到端加密的群組影片通話

原文地址
目前作者正在學習SFU相關的技術,偶然見看到一篇帖子,講了很多原理性的知識,翻譯一遍理解更加深刻,感興趣的同學可以看原帖,或者看本人翻譯的版本,自知水平有限,有很多意譯的地方,若有錯誤還請指正。

譯文:

Signal 在一年之前發布了端到端加密的群組影片通話服務,從那時起,我們把通話者的數量從5人一直擴展到了40個。因為沒有現成的軟體即能夠確保所有通訊都進行端到端加密,同時夠保證通話的規模,我們構建了自己的開源Signal通話服務來完成這項工作。這篇文章將更加詳細的描述它的工作原理。

SFU

在一次群組通話中,每一方都需要把自己的音影片轉數據發給通話的其他參與者。有三種通用架構來實現這一要求:

  • Full mesh: 每個通話者都將媒體數據(音影片數據)直接轉發給其它通話參與者。這僅能工作在小規模通話的場景下,大規模通話就不行了:大多數人的網路速度還不足以支撐同時發送40路影片數據。
  • Server mixing: 每個通話者將各自的媒體數據發送到伺服器。在服務端進行混流後(也就是多路媒體數據被處理成一路媒體數據)發送給每個參與者。這種方案適用於非加密的大規模通話場景,需要端到端加密的話就不兼容了,因為服務端需要查看和更改媒體數據。
  • Selective Forwarding:每個參與者將媒體數據發送給服務端。服務端僅將這些數據轉發給其它參與者而不需要查看或者更改媒體數據。這既能滿足大規模通話場景,也能和端到端加密保持兼容。

因為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 最困難的部分是在網路條件不斷變化的同時將正確解析度的影片轉發給每個通話者。

難點如下:

  • 每個通話者的網路連接容量在時刻變化並且不易感知。如果SFU發送數據過多會造成額外延遲;如果發送數據太少,會降低通話品質。因此,SFU必須不斷的並且小心的調整發送的數據量以使其恰到好處。
  • SFU不能夠修改它轉發的媒體數據,它只能夠從通話者發給它的媒體數據中進行選擇。如果將SFU的發送選項限制為要麼發送可用的最高解析度影片數據,要麼不發送數據,則很難適應各種網路條件。所以每個參與者必須向 SFU 發送多種解析度的影片使其可以在它們之間不斷小心的進行切換。

解決方案是把我們即將單獨討論的幾種技術結合起來:

  • Simulcast(通常被稱作大小流)和包重寫可以使得影片在不同解析度中進行切換。
  • 使用擁塞控制來決定要發送數據的正確數量。
  • 使用速率分配(Rate Allocation)來決定在一個budget中要發送什麼數據。

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如下:

  • 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 的輸入層,並且在兩個數據包之後發生了一次切換,數據包重寫可能看起來像這樣:

簡化版的程式碼如下:

 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 秒後開始使用該密鑰(允許客戶端留出一段時間用於接收新密鑰)。

使用這些規則,每個客戶端都可以控制自己的密鑰分發和輪換,密鑰的輪換依賴於正在通話中的人而不是被邀請的人。這意味著每個客戶端都可以驗證上述安全屬性是否得到保證。