あるお客様から、まさに的を射た指摘をいただきました。HTTP サーバーからのファイル配信が大量のメモリを使う、という観察です。TsgcWebSocketHTTPServer が DocumentRoot から大容量ファイルを配信すると、接続ごとにファイル全体が RAM に読み込まれていました。100 のクライアントが同じ 1 GB のファイルをダウンロードすると、約 100 GB の RAM が必要になります。今回のリリースでこれを修正しました。ファイルはディスクからストリーミングされるようになり、ファイルサイズや接続クライアント数にかかわらずサーバーのメモリはほぼ一定に保たれます。これは sgcWebSockets 2026.6.0 で提供されます。
問題: 接続ごとにファイル全体のコピーが 1 つ
静的ファイルの 2 つの経路、HTTP/1.x 向けの DoResponseHTTP と HTTP/2 向けの DoHTTP2DocumentRoot は、いずれも同じ方法でレスポンスを構築していました。TMemoryStream を作成し、LoadFromFile を呼び出して、そのストリームをレスポンスに渡していたのです。LoadFromFile はファイル全体を一度にメモリへ読み込むため、リクエストごとのコストはファイルサイズそのものになり、同時に行われるダウンロードはそれぞれ独自のコピーを保持していました。
// Old behaviour, simplified
oStream := TMemoryStream.Create;
oStream.LoadFromFile(vDocument); // entire file into RAM
AResponseInfo.ContentStream := oStream;
その結果、ピークメモリはおおよそ file size × concurrent connections になります。動画、インストーラー、ディスクイメージ、データベースのバックアップを取得するクライアントが少しいるだけで、サーバーはスワップやメモリ不足によるクラッシュに追い込まれてしまいます。
低速読み取りによる増幅
同じお客様が 2 つ目のシナリオも説明してくださいました。これは実際の攻撃です。多数の接続がそれぞれレスポンスを非常にゆっくり、1 秒あたり 1 バイト程度の速度で読み取る、というものです。これは低速読み取り攻撃で、Slowloris の一種です。読み取りを少しずつ行うクライアントは接続を非常に長時間開いたままにします。そして従来のコードでは、それらの接続のそれぞれがメモリ上のファイル全体のコピーを固定し続けていました。控えめなサイズのファイルでも、数千の低速読み取りクライアントがいれば、ほとんど何も転送しないままギガバイト単位のメモリを人質に取ることができたのです。
計測方法
小さなテスト用ハーネスを構築しました。DocumentRoot と大きなテストファイルを持つサーバーと、2 つのモードを持つクライアントです。「高速」モードは N 件の通常のダウンロードを同時に開始し、「低速」モードは有効なリクエストを送信した後に本文を 1 秒あたり 1 バイトで読み取る M 件の生接続を開きます。100 MB のファイルを使い、修正前と修正後でサーバーのワーキングセットをサンプリングしました。
| シナリオ (100 MB ファイル) | 修正前 | 修正後 |
|---|---|---|
| 同時ダウンロード 5 件 | ~511 MB | ~11 MB |
| 同時ダウンロード 10 件 | ~1,012 MB | ~11 MB |
| 同時ダウンロード 20 件 | ~1.7 GB | ~12 MB |
| 低速読み取り (1 byte/sec) 接続 100 件 | ~1.67 GB を一定に保持 | ~19 MB |
| ファイルの整合性 | 該当なし | SHA-256 でバイト単位まで一致、正しい Content-Length |
修正前は、メモリがファイルサイズと接続数の積をほぼ正確に追従していました。修正後は、ファイルがどれほど大きくても、何台のクライアントが接続しても、数十メガバイトにとどまります。
修正: ディスクからストリーミング
ファイルは TMemoryStream ではなく、読み取り専用で共有された TFileStream で配信されるようになりました。パイプラインの他の部分を変更する必要はありませんでした。書き込み側はすでにストリーミングしているからです。HTTP/1.x サーバーはコンテンツストリームを 32 KB のチャンクで送信し、HTTP/2 サーバーはおおよそ 64 KB の DATA フレームで送出します。つまりサーバーはディスクから小さなチャンクを読み込んで送信し、次へ進むだけで、接続ごとのメモリコストがファイル全体から数十キロバイトまで下がるのです。
// New behaviour, simplified
oStream := TFileStream.Create(vDocument, fmOpenRead or fmShareDenyWrite);
oStream.Position := 0;
AResponseInfo.ContentStream := oStream;
Content-Length は TFileStream.Size から得られるため引き続き正しく、ストリームはこれまでとまったく同じくレスポンス後に解放されます。ダウンロードがソースファイルとバイト単位で一致することを SHA-256 比較で検証し、ヘッダーが正しい長さを報告することも確認しました。API の変更はなく、設定する項目もありません。DocumentRoot の設定はこれまでどおり動作し、ただファイルをバッファリングしなくなっただけです。
oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.Port := 80;
oServer.DocumentRoot := 'C:\inetpub\files'; // large files now stream from disk
oServer.Active := True;
.NET 版はこの修正を自動的に継承します。その DocumentRoot のファイル配信は、Delphi コンポーネントが使用するのと同じコンパイル済みコアで動作するからです。
低速読み取りクライアントへの対処
ディスクからのストリーミングによってメモリの増幅がなくなったため、低速読み取りクライアントが RAM にファイル全体を固定することはもはやできません。残るのは接続そのものであり、それには送信タイムアウトがあります。Options.WriteTimeOut はソケット書き込みに期限を設定するため、データの受信を止めたクライアントは、サーバースレッドを永久にブロックする代わりに切断されます。今回のリリースでは、このタイムアウトが Windows だけでなく Linux やその他の POSIX プラットフォームでも機能します。POSIX のソケット API はミリ秒整数ではなく timeval を期待しますが、それが正しく処理されるようになりました。
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;
公開サーバーには、3 つの仕組みを組み合わせることをおすすめします。Options.WriteTimeOut を設定して停滞した書き込みを切断すること、ファイアウォールの RateLimit.MaxConnectionsPerIP を使って単一アドレスが開ける接続数を制限すること、そしてスレッドプールスケジューラや IOCP・EPOLL ハンドラーを検討して、低速接続の殺到が 1 つずつスレッドを使い切ってしまわないようにすることです。
アップグレード
このストリーミング修正はドロップインです。更新するとすぐに、DocumentRoot はコード変更なしにフラットなメモリでファイルを配信します。この変更は Delphi 7 から 13 まで、HTTP/1.x と HTTP/2 の両経路で検証済みで、.NET ビルドは共有コアを通じてこれを取り込みます。大容量の静的ファイルを配信している場合、これはメモリの有意義な削減であり、低速読み取りの悪用に対する有用な一歩となります。
sgcWebSockets ダウンロードページから更新するか、GetIt または登録済みのアカウントから入手してください。
ご質問、ご意見、移行に関するお手伝いが必要ですか? お問い合わせください。コードを書いた本人からの返信が届きます。
