Como tornei a pornografia 20x mais eficiente com o streaming de vídeo em Python

Publicados: 2022-03-11

Introdução

A pornografia é uma grande indústria. Não há muitos sites na Internet que possam rivalizar com o tráfego de seus maiores players.

E fazer malabarismos com esse imenso tráfego é difícil. Para tornar as coisas ainda mais difíceis, grande parte do conteúdo veiculado por sites pornográficos é composto por fluxos de vídeo ao vivo de baixa latência, em vez de conteúdo de vídeo estático simples. Mas para todos os desafios envolvidos, raramente li sobre os desenvolvedores python que os enfrentam. Então decidi escrever sobre minha própria experiência no trabalho.

Qual é o problema?

Alguns anos atrás, eu trabalhava para o 26º (na época) site mais visitado do mundo – não apenas para a indústria pornográfica: o mundo.

Na época, o site atendia solicitações de streaming de vídeo pornô com o protocolo Real Time Messaging (RTMP). Mais especificamente, usou uma solução Flash Media Server (FMS), construída pela Adobe, para fornecer aos usuários transmissões ao vivo. O processo básico foi o seguinte:

  1. O usuário solicita acesso a alguma transmissão ao vivo
  2. O servidor responde com uma sessão RTMP reproduzindo a filmagem desejada

Por alguns motivos, o FMS não foi uma boa escolha para nós, começando pelos custos, que incluíam a compra de ambos:

  1. Licenças do Windows para cada máquina em que executamos o FMS.
  2. ~$4k de licenças específicas de FMS, das quais tivemos que comprar várias centenas (e mais todos os dias) devido à nossa escala.

Todas essas taxas começaram a acumular. E custos à parte, o FMS era um produto carente, especialmente em sua funcionalidade (mais sobre isso daqui a pouco). Então decidi descartar o FMS e escrever meu próprio analisador Python RTMP do zero.

No final, consegui tornar nosso serviço cerca de 20x mais eficiente.

Começando

Havia dois problemas principais envolvidos: primeiro, o RTMP e outros protocolos e formatos da Adobe não eram abertos (ou seja, publicamente disponíveis), o que tornava difícil trabalhar com eles. Como você pode reverter ou analisar arquivos em um formato sobre o qual você não sabe nada? Felizmente, houve alguns esforços de reversão disponíveis na esfera pública (não produzidos pela Adobe, mas sim por um grupo chamado OS Flash, agora extinto) nos quais baseamos nosso trabalho.

Nota: Mais tarde, a Adobe lançou “especificações” que não continham mais informações do que as já divulgadas no wiki e nos documentos de reversão não produzidos pela Adobe. Suas especificações (da Adobe) eram de uma qualidade absurdamente baixa e tornavam quase impossível usar suas bibliotecas. Além disso, o próprio protocolo às vezes parecia intencionalmente enganoso. Por exemplo:

  1. Eles usaram inteiros de 29 bits.
  2. Eles incluíam cabeçalhos de protocolo com formatação big endian em todos os lugares, exceto para um campo específico (ainda não marcado), que era little endian.
  3. Eles comprimiram os dados em menos espaço ao custo do poder computacional ao transportar 9k quadros de vídeo, o que fazia pouco ou nenhum sentido, porque eles estavam ganhando bits ou bytes de cada vez – ganhos insignificantes para esse tamanho de arquivo.

E em segundo lugar: o RTMP é altamente orientado à sessão, o que tornou praticamente impossível fazer multicast de um fluxo de entrada. Idealmente, se vários usuários quisessem assistir à mesma transmissão ao vivo, poderíamos simplesmente passar os ponteiros de volta para uma única sessão na qual esse fluxo está sendo transmitido (isso seria streaming de vídeo multicast). Mas com o RTMP, tivemos que criar uma instância inteiramente nova do stream para cada usuário que quisesse acesso. Este foi um completo desperdício.

Três usuários demonstrando a diferença entre uma solução de streaming de vídeo multicast e um problema de streaming FMS.

Minha solução de streaming de vídeo multicast

Com isso em mente, decidi reempacotar/analisar o fluxo de resposta típico em 'tags' FLV (onde uma 'tag' é apenas algum vídeo, áudio ou metadados). Essas tags FLV podem viajar dentro do RTMP com poucos problemas.

Os benefícios de tal abordagem:

  1. Nós só precisávamos reempacotar um fluxo uma vez (reempacotar era um pesadelo devido à falta de especificações e peculiaridades de protocolo descritas acima).
  2. Poderíamos reutilizar qualquer fluxo entre clientes com muito poucos problemas fornecendo-lhes simplesmente um cabeçalho FLV, enquanto um ponteiro interno para tags FLV (junto com algum tipo de deslocamento para indicar onde eles estão no fluxo) permitia acesso a o conteúdo.

Comecei o desenvolvimento na linguagem que eu conhecia melhor na época: C. Com o tempo, essa escolha tornou-se incômoda; então comecei a aprender o básico do Python enquanto fazia a portabilidade do meu código C. O processo de desenvolvimento acelerou, mas depois de algumas demonstrações, rapidamente me deparei com o problema de esgotar os recursos. O manuseio de soquete do Python não foi feito para lidar com esses tipos de situações: especificamente, no Python, nos encontramos fazendo várias chamadas de sistema e alternâncias de contexto por ação, adicionando uma enorme quantidade de sobrecarga.

Melhorando o desempenho do streaming de vídeo: misturando Python, RTMP e C

Depois de criar o perfil do código, optei por mover as funções críticas de desempenho para um módulo Python escrito inteiramente em C. Isso era algo de nível bastante baixo: especificamente, ele fazia uso do mecanismo epoll do kernel para fornecer uma ordem logarítmica de crescimento .

