Strumieniowanie dużych plików z serwera HTTP sgcWebSockets

· Komponenty

Klient napisał do nas z prostą obserwacją, która okazała się całkowicie trafna: serwowanie plików z serwera HTTP zużywało dużo pamięci. Jeśli TsgcWebSocketHTTPServer serwował duży plik z DocumentRoot, cały plik był wczytywany do RAM dla każdego połączenia. Stu klientów pobierających ten sam plik o rozmiarze 1 GB oznaczało mniej więcej 100 GB RAM. To wydanie rozwiązuje ten problem. Pliki są teraz strumieniowane z dysku, a zużycie pamięci serwera pozostaje płaskie niezależnie od rozmiaru pliku i liczby podłączonych klientów. Zmiana trafia do sgcWebSockets 2026.6.0.

Problem: jedna pełna kopia na każde połączenie

Obie ścieżki serwowania plików statycznych, DoResponseHTTP dla HTTP/1.x oraz DoHTTP2DocumentRoot dla HTTP/2, budowały odpowiedź w ten sam sposób. Tworzyły TMemoryStream, wywoływały LoadFromFile i przekazywały ten strumień do odpowiedzi. LoadFromFile wczytuje cały plik do pamięci za jednym razem, więc koszt na żądanie wynosił pełny rozmiar pliku, a każde równoległe pobranie utrzymywało własną kopię.

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

To sprawia, że szczytowe zużycie pamięci wynosi mniej więcej rozmiar pliku × liczba równoległych połączeń. Garstka klientów pobierających film, instalator, obraz dysku lub kopię zapasową bazy danych wystarczy, aby zepchnąć serwer w swapowanie lub awarię z powodu braku pamięci.

Mnożnik powolnego odczytu

Ten sam klient opisał drugi scenariusz, a jest to realny atak: wiele połączeń, z których każde odczytuje odpowiedź bardzo wolno, rzędu jednego bajta na sekundę. To atak typu slow-read, odmiana Slowloris. Klient, który sączy swoje odczyty, utrzymuje połączenie otwarte przez bardzo długi czas, a w starym kodzie każde z tych połączeń przypinało też pełną kopię pliku w pamięci. Kilka tysięcy powolnych czytelników skromnego pliku mogło przetrzymywać gigabajty jako zakładnika, przesyłając przy tym niemal nic.

Jak to zmierzyliśmy

Zbudowaliśmy niewielkie środowisko testowe: serwer z DocumentRoot i dużym plikiem testowym oraz klienta z dwoma trybami. Tryb „szybki” otwiera N równoległych normalnych pobrań, a tryb „wolny” otwiera M surowych połączeń, które wysyłają poprawne żądanie, a następnie odczytują treść z prędkością jednego bajta na sekundę. Próbkowaliśmy zestaw roboczy serwera dla pliku o rozmiarze 100 MB, przed poprawką i po niej.

Scenariusz (plik 100 MB)Przed poprawkąPo poprawce
5 równoległych pobrań~511 MB~11 MB
10 równoległych pobrań~1012 MB~11 MB
20 równoległych pobrań~1.7 GB~12 MB
100 połączeń z powolnym odczytem (1 byte/sec)~1.67 GB utrzymywane płasko~19 MB
Integralność plikunie dotyczySHA-256 bajt w bajt, poprawny Content-Length

Przed poprawką zużycie pamięci śledziło rozmiar pliku pomnożony przez liczbę połączeń niemal dokładnie. Po poprawce pozostaje na poziomie dziesiątek megabajtów niezależnie od tego, jak duży jest plik i ilu klientów się łączy.

Poprawka: strumieniowanie z dysku

Plik jest teraz serwowany za pomocą tylko do odczytu, współdzielonego TFileStream zamiast TMemoryStream. Nic innego w potoku nie musiało się zmienić, ponieważ strona zapisu już strumieniuje: serwer HTTP/1.x wysyła strumień treści w porcjach 32 KB, a serwer HTTP/2 emituje go w ramkach DATA o rozmiarze mniej więcej 64 KB. Serwer odczytuje więc małą porcję z dysku, wysyła ją i przechodzi dalej, co obniża koszt pamięci na połączenie z całego pliku do kilkudziesięciu kilobajtów.

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

Content-Length jest nadal poprawny, ponieważ pochodzi z TFileStream.Size, a strumień jest zwalniany po odpowiedzi dokładnie tak jak wcześniej. Zweryfikowaliśmy, że pobrania są bajt w bajt identyczne z plikiem źródłowym za pomocą porównania SHA-256 oraz że nagłówek raportuje właściwą długość. Nie ma żadnej zmiany API ani niczego do skonfigurowania: ustawienie DocumentRoot działa tak jak zawsze, po prostu nie buforuje już pliku.

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

Edycja .NET dziedziczy poprawkę automatycznie, ponieważ jej serwowanie plików z DocumentRoot działa w tym samym skompilowanym rdzeniu, którego używają komponenty Delphi.

Spowalnianie powolnych czytelników

Strumieniowanie z dysku usuwa amplifikację pamięci, więc powolny czytelnik nie może już przypiąć całego pliku w RAM. Pozostaje samo połączenie, a na to jest limit czasu wysyłki. Options.WriteTimeOut ustawia termin na zapisy do gniazda, więc klient, który przestaje przyjmować dane, jest odłączany zamiast blokować wątek serwera na zawsze. W tym wydaniu ten limit czasu działa również na Linuksie i innych platformach POSIX, nie tylko w Windows. API gniazd POSIX oczekuje timeval, a nie liczby całkowitej milisekund, i jest to teraz obsługiwane poprawnie.

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;

W przypadku serwera publicznego zalecamy połączenie trzech dźwigni: ustaw Options.WriteTimeOut, aby zablokowane zapisy były odrzucane, użyj RateLimit.MaxConnectionsPerIP z zapory, aby ograniczyć, ile połączeń może otworzyć pojedynczy adres, oraz rozważ harmonogram puli wątków lub handlery IOCP i EPOLL, tak aby zalew powolnych połączeń nie mógł wyczerpać po jednym wątku na każde.

Aktualizacja

Poprawka strumieniowania jest typu drop-in. Gdy tylko zaktualizujesz, DocumentRoot serwuje pliki z dysku z płaskim zużyciem pamięci, bez zmiany kodu. Zmiana została zweryfikowana w Delphi od 7 do 13, na ścieżkach HTTP/1.x i HTTP/2, a kompilacja .NET przejmuje ją przez współdzielony rdzeń. Jeśli serwujesz duże pliki statyczne, to znacząca redukcja zużycia pamięci i przydatny krok przeciwko nadużyciom typu slow-read.

Zaktualizuj ze strony pobierania sgcWebSockets lub pobierz przez GetIt albo swoje zarejestrowane konto.

Pytania, opinie lub pomoc przy migracji? Skontaktuj się z nami, otrzymasz odpowiedź od ludzi, którzy napisali ten kod.