Na cside, damos uma forte ênfase à segurança de memória e ao desempenho. Todos os nossos serviços centrais são escritos em Rust, incluindo o serviço Edge.
A Edge situa-se numa fronteira sensível. Tem de ser rápida, segura e resiliente, porque faz parte do caminho onde recolhemos sinais de alta qualidade para produtos como a deteção de VPN e a deteção de bots.
Para muitas aplicações web, a configuração de TLS mais simples é deixar um balanceador de carga na cloud terminar o TLS e encaminhar HTTP simples para a aplicação. Esse é um bom valor por omissão quando a aplicação só precisa do pedido HTTP final.
A Edge tem uma função diferente.
Alguns sinais de deteção existem antes de o pedido se tornar HTTP comum, na camada 4 (a camada de transporte) e não na camada 7. Se o TLS for totalmente terminado antes de o tráfego chegar à Edge, esses sinais deixam de estar disponíveis na mesma forma. Por isso, para esta parte da plataforma, a Edge tem de operar na camada 4 e gerir o caminho do TLS diretamente.
Isso dá à cside melhor qualidade de sinal para a deteção, mas também significa que a Edge precisa de lidar ela própria com o comportamento real do TLS.
Este artigo é sobre uma pequena correção de fiabilidade nesse caminho: retirar o trabalho de handshake de TLS do caminho de accept do Axum e colocá-lo em tasks Tokio limitadas.
O que começou a falhar
O patch em si não era grande, mas compreender a falha deu algum trabalho.
A Edge estava saudável. Os certificados estavam a carregar. A porta estava acessível. A maior parte do tráfego comportava-se normalmente.
Mas sob mais carga, algumas verificações HTTPS e ligações de clientes pareciam ficar penduradas ou expirar.
À primeira vista, isso pode parecer um problema de TLS. Na prática, o padrão importante era mais específico: alguns clientes abriam uma ligação mas não completavam o handshake de TLS.
Isso é normal num serviço exposto à internet. Os endpoints públicos veem ligações incompletas a toda a hora:
o cliente liga-se e depois não envia nada
o cliente inicia o TLS e depois desaparece
o cliente inicia o TLS e depois bloqueia
Essas falhas não eram a parte surpreendente.
A parte surpreendente era o quanto um único handshake incompleto podia afetar o tráfego saudável próximo.
Antes da correção
Antes da correção, uma parte do caminho de TLS da Edge fazia demasiado trabalho num único passo.
Conceptualmente, comportava-se assim:
aceitar uma ligação
terminar o trabalho de TLS dessa ligação
depois aceitar a ligação seguinte
Em Rust simplificado, a forma era aproximadamente esta:
impl axum::serve::Listener for EdgeTlsListener {
async fn accept(&mut self) -> (TlsStream, SocketAddr) {
let (mut tcp_stream, addr) = self.tcp_listener.accept().await;
let hello = read_tls_hello(&mut tcp_stream).await;
let tls_stream = complete_tls_handshake(hello, tcp_stream).await;
(tls_stream, addr)
}
}
Esse código é fácil de raciocinar, mas coloca todo o handshake de TLS dentro de accept().
Para o Axum, accept() é a porta de entrada. Se estiver ocupada à espera de uma ligação, o servidor não está a receber a ligação completada seguinte desse listener.
Isso parece simples, mas cria head-of-line blocking.
Se uma ligação iniciava o TLS e depois bloqueava, a Edge esperava pelo timeout dessa ligação antes de avançar. Durante essa espera, as ligações saudáveis podiam ficar atrasadas atrás dela.
O problema não era:
o TLS não funciona
Era:
um handshake de TLS incompleto pode atrasar handshakes saudáveis posteriores
Essa distinção importava.
Aumentar o timeout não teria resolvido o problema. Teria feito o caminho lento segurar a fila durante mais tempo.
A correção
A correção foi separar a aceitação de uma ligação da conclusão do seu handshake de TLS.
A Edge agora aceita novas ligações rapidamente e trata de cada handshake de TLS de forma independente. Um handshake bloqueado ou incompleto ainda pode expirar, mas não impede que as ligações saudáveis posteriores progridam.
Conceptualmente, o novo fluxo é assim:
aceitar ligações rapidamente
tratar de cada handshake de TLS de forma independente
devolver ao tratamento de pedidos apenas as ligações seguras concluídas
A nova forma usa tasks Tokio mais um channel de ligações seguras concluídas:
let (ready_tx, ready_rx) = tokio::sync::mpsc::channel(limit);
tokio::spawn(async move {
loop {
let (tcp_stream, addr) = tcp_listener.accept().await;
let ready_tx = ready_tx.clone();
tokio::spawn(async move {
if let Some(tls_stream) = finish_tls(tcp_stream).await {
let _ = ready_tx.send((tls_stream, addr)).await;
}
});
}
});
Depois, o listener voltado para o Axum torna-se muito mais pequeno:
impl axum::serve::Listener for EdgeTlsListener {
async fn accept(&mut self) -> (TlsStream, SocketAddr) {
self.ready_rx
.recv()
.await
.expect("TLS accept loop terminated")
}
}
A parte importante é a fronteira: accept() já não executa ele próprio o trabalho lento de handshake. Recebe handshakes que já foram concluídos.
Assim, o modo de falha mudou disto:
um handshake bloqueado
-> atrasa a ligação seguinte
para isto:
um handshake bloqueado
-> expira de forma independente
-> as ligações saudáveis continuam
Esta é a melhoria de fiabilidade importante.
A correção não eliminou os timeouts. Os timeouts continuam a ser necessários. Um handshake incompleto não deve viver para sempre.
A correção mudou onde o timeout é pago. Uma ligação má agora paga o seu próprio timeout em vez de fazer com que outras ligações o paguem por ela.
Mantê-lo limitado
Há uma segunda parte importante da correção.
Se cada nova ligação puder criar trabalho ilimitado, então o serviço torna-se responsivo mas não seguro sob pressão. Por isso, a Edge também limita o número de handshakes de TLS que podem estar em curso ao mesmo tempo.
A versão simplificada é assim:
let permits = Arc::new(tokio::sync::Semaphore::new(limit));
loop {
let (tcp_stream, addr) = tcp_listener.accept().await;
let permit = permits.clone().acquire_owned().await.expect("TLS semaphore closed");
let ready_tx = ready_tx.clone();
tokio::spawn(async move {
let _permit = permit;
if let Some(tls_stream) = finish_tls(tcp_stream).await {
let _ = ready_tx.send((tls_stream, addr)).await;
}
});
}
O permit pertence à task. Quando a task termina, o Rust faz drop do permit e devolve capacidade ao semáforo. Isso mantém o limite de concorrência ligado ao tempo de vida do trabalho de handshake real.
Isso dá-nos as duas propriedades que queríamos:
os handshakes lentos não bloqueiam os handshakes saudáveis
e:
os handshakes lentos não podem criar trabalho ilimitado
Este é o tipo de compromisso que nos importa na Edge: melhorar a fiabilidade sem abdicar de uma utilização de recursos previsível.
Tornar a falha visível
Também apertámos um caminho de falha interno.
Se a parte da Edge responsável por aceitar ligações seguras alguma vez parar inesperadamente, o serviço não deve esperar silenciosamente para sempre. Os bloqueios silenciosos são difíceis de operar e difíceis de raciocinar.
O channel torna este estado explícito. Se todos os emissores desaparecerem, receber do channel devolve None. Isso não deve ser tratado como tempo inativo normal, por isso o corpo final de accept() substitui o anterior .expect() por uma falha explícita e registada:
self.ready_rx.recv().await.unwrap_or_else(|| {
tracing::error!("TLS accept loop terminated");
panic!("TLS accept loop terminated")
})
O caminho de falha agora torna-se visível imediatamente em vez de se transformar numa espera oculta.
Isso não altera o tráfego normal dos clientes, mas torna o sistema mais fácil de confiar durante incidentes.
O que aprendemos
A principal lição é que os timeouts não chegam se o timeout for pago no sítio errado.
Um timeout à volta do trabalho de TLS parece razoável. Mas se uma ligação lenta puder fazer com que ligações não relacionadas esperem atrás dela, o timeout torna-se uma dor partilhada.
O melhor modelo é:
aceitar rapidamente
isolar o trabalho lento
limitar a concorrência
tornar a falha inesperada visível
Outra lição é que os serviços expostos à internet devem tratar as ligações incompletas como normais. Os clientes desligam-se. As verificações de saúde voltam a tentar. As redes oscilam. Alguns handshakes nunca terminam.
A Edge não deve assumir que a internet é arrumada.
Antes da correção:
um handshake incompleto
-> o tráfego saudável próximo pode esperar
Depois da correção:
um handshake incompleto
-> timeout isolado
-> o tráfego saudável continua
O patch final não fez as ligações más desaparecer.
Fez com que a Edge as tratasse no sítio certo, com os limites certos, mantendo as garantias de desempenho e segurança de memória que esperamos dos nossos serviços Rust.