Na programação de soquete assíncrona, existem recursos que podem fornecer informações se um determinado soquete é legível/gravável/com erros. No passado, os desenvolvedores usavam a chamada de sistema select() para obter essas informações, o que não era muito bom. Poll() é uma versão melhor de select, mas ainda não é tão boa, pois você tem que passar um monte de descritores de soquete em cada chamada.

O Epoll é incrível, pois tudo o que você precisa fazer é registrar um soquete e o sistema se lembrará desse soquete distinto, lidando com todos os detalhes internamente. Portanto, não há sobrecarga de passagem de argumentos com cada chamada. Ele também escala muito melhor e retorna apenas os soquetes com os quais você se importa, o que é muito melhor do que percorrer uma lista de descritores de soquete de 100k para ver se eles tinham eventos com bitmasks - o que você precisa fazer se usar as outras soluções.

Mas pelo aumento no desempenho, pagamos um preço: essa abordagem seguiu um padrão de design completamente diferente do anterior. A abordagem anterior do site era (se bem me lembro) um processo monolítico que bloqueava o recebimento e o envio; Eu estava desenvolvendo uma solução orientada a eventos, então tive que refatorar o restante do código também para se adequar a esse novo modelo.

Especificamente, em nossa nova abordagem, tínhamos um loop principal, que lidava com recebimento e envio da seguinte forma:

A solução de streaming de vídeo Python usou uma combinação de RTMP, 'tags' FLV e streaming de vídeo multicast.

  1. Os dados recebidos foram passados ​​(como mensagens) até a camada RTMP.
  2. O RTMP foi dissecado e as tags FLV foram extraídas.
  3. Os dados FLV foram enviados para a camada de buffer e multicast, que organizou os fluxos e preencheu os buffers de baixo nível do remetente.
  4. O remetente mantinha um struct para cada cliente, com um índice do último envio, e tentava enviar o máximo de dados possível ao cliente.

Esta era uma janela rolante de dados e incluía algumas heurísticas para descartar quadros quando o cliente era muito lento para receber. As coisas funcionaram muito bem.

Problemas de nível de sistema, arquitetura e hardware

Mas nos deparamos com outro problema: as trocas de contexto do kernel estavam se tornando um fardo. Como resultado, optamos por escrever apenas a cada 100 milissegundos, em vez de instantaneamente. Isso agregou os pacotes menores e evitou uma explosão de trocas de contexto.

Talvez um problema maior estivesse no domínio das arquiteturas de servidor: precisávamos de um cluster com balanceamento de carga e capacidade de failover — perder usuários devido a mau funcionamento do servidor não é divertido. No início, adotamos uma abordagem de diretor separado, na qual um 'diretor' designado tentaria criar e destruir feeds de emissoras ao prever a demanda. Isso falhou espetacularmente. Na verdade, tudo o que tentamos falhou substancialmente. No final, optamos por uma abordagem relativamente de força bruta de compartilhamento de broadcasters entre os nós do cluster aleatoriamente, igualando o tráfego.

Isso funcionou, mas com uma desvantagem: embora o caso geral tenha sido tratado muito bem, vimos um desempenho terrível quando todos no site (ou um número desproporcional de usuários) assistiam a uma única emissora. A boa notícia: isso nunca acontece fora de uma campanha de marketing. Implementamos um cluster separado para lidar com esse cenário, mas, na verdade, raciocinamos que comprometer a experiência do usuário pagante por um esforço de marketing não fazia sentido - na verdade, esse não era realmente um cenário genuíno (embora fosse bom lidar com todos os caso).

Conclusão

Algumas estatísticas do resultado final: o tráfego diário no cluster era de cerca de 100 mil usuários no pico (60% de carga), cerca de 50 mil em média. Gerenciei dois clusters (HUN e US); cada um deles movimentava cerca de 40 máquinas para dividir a carga. A largura de banda agregada dos clusters ficou em torno de 50 Gbps, dos quais eles usaram cerca de 10 Gbps durante o pico de carga. No final, consegui liberar 10 Gbps/máquina facilmente; teoricamente 1 , esse número poderia ter chegado a 30 Gbps/máquina, o que se traduz em cerca de 300 mil usuários assistindo a streams simultaneamente de um servidor.

O cluster FMS existente continha mais de 200 máquinas, que poderiam ter sido substituídas pelas minhas 15 – apenas 10 das quais fariam algum trabalho real. Isso nos deu aproximadamente uma melhoria de 200/10 = 20x.

Provavelmente, minha maior lição do projeto de streaming de vídeo Python foi que eu não deveria me deixar parar pela perspectiva de ter que aprender um novo conjunto de habilidades. Em particular, Python, transcodificação e programação orientada a objetos eram todos conceitos com os quais eu tinha uma experiência muito subprofissional antes de assumir este projeto de vídeo multicast.

Isso, e que lançar sua própria solução pode pagar muito.

1 Mais tarde, quando colocamos o código em produção, tivemos problemas de hardware, pois usávamos servidores Intel sr2500 mais antigos que não suportavam placas Ethernet de 10 Gbit por causa de suas baixas larguras de banda PCI. Em vez disso, nós os usamos em ligações Ethernet de 1-4x1 Gbit (agregando o desempenho de várias placas de interface de rede em um cartão virtual). Eventualmente, conseguimos alguns dos mais novos Intels sr2600 i7, que serviam 10 Gbps sobre óptica sem problemas de desempenho. Todos os cálculos projetados referem-se a este hardware.