Streaming de archivos grandes desde el servidor HTTP de sgcWebSockets

· Componentes

Un cliente nos escribió con una observación sencilla que resultó ser exactamente correcta: servir archivos desde el servidor HTTP consumía mucha memoria. Si TsgcWebSocketHTTPServer servía un archivo grande desde DocumentRoot, el archivo completo se leía en RAM por cada conexión. Cien clientes descargando el mismo archivo de 1 GB significaban aproximadamente 100 GB de RAM. Esta versión lo soluciona. Ahora los archivos se transmiten desde disco y la memoria del servidor se mantiene estable independientemente del tamaño del archivo y de cuántos clientes estén conectados. Llega en sgcWebSockets 2026.6.0.

El problema: una copia completa por conexión

Ambas rutas de archivos estáticos, DoResponseHTTP para HTTP/1.x y DoHTTP2DocumentRoot para HTTP/2, construían la respuesta de la misma manera. Creaban un TMemoryStream, llamaban a LoadFromFile y entregaban ese stream a la respuesta. LoadFromFile lee el archivo entero en memoria de una sola vez, por lo que el coste por solicitud era el tamaño completo del archivo, y cada descarga concurrente mantenía su propia copia.

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

Eso hace que la memoria máxima sea aproximadamente file size × concurrent connections. Un puñado de clientes descargando un vídeo, un instalador, una imagen de disco o una copia de seguridad de base de datos basta para llevar a un servidor al swapping o a un fallo por falta de memoria.

El multiplicador de la lectura lenta

El mismo cliente describió un segundo escenario, y es un ataque real: muchas conexiones que leen la respuesta muy despacio, del orden de un byte por segundo. Se trata de un ataque de lectura lenta, una variante de Slowloris. Un cliente que va leyendo a cuentagotas mantiene la conexión abierta durante muchísimo tiempo, y con el código antiguo cada una de esas conexiones también retenía una copia completa del archivo en memoria. Unos pocos miles de lectores lentos de un archivo modesto podían mantener gigabytes como rehenes mientras transferían casi nada.

Cómo lo medimos

Construimos un pequeño banco de pruebas: un servidor con un DocumentRoot y un archivo de prueba grande, más un cliente con dos modos. Un modo «rápido» abre N descargas normales concurrentes, y un modo «lento» abre M conexiones en crudo que envían una solicitud válida y luego leen el cuerpo a un byte por segundo. Tomamos muestras del working set del servidor con un archivo de 100 MB, antes de la corrección y después.

Escenario (archivo de 100 MB)Antes de la correcciónDespués de la corrección
5 descargas concurrentes~511 MB~11 MB
10 descargas concurrentes~1,012 MB~11 MB
20 descargas concurrentes~1.7 GB~12 MB
100 conexiones de lectura lenta (1 byte/sec)~1.67 GB retenidos de forma estable~19 MB
Integridad del archivon/dSHA-256 byte a byte exacto, Content-Length correcto

Antes de la corrección, la memoria seguía casi exactamente el tamaño del archivo multiplicado por el número de conexiones. Después de la corrección se mantiene en decenas de megabytes sin importar lo grande que sea el archivo ni cuántos clientes se conecten.

La solución: transmitir desde disco

El archivo ahora se sirve con un TFileStream de solo lectura y compartido en lugar de un TMemoryStream. No hubo que cambiar nada más en el pipeline, porque el lado de escritura ya transmite: el servidor HTTP/1.x envía el stream de contenido en fragmentos de 32 KB, y el servidor HTTP/2 lo emite en tramas DATA de aproximadamente 64 KB. Así, el servidor lee un fragmento pequeño del disco, lo envía y continúa, lo que reduce el coste de memoria por conexión del archivo completo a unas pocas decenas de kilobytes.

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

Content-Length sigue siendo correcto, porque proviene de TFileStream.Size, y el stream se libera tras la respuesta exactamente igual que antes. Verificamos que las descargas son idénticas byte a byte al archivo de origen mediante una comparación SHA-256, y que la cabecera informa de la longitud correcta. No hay ningún cambio de API ni nada que configurar: establecer DocumentRoot funciona como siempre lo hizo, simplemente ya no almacena el archivo en buffer.

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

La edición .NET hereda la corrección automáticamente, porque el servicio de archivos de su DocumentRoot se ejecuta en el mismo núcleo compilado que usan los componentes Delphi.

Frenando a los lectores lentos

Transmitir desde disco elimina la amplificación de memoria, así que un lector lento ya no puede retener un archivo entero en RAM. Lo que queda es la conexión en sí, y para eso existe un timeout de envío. Options.WriteTimeOut establece un plazo límite para las escrituras de socket, de modo que un cliente que deja de aceptar datos se descarta en lugar de bloquear un hilo del servidor para siempre. En esta versión ese timeout también funciona en Linux y en otras plataformas POSIX, no solo en Windows. La API de sockets POSIX espera un timeval en lugar de un entero de milisegundos, y eso ahora se gestiona correctamente.

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;

Para un servidor público recomendamos combinar tres palancas: establece Options.WriteTimeOut para que se descarten las escrituras estancadas, usa el RateLimit.MaxConnectionsPerIP del firewall para limitar cuántas conexiones puede abrir una sola dirección, y considera un planificador con pool de hilos o los manejadores IOCP y EPOLL para que una avalancha de conexiones lentas no agote un hilo por cada una.

Actualización

La corrección de streaming es directa, sin cambios. En cuanto actualices, DocumentRoot sirve los archivos desde disco con memoria estable, sin ningún cambio de código. El cambio se verificó en Delphi 7 hasta 13, en las rutas HTTP/1.x y HTTP/2, y la compilación .NET lo recoge a través del núcleo compartido. Si sirves archivos estáticos grandes, esto supone una reducción significativa de memoria y un paso útil contra el abuso de lectura lenta.

Actualiza desde la página de descarga de sgcWebSockets, u obtenlo a través de GetIt o de tu cuenta registrada.

¿Preguntas, comentarios o ayuda con la migración? Ponte en contacto, recibirás respuesta de las personas que escribieron el código.