Bij cside leggen we sterk de nadruk op geheugenveiligheid en prestaties. Al onze kernservices zijn geschreven in Rust, inclusief de Edge-service.
De Edge bevindt zich op een gevoelige grens. Hij moet snel, veilig en veerkrachtig zijn, want hij maakt deel uit van het pad waar we hoogwaardige signalen verzamelen voor producten zoals VPN-detectie en bot-detectie.
Voor veel webapplicaties is de eenvoudigste TLS-opzet om een cloud-load-balancer TLS te laten termineren en gewone HTTP door te sturen naar de applicatie. Dat is een goede standaardkeuze wanneer de applicatie alleen het uiteindelijke HTTP-verzoek nodig heeft.
De Edge heeft een andere taak.
Sommige detectiesignalen bestaan al voordat het verzoek gewone HTTP wordt, op laag 4 (de transportlaag) in plaats van laag 7. Als TLS volledig wordt getermineerd voordat het verkeer de Edge bereikt, zijn die signalen niet langer in dezelfde vorm beschikbaar. Voor dit deel van het platform moet de Edge daarom op laag 4 werken en het TLS-pad rechtstreeks beheren.
Dat geeft cside een betere signaalkwaliteit voor detectie, maar het betekent ook dat de Edge zelf het TLS-gedrag uit de praktijk moet afhandelen.
Dit bericht gaat over één kleine betrouwbaarheidsverbetering in dat pad: het TLS-handshake-werk uit het Axum-accept-pad halen en in begrensde Tokio-taken plaatsen.
Wat begon te falen
De patch zelf was niet groot, maar het begrijpen van de storing kostte wat uitzoekwerk.
De Edge was gezond. Certificaten werden geladen. De poort was bereikbaar. Het meeste verkeer gedroeg zich normaal.
Maar onder hogere belasting leken sommige HTTPS-checks en clientverbindingen vast te lopen of een timeout te krijgen.
In eerste instantie kan dat op een TLS-probleem lijken. In de praktijk was het belangrijke patroon specifieker: sommige clients openden een verbinding maar voltooiden de TLS-handshake niet.
Dat is normaal op een service die op het internet is gericht. Publieke eindpunten zien voortdurend onvolledige verbindingen:
client maakt verbinding en stuurt vervolgens niets
client start TLS en verdwijnt vervolgens
client start TLS en blijft vervolgens hangen
Die storingen waren niet het verrassende deel.
Het verrassende deel was hoeveel impact één onvolledige handshake kon hebben op nabijgelegen gezond verkeer.
Voor de fix
Voor de fix deed één deel van het Edge-TLS-pad te veel werk in één enkele stap.
Conceptueel gedroeg het zich zo:
accepteer één verbinding
voltooi het TLS-werk voor die verbinding
accepteer vervolgens de volgende verbinding
In vereenvoudigde Rust zag de vorm er ongeveer zo uit:
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)
}
}
Die code is makkelijk te doorgronden, maar plaatst de volledige TLS-handshake binnen accept().
Voor Axum is accept() de voordeur. Als die bezig is met wachten op één verbinding, ontvangt de server niet de volgende voltooide verbinding van die listener.
Dat lijkt eenvoudig, maar het veroorzaakt head-of-line blocking.
Als één verbinding TLS startte en vervolgens bleef hangen, wachtte de Edge op de timeout van die verbinding voordat hij verderging. Tijdens dat wachten konden gezonde verbindingen erachter vertraging oplopen.
Het probleem was niet:
TLS kan niet werken
Het was:
één onvolledige TLS-handshake kan latere gezonde handshakes vertragen
Dat onderscheid deed ertoe.
Het verhogen van de timeout zou het probleem niet hebben opgelost. Het zou ervoor hebben gezorgd dat het trage pad de boel nog langer ophield.
De fix
De fix bestond eruit het accepteren van een verbinding te scheiden van het voltooien van de bijbehorende TLS-handshake.
De Edge accepteert nu snel nieuwe verbindingen en handelt elke TLS-handshake onafhankelijk af. Een vastgelopen of onvolledige handshake kan nog steeds een timeout krijgen, maar blokkeert latere gezonde verbindingen niet langer in hun voortgang.
Conceptueel ziet de nieuwe flow er zo uit:
accepteer verbindingen snel
handel elke TLS-handshake onafhankelijk af
geef alleen voltooide beveiligde verbindingen door aan de verzoekafhandeling
De nieuwe vorm gebruikt Tokio-taken plus een channel van voltooide beveiligde verbindingen:
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;
}
});
}
});
De listener die naar Axum is gericht, wordt dan veel kleiner:
impl axum::serve::Listener for EdgeTlsListener {
async fn accept(&mut self) -> (TlsStream, SocketAddr) {
self.ready_rx
.recv()
.await
.expect("TLS accept loop terminated")
}
}
Het belangrijke deel is de grens: accept() voert het trage handshake-werk niet langer zelf uit. Het ontvangt handshakes die al voltooid zijn.
Zo veranderde de storingsmodus van dit:
één vastgelopen handshake
-> vertraagt de volgende verbinding
naar dit:
één vastgelopen handshake
-> krijgt onafhankelijk een timeout
-> gezonde verbindingen gaan door
Dat is de belangrijke betrouwbaarheidsverbetering.
De fix heeft de timeouts niet verwijderd. Timeouts zijn nog steeds noodzakelijk. Een onvolledige handshake zou niet eeuwig moeten blijven bestaan.
De fix veranderde waar de timeout wordt betaald. Een slechte verbinding betaalt nu haar eigen timeout in plaats van andere verbindingen ervoor te laten betalen.
Het begrensd houden
Er is een tweede belangrijk deel van de fix.
Als elke nieuwe verbinding onbeperkt werk kan creëren, dan wordt de service wel responsief maar niet veilig onder druk. Daarom begrenst de Edge ook het aantal TLS-handshakes dat tegelijkertijd onderweg kan zijn.
De vereenvoudigde versie ziet er zo uit:
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;
}
});
}
De permit is eigendom van de taak. Wanneer de taak klaar is, laat Rust de permit vallen en geeft de capaciteit terug aan de semafoor. Daardoor blijft de concurrency-grens gekoppeld aan de levensduur van het daadwerkelijke handshake-werk.
Dat geeft ons beide eigenschappen die we wilden:
trage handshakes blokkeren geen gezonde handshakes
en:
trage handshakes kunnen geen onbegrensd werk creëren
Dit is het soort afweging dat ons aan het hart gaat in de Edge: de betrouwbaarheid verbeteren zonder voorspelbaar resourcegebruik op te geven.
Storingen zichtbaar maken
We hebben ook een intern storingspad aangescherpt.
Als het deel van de Edge dat verantwoordelijk is voor het accepteren van beveiligde verbindingen ooit onverwacht stopt, zou de service niet stilletjes eeuwig moeten blijven wachten. Stille blokkades zijn lastig te beheren en lastig te doorgronden.
De channel maakt deze toestand expliciet. Als alle senders weg zijn, geeft het ontvangen uit de channel None terug. Dat zou niet als normale inactiviteit moeten worden behandeld, dus vervangt de uiteindelijke accept()-body de eerdere .expect() door een expliciete, gelogde storing:
self.ready_rx.recv().await.unwrap_or_else(|| {
tracing::error!("TLS accept loop terminated");
panic!("TLS accept loop terminated")
})
Het storingspad wordt nu onmiddellijk zichtbaar in plaats van te veranderen in een verborgen wachttijd.
Dat verandert niets aan normaal klantverkeer, maar het maakt het systeem makkelijker te vertrouwen tijdens incidenten.
Wat we hebben geleerd
De belangrijkste les is dat timeouts niet genoeg zijn als de timeout op de verkeerde plek wordt betaald.
Een timeout rond TLS-werk klinkt redelijk. Maar als één trage verbinding ongerelateerde verbindingen erachter kan laten wachten, wordt de timeout gedeelde pijn.
Het betere model is:
accepteer snel
isoleer traag werk
begrens de concurrency
maak onverwachte storingen zichtbaar
Een andere les is dat services die op het internet gericht zijn, onvolledige verbindingen als normaal moeten beschouwen. Clients verbreken de verbinding. Health checks proberen het opnieuw. Netwerken haperen. Sommige handshakes worden nooit voltooid.
De Edge zou er niet van uit moeten gaan dat het internet netjes is.
Voor de fix:
één onvolledige handshake
-> nabijgelegen gezond verkeer kan moeten wachten
Na de fix:
één onvolledige handshake
-> geïsoleerde timeout
-> gezond verkeer gaat door
De uiteindelijke patch liet slechte verbindingen niet verdwijnen.
Hij zorgde ervoor dat de Edge ze op de juiste plek afhandelt, met de juiste grenzen, terwijl de prestatie- en geheugenveiligheidsgaranties behouden blijven die we van onze Rust-services verwachten.







