Streaming di file di grandi dimensioni dal server HTTP di sgcWebSockets

· Componenti

Un cliente ci ha scritto con una semplice osservazione che si è rivelata assolutamente corretta: servire file dal server HTTP usava molta memoria. Se TsgcWebSocketHTTPServer serviva un file di grandi dimensioni da DocumentRoot, l'intero file veniva letto in RAM per ogni connessione. Cento client che scaricavano lo stesso file da 1 GB significavano circa 100 GB di RAM. Questa release risolve il problema. Ora i file vengono trasmessi in streaming dal disco e la memoria del server rimane costante indipendentemente dalla dimensione del file e dal numero di client connessi. È incluso in sgcWebSockets 2026.6.0.

Il problema: una copia completa per ogni connessione

Entrambi i percorsi per i file statici, DoResponseHTTP per HTTP/1.x e DoHTTP2DocumentRoot per HTTP/2, costruivano la risposta allo stesso modo. Creavano un TMemoryStream, chiamavano LoadFromFile e passavano quello stream alla risposta. LoadFromFile legge l'intero file in memoria in un'unica operazione, quindi il costo per richiesta era pari alla dimensione completa del file, e ogni download concorrente conteneva la propria copia.

// Old behaviour, simplified
oStream := TMemoryStream.Create;
oStream.LoadFromFile(vDocument);   // entire file into RAM
AResponseInfo.ContentStream := oStream;

Questo rende la memoria di picco circa file size × concurrent connections. Bastano una manciata di client che scaricano un video, un installer, un'immagine disco o un backup di database per spingere un server in swap o in un crash per esaurimento della memoria.

Il moltiplicatore della lettura lenta

Lo stesso cliente ha descritto un secondo scenario, ed è un vero attacco: molte connessioni che leggono ciascuna la risposta molto lentamente, nell'ordine di un byte al secondo. Questo è un attacco a lettura lenta, una variante di Slowloris. Un client che centellina le proprie letture mantiene la connessione aperta per un tempo molto lungo, e con il vecchio codice ognuna di queste connessioni teneva inoltre bloccata in memoria una copia completa del file. Qualche migliaio di lettori lenti di un file di dimensioni modeste poteva tenere in ostaggio gigabyte trasferendo quasi nulla.

Come l'abbiamo misurato

Abbiamo costruito un piccolo banco di prova: un server con un DocumentRoot e un grande file di test, più un client con due modalità. Una modalità "veloce" apre N download normali concorrenti, e una modalità "lenta" apre M connessioni raw che inviano una richiesta valida e poi leggono il corpo a un byte al secondo. Abbiamo campionato il working set del server con un file da 100 MB, prima della correzione e dopo.

Scenario (file da 100 MB)Prima della correzioneDopo la correzione
5 download concorrenti~511 MB~11 MB
10 download concorrenti~1.012 MB~11 MB
20 download concorrenti~1.7 GB~12 MB
100 connessioni a lettura lenta (1 byte/sec)~1.67 GB mantenuti costanti~19 MB
Integrità del filen/dSHA-256 byte per byte, Content-Length corretto

Prima della correzione, la memoria seguiva quasi esattamente la dimensione del file moltiplicata per il numero di connessioni. Dopo la correzione rimane nell'ordine delle decine di megabyte indipendentemente da quanto sia grande il file o da quanti client si connettano.

La correzione: streaming dal disco

Ora il file viene servito con un TFileStream condiviso e in sola lettura invece di un TMemoryStream. Niente altro nella pipeline ha dovuto cambiare, perché il lato di scrittura era già in streaming: il server HTTP/1.x invia il content stream in blocchi da 32 KB, e il server HTTP/2 lo emette in DATA frame da circa 64 KB. Quindi il server legge un piccolo blocco dal disco, lo invia e prosegue, il che riduce il costo di memoria per connessione dall'intero file a poche decine di kilobyte.

// New behaviour, simplified
oStream := TFileStream.Create(vDocument, fmOpenRead or fmShareDenyWrite);
oStream.Position := 0;
AResponseInfo.ContentStream := oStream;

Content-Length è ancora corretto, perché proviene da TFileStream.Size, e lo stream viene liberato dopo la risposta esattamente come prima. Abbiamo verificato che i download siano identici byte per byte al file sorgente con un confronto SHA-256, e che l'header riporti la lunghezza corretta. Non c'è alcuna modifica all'API e nulla da configurare: impostare DocumentRoot funziona come ha sempre funzionato, semplicemente non bufferizza più il file.

oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.Port := 80;
oServer.DocumentRoot := 'C:\inetpub\files';  // large files now stream from disk
oServer.Active := True;

L'edizione .NET eredita la correzione automaticamente, perché il suo servizio file da DocumentRoot gira nello stesso core compilato che usano i componenti Delphi.

Rallentare i lettori lenti

Lo streaming dal disco elimina l'amplificazione della memoria, così un lettore lento non può più bloccare un intero file in RAM. Ciò che rimane è la connessione stessa, e per questo c'è un timeout di invio. Options.WriteTimeOut imposta una scadenza sulle scritture del socket, così un client che smette di accettare dati viene scartato invece di bloccare per sempre un thread del server. In questa release questo timeout funziona anche su Linux e altre piattaforme POSIX, non solo su Windows. L'API socket POSIX si aspetta un timeval anziché un intero in millisecondi, e ora questo viene gestito correttamente.

oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.DocumentRoot := 'C:\inetpub\files';
oServer.Options.WriteTimeOut := 15000;   // drop a client that stalls for 15s, Windows and POSIX
oServer.Active := True;

Per un server pubblico consigliamo di combinare tre leve: impostare Options.WriteTimeOut così che le scritture bloccate vengano scartate, usare RateLimit.MaxConnectionsPerIP del firewall per limitare quante connessioni può aprire un singolo indirizzo, e considerare uno scheduler con thread pool o gli handler IOCP ed EPOLL così che un'ondata di connessioni lente non possa esaurire un thread ciascuna.

Aggiornamento

La correzione dello streaming è immediata. Non appena aggiorni, DocumentRoot serve i file dal disco con memoria costante, senza alcuna modifica al codice. La modifica è stata verificata da Delphi 7 fino al 13, sui percorsi HTTP/1.x e HTTP/2, e la build .NET la recepisce attraverso il core condiviso. Se servi file statici di grandi dimensioni, questa è una riduzione significativa della memoria e un passo utile contro l'abuso a lettura lenta.

Aggiorna dalla pagina di download di sgcWebSockets, oppure ottienilo tramite GetIt o il tuo account registrato.

Domande, feedback o aiuto per la migrazione? Contattaci, riceverai una risposta dalle persone che hanno scritto il codice.