Chez cside, nous accordons une grande importance à la sûreté mémoire et à la performance. Tous nos services centraux sont écrits en Rust, y compris le service Edge.
L'Edge se situe à une frontière sensible. Il doit être rapide, sûr et résilient, car il fait partie du chemin où nous collectons des signaux de haute qualité pour des produits comme la détection de VPN et la détection de bot.
Pour de nombreuses applications web, la configuration TLS la plus simple consiste à laisser un répartiteur de charge cloud terminer TLS et transmettre du HTTP en clair à l'application. C'est un bon comportement par défaut lorsque l'application n'a besoin que de la requête HTTP finale.
L'Edge a un rôle différent.
Certains signaux de détection existent avant que la requête ne devienne du HTTP ordinaire, à la couche 4 (la couche transport) plutôt qu'à la couche 7. Si TLS est entièrement terminé avant que le trafic n'atteigne l'Edge, ces signaux ne sont plus disponibles sous la même forme. Pour cette partie de la plateforme, l'Edge doit donc opérer à la couche 4 et gérer le chemin TLS directement.
Cela donne à cside une meilleure qualité de signal pour la détection, mais cela signifie aussi que l'Edge doit gérer lui-même le comportement réel de TLS.
Cet article porte sur une petite correction de fiabilité dans ce chemin : sortir le travail de handshake TLS du chemin d'acceptation d'Axum pour le placer dans des tâches Tokio bornées.
Ce qui a commencé à échouer
Le correctif en lui-même n'était pas volumineux, mais comprendre la panne a demandé quelques recherches.
L'Edge était en bonne santé. Les certificats se chargeaient. Le port était joignable. La plupart du trafic se comportait normalement.
Mais sous une charge plus élevée, certaines vérifications HTTPS et connexions clientes semblaient se bloquer ou expirer.
Au premier abord, cela peut ressembler à un problème de TLS. En pratique, le schéma important était plus précis : certains clients ouvraient une connexion mais ne terminaient pas le handshake TLS.
C'est normal sur un service exposé à internet. Les points d'accès publics voient des connexions incomplètes en permanence :
le client se connecte, puis n'envoie rien
le client démarre TLS, puis disparaît
le client démarre TLS, puis se fige
Ces échecs n'étaient pas la partie surprenante.
La partie surprenante, c'était l'ampleur de l'impact qu'un seul handshake incomplet pouvait avoir sur le trafic sain à proximité.
Avant le correctif
Avant le correctif, une partie du chemin TLS de l'Edge faisait trop de travail en une seule étape.
Conceptuellement, son comportement était le suivant :
accepter une connexion
terminer le travail TLS pour cette connexion
puis accepter la connexion suivante
En Rust simplifié, la forme ressemblait à peu près à ceci :
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)
}
}
Ce code est facile à appréhender, mais il place l'intégralité du handshake TLS à l'intérieur de accept().
Pour Axum, accept() est la porte d'entrée. S'il est occupé à attendre une connexion, le serveur ne reçoit pas la prochaine connexion terminée depuis ce listener.
Cela paraît simple, mais cela crée du head-of-line blocking.
Si une connexion démarrait TLS puis se figeait, l'Edge attendait le timeout de cette connexion avant de passer à la suite. Pendant cette attente, des connexions saines pouvaient se retrouver retardées derrière elle.
Le problème n'était pas :
TLS ne peut pas fonctionner
C'était :
un seul handshake TLS incomplet peut retarder des handshakes sains ultérieurs
Cette distinction était importante.
Augmenter le timeout n'aurait pas résolu le problème. Cela aurait simplement fait tenir la file plus longtemps au chemin lent.
Le correctif
Le correctif a consisté à séparer l'acceptation d'une connexion de la finalisation de son handshake TLS.
L'Edge accepte désormais rapidement les nouvelles connexions et traite chaque handshake TLS indépendamment. Un handshake figé ou incomplet peut toujours expirer, mais il n'empêche plus des connexions saines ultérieures de progresser.
Conceptuellement, le nouveau flux ressemble à ceci :
accepter rapidement les connexions
traiter chaque handshake TLS indépendamment
ne renvoyer au traitement des requêtes que les connexions sécurisées terminées
La nouvelle forme utilise des tâches Tokio ainsi qu'un channel de connexions sécurisées terminées :
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;
}
});
}
});
Le listener exposé à Axum devient alors beaucoup plus petit :
impl axum::serve::Listener for EdgeTlsListener {
async fn accept(&mut self) -> (TlsStream, SocketAddr) {
self.ready_rx
.recv()
.await
.expect("TLS accept loop terminated")
}
}
L'élément important, c'est la frontière : accept() ne réalise plus lui-même le travail lent de handshake. Il reçoit des handshakes qui sont déjà terminés.
Le mode de défaillance est ainsi passé de ceci :
un handshake figé
-> retarde la connexion suivante
à ceci :
un handshake figé
-> expire de façon indépendante
-> les connexions saines continuent
C'est là l'amélioration de fiabilité importante.
Le correctif n'a pas supprimé les timeouts. Les timeouts restent nécessaires. Un handshake incomplet ne devrait pas vivre éternellement.
Le correctif a changé l'endroit où le timeout est payé. Une mauvaise connexion paie désormais son propre timeout au lieu de le faire payer aux autres connexions.
Garder le tout borné
Il y a une seconde partie importante dans ce correctif.
Si chaque nouvelle connexion peut créer un travail illimité, le service devient réactif mais pas sûr sous pression. L'Edge borne donc aussi le nombre de handshakes TLS pouvant être en cours simultanément.
La version simplifiée ressemble à ceci :
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;
}
});
}
Le permit appartient à la tâche. Lorsque la tâche se termine, Rust libère le permit et restitue de la capacité au sémaphore. Cela maintient la borne de concurrence liée à la durée de vie du travail de handshake réel.
Cela nous donne les deux propriétés que nous voulions :
les handshakes lents ne bloquent pas les handshakes sains
et :
les handshakes lents ne peuvent pas créer de travail illimité
C'est le genre de compromis qui nous tient à cœur dans l'Edge : améliorer la fiabilité sans renoncer à une utilisation prévisible des ressources.
Rendre les défaillances visibles
Nous avons aussi resserré un chemin de défaillance interne.
Si la partie de l'Edge chargée d'accepter les connexions sécurisées s'arrête un jour de façon inattendue, le service ne devrait pas attendre silencieusement pour toujours. Les blocages silencieux sont difficiles à exploiter et difficiles à appréhender.
Le channel rend cet état explicite. Si tous les expéditeurs ont disparu, la réception depuis le channel renvoie None. Cela ne devrait pas être traité comme une période d'inactivité normale ; le corps final de accept() remplace donc le précédent .expect() par une défaillance explicite et journalisée :
self.ready_rx.recv().await.unwrap_or_else(|| {
tracing::error!("TLS accept loop terminated");
panic!("TLS accept loop terminated")
})
Le chemin de défaillance devient désormais visible immédiatement au lieu de se transformer en une attente cachée.
Cela ne change rien au trafic client normal, mais cela rend le système plus facile à faire confiance pendant les incidents.
Ce que nous avons appris
La principale leçon, c'est que les timeouts ne suffisent pas si le timeout est payé au mauvais endroit.
Un timeout autour du travail TLS semble raisonnable. Mais si une connexion lente peut forcer des connexions sans rapport à attendre derrière elle, le timeout devient une souffrance partagée.
Le meilleur modèle est :
accepter rapidement
isoler le travail lent
borner la concurrence
rendre visibles les défaillances inattendues
Une autre leçon, c'est que les services exposés à internet devraient considérer les connexions incomplètes comme normales. Les clients se déconnectent. Les health checks réessaient. Les réseaux vacillent. Certains handshakes ne se terminent jamais.
L'Edge ne devrait pas présumer qu'internet est bien rangé.
Avant le correctif :
un handshake incomplet
-> le trafic sain à proximité peut attendre
Après le correctif :
un handshake incomplet
-> timeout isolé
-> le trafic sain continue
Le correctif final n'a pas fait disparaître les mauvaises connexions.
Il a fait en sorte que l'Edge les gère au bon endroit, avec les bonnes bornes, tout en préservant les garanties de performance et de sûreté mémoire que nous attendons de nos services en Rust.







