Azure Storage の BLOB をプライベート化した途端、「大容量ファイルがダウンロードできなくなった」「OutOfMemoryException が出る」「SAS URL が急に使えない」といった悩みはとても多いです。本記事では、ASP.NET Core(MVC)+ Azure 上の Web アプリから、10〜20GB クラスのファイルを安全かつ効率的にダウンロードさせるための設計と具体的な実装パターンを、ストリーミング配信とネットワーク構成の両面から詳しく解説します。
Azure のプライベート BLOB から大容量をダウンロードしたいシナリオ整理
前提となるシナリオは次のようなものです。
- 社内向け Web アプリ:ASP.NET Core MVC で実装、Azure App Service などでホスト。
- ファイル保存先:Azure Storage(Blob Storage)。
- 以前は BLOB コンテナーが「パブリック」だったため、SAS URL を直接クライアントに渡してダウンロードできていた。
- セキュリティ強化のため、
- BLOB のパブリックアクセスを禁止(コンテナー/アカウントレベル)。
- ストレージアカウントのネットワーク規則を「選択されたネットワークのみ」に変更。
- これにより、クライアントからストレージへの直接アクセスができなくなり、SAS URL 方式が動作しなくなった。
この状況を回避するため、Web アプリが一度 BLOB を取得してからクライアントに転送する「中継方式」を検討したところ、次のような実装で問題が発生しました。
NG 実装:MemoryStream に全量読み込む方式の問題点
典型的な NG コード例
よくある実装は次のような流れです。
- サーバー側で BlobClient から BLOB をダウンロード。
MemoryStreamに全体をダウンロード。MemoryStream.ToArray()でbyte[]に展開。File()やFileContentResultでブラウザに返却。
サンプルコードイメージは以下のようになります。
public async Task<IActionResult> Download(string blobFileName)
{
var blobClient = _container.GetBlobClient(blobFileName);
using var ms = new MemoryStream();
// TransferOptions でチャンクサイズや並列度を指定して高速化しているケースも多い
await blobClient.DownloadToAsync(ms /*, transferOptions*/);
// 全量を byte[] に展開
var bytes = ms.ToArray();
return File(
fileContents: bytes,
contentType: "application/octet-stream",
fileDownloadName: blobFileName
);
}
見た目はシンプルですが、この実装には大きな落とし穴があります。
なぜ OutOfMemoryException が発生するのか
問題点を整理すると次の通りです。
- 全量をメモリに乗せている
MemoryStream自体がファイル全体をメモリに持ちます。ToArray()でさらに新しいbyte[]を確保するため、実質 2倍近いメモリを消費します。- サーバーのメモリが 4GB〜8GB 程度だと、400MB〜1GB クラスのファイルでも簡単に OOM になります。
- 並列ダウンロードでさらにメモリを圧迫
- TransferOptions で並列数を増やしている場合、内部バッファ分もメモリを消費します。
- 「速くしたいから並列数を増やす」ほど、メモリプレッシャーが高まるというジレンマがあります。
- App Service のメモリ制限に引っかかる
- Azure App Service の Standard / Premium プランでも、ワーカーあたりメモリは有限です。
- 他の処理(アプリ本体やフレームワーク、GC など)が使う領域もあるため、BLOB ダウンロードだけに大容量を割り当てられません。
結果として、「400MB くらいで OutOfMemoryException」が発生し、10GB や 18GB といったファイルはとても扱えない、という状況になります。
解決策:サーバーでバッファに貯めずストリーミングで返す
根本的な解決策はシンプルです。
「サーバー側でファイルの全量を保持しない」=「ストリーミングでそのまま返す」
ASP.NET Core では、FileStreamResult や File()(Stream オーバーロード)を使うことで、BLOB から読み出したストリームをそのまま HTTP 応答に流すことができます。これにより、大容量ファイルでもサーバーのメモリ使用量を最小限に抑えられます。
基本パターン:FileStreamResult で BLOB ストリームを返す
最もシンプルなストリーミング実装例です。
public async Task<IActionResult> Download(string blobFileName)
{
var blobClient = _container.GetBlobClient(blobFileName);
// BlobDownloadInfo を取得
var download = await blobClient.DownloadAsync();
// Content プロパティは BLOB のストリーム
var stream = download.Value.Content;
var contentType = download.Value.ContentType ?? "application/octet-stream";
return new FileStreamResult(stream, contentType)
{
FileDownloadName = blobFileName
};
}
ポイントは次の通りです。
- MemoryStream を使わず、BLOB のストリームそのものを返しているため、サーバーは常に小さなバッファ単位でしかファイルを保持しません。
- ブラウザはレスポンスを受け取りながら逐次保存するため、10GB 超でも理論上問題なくダウンロード可能です。
- 質問元では、実際に 10GB のファイルダウンロードが成功し、OOM が解消されています。
薄い経路でレスポンス本体へ直接コピーするパターン
より直接的に、BLOB から HTTP レスポンスボディへコピーするパターンです。余計なオブジェクトを作らないぶん、オーバーヘッドが少なくなります。
[HttpGet("/download/{*blobPath}")]
public async Task<IActionResult> Download(string blobPath, CancellationToken ct)
{
var blob = _container.GetBlobClient(blobPath);
// ストリーミングダウンロード(ヘッダー情報も取得)
var resp = await blob.DownloadStreamingAsync(cancellationToken: ct);
// ファイル名を設定(パスから取得する例)
var fileName = Path.GetFileName(blobPath);
Response.Headers.Append(
"Content-Disposition",
$"attachment; filename=\"{fileName}\""
);
Response.ContentType = resp.Value.Details.ContentType ?? "application/octet-stream";
// BLOB -> HTTP レスポンスボディへ そのままコピー
await resp.Value.Content.CopyToAsync(Response.Body, ct);
return new EmptyResult();
}
このパターンでは、ASP.NET Core の FileResult 系クラスすら使わず、「BLOB ストリーム → Response.Body」だけという非常に薄い経路でデータが流れます。
- 処理の見通しが良く、チューニングポイント(バッファサイズ、タイムアウトなど)も把握しやすい。
CancellationTokenでクライアント切断時に処理を中断でき、無駄な BLOB 読み出しを減らせる。
レジューム対応:Range ヘッダー(断点再開)を有効にする
ネットワーク環境によっては、数十 GB のダウンロード中に通信が途切れることがあります。そこでサポートしておきたいのが Range ヘッダー(部分ダウンロード)です。
ASP.NET Core の File() メソッドには enableRangeProcessing という引数があり、これを true にするだけで Range リクエストに対応できます。
[HttpGet("/download/{*blobPath}")]
public async Task<IActionResult> Download(string blobPath, CancellationToken ct)
{
var blobClient = _container.GetBlobClient(blobPath);
// BlobClient から読み取り専用ストリームを取得
await using var stream = await blobClient.OpenReadAsync(
allowModifications: false,
cancellationToken: ct
);
var fileName = Path.GetFileName(blobPath);
// 第4引数 enableRangeProcessing: true で Range 対応有効化
return File(
fileStream: stream,
contentType: "application/octet-stream",
fileDownloadName: fileName,
enableRangeProcessing: true
);
}
これにより、クライアント側(ブラウザやダウンロードマネージャ)が途中までダウンロードした状態から再開することができ、長時間におよぶ大容量ダウンロードの成功率が大幅に向上します。
プライベート環境で SAS が使えない理由と設計のポイント
SAS とパブリックアクセスの関係
よく誤解される点として、「BLOB のパブリックアクセスを禁止すると SAS が使えなくなる」という認識があります。しかし、正確には次のように整理できます。
- SAS(Shared Access Signature)は認証・認可トークンであり、BLOB がパブリックかどうかとは直接関係ありません。
- ただし、ストレージアカウントのネットワーク規則で「選択されたネットワークのみ」「プライベートエンドポイントのみ」などにしている場合、 クライアントのネットワークからストレージに到達できないと、SAS が正しくても接続できません。
つまり、「SAS の可否」ではなく、ネットワーク到達性の問題で失敗しているケースが多い、ということです。
代表的な構成パターン
| 構成パターン | 概要 | メリット | 注意点 |
|---|---|---|---|
| 1. Web アプリ経由でプロキシ配信 (推奨) | Web アプリから BLOB にアクセスし、ストリーミングでクライアントへ転送 | クライアントからストレージを直接見せないためセキュア アプリ側で認可ロジック(ユーザーごとの権限)を実装しやすい ネットワークは Web アプリ ↔ BLOB 間だけ考えればよい | トラフィックは Web アプリを経由するため、スループット要件に応じてスケール設計が必要 |
| 2. クライアントから BLOB へ SAS で直接アクセス | Web アプリは SAS URL を発行するだけ。ダウンロードはクライアント → BLOB で完結 | Web アプリのトラフィック負荷が軽い 大容量でも Web アプリのメモリを気にしなくてよい | クライアントの発信元 IP がストレージアカウントの許可範囲内である必要がある IP 制御が難しい場合、App Gateway / WAF + Private Link などの構成が必要 |
パターン 1:Web アプリ経由でプロキシ配信(推奨構成)
プライベート BLOB を扱う場合、もっとも現実的で設計しやすいのがこのパターンです。
- Web アプリを VNet 統合し、ストレージアカウントにはプライベートエンドポイント(Private Link)で接続。
- インターネットから BLOB への直接アクセスは不可。
- ユーザーは Web アプリのダウンロード URL にアクセスし、Web アプリは BLOB をストリーミングして返す。
この構成なら、BLOB 側は完全にプライベートでも問題ありません。「BLOB のパブリックアクセス禁止」+「SAS URL をクライアントに配布しない」という、セキュアな構成を取りつつ、大容量ダウンロードも実現できます。
パターン 2:クライアントから直接 BLOB にアクセスさせる場合
既存システムの都合で、「どうしてもクライアントから BLOB に直接アクセスさせたい」というケースもあります。その場合は、次のような設計になります。
- Web アプリは短寿命の SAS を発行してクライアントに渡す。
- クライアントは SAS URL を使って BLOB に直接アクセスする。
- ストレージアカウント側では
- クライアントのグローバル IP を許可する、または
- Application Gateway / WAF をインターネット向けに公開し、Private Link 経由で BLOB に到達させる
この構成は、インターネットに公開するコンポーネントが増える分、ネットワークやセキュリティの設計・運用コストが上がります。新規設計なら、まずはパターン 1(Web アプリ経由)を検討するのがおすすめです。
フロントエンドからのダウンロード開始方法
バックエンドでストリーミングを実装できれば、フロントエンド側はとてもシンプルです。基本的には <a href="..."> でダウンロード用のエンドポイントを叩くだけで、ブラウザが自動的に保存ダイアログを表示してくれます。
自動クリックでダイアログを出すシンプルな例
<a href="/download/your/blob/path" target="_blank" id="download" style="display:none">download</a>
<script>
document.getElementById('download').click();
</script>
ポイントは次の通りです。
- ユーザー操作(ボタンクリックなど)をトリガーに上記処理を呼べば、ポップアップブロックに引っかかりにくい。
- サーバー側が
Content-Disposition: attachmentを付与していれば、ブラウザはファイル保存として扱ってくれます。 - SPA の場合でも、最終的には「ダウンロード用の GET エンドポイントに遷移させる」だけで OK です。
実装時のベストプラクティスチェックリスト
大容量ダウンロードを実運用する際に、最低限押さえておきたいチェックポイントをまとめます。
| 項目 | 推奨アクション |
|---|---|
| メモリ使用 | MemoryStream / ToArray() で全量メモリ展開しない。 常に BLOB → HTTP レスポンスのストリーミングを前提にする。 |
| ストリーミング | FileStreamResult、File(Stream ...)、DownloadStreamingAsync などを活用。 CopyToAsync(Response.Body) を使って逐次送信。 |
| MIME / ファイル名 | BLOB の ContentType を尊重し、なければ application/octet-stream。 Content-Disposition: attachment; filename="..." を必ず設定。 |
| キャンセル / タイムアウト | HttpContext.RequestAborted や CancellationToken を必ず渡す。 Azure 側やアプリ側のタイムアウト値も確認(長時間ダウンロードを想定)。 |
| Range 対応 | 断点再開が要件なら enableRangeProcessing: true を設定。 CDN やプロキシを挟む場合は Range リクエストの扱いも確認。 |
| 並列ダウンロード | サーバー側でチャンクを並列化する必要は基本的にない。 「BLOB 1本 → クライアント 1本」のシンプルなストリーミングで十分。 |
| ネットワーク設計 | ストレージアカウントはプライベートエンドポイントや許可 IP で保護。 Web アプリとストレージの通信経路(VNet、Private Link)を明確にする。 |
サンプル:Blob Storage をストリーミングする Controller 全体像
ここまでの内容を踏まえ、DI された BlobServiceClient を使った Controller のサンプルを掲載します。実際のプロジェクトでは、リポジトリクラスなどに切り出して構成しても問題ありません。
using Azure.Storage.Blobs;
using Microsoft.AspNetCore.Mvc;
public class BlobDownloadController : Controller
{
private readonly BlobContainerClient _container;
public BlobDownloadController(BlobServiceClient blobServiceClient)
{
// 例: "files" コンテナーを利用
_container = blobServiceClient.GetBlobContainerClient("files");
}
[HttpGet("/download/{*blobPath}")]
public async Task<IActionResult> Download(string blobPath, CancellationToken ct)
{
if (string.IsNullOrEmpty(blobPath))
{
return BadRequest("blobPath is required.");
}
var blobClient = _container.GetBlobClient(blobPath);
if (!await blobClient.ExistsAsync(ct))
{
return NotFound();
}
// 認可チェック(ユーザーごとに参照可能か等)をここに入れる
// if (!UserCanDownload(blobPath, User)) { return Forbid(); }
// Range 対応したい場合
await using var stream = await blobClient.OpenReadAsync(
allowModifications: false,
cancellationToken: ct
);
var properties = await blobClient.GetPropertiesAsync(cancellationToken: ct);
var contentType = properties.Value.ContentType ?? "application/octet-stream";
var fileName = Path.GetFileName(blobPath);
return File(
fileStream: stream,
contentType: contentType,
fileDownloadName: fileName,
enableRangeProcessing: true
);
}
}
このサンプルをベースに、
- 認可ロジック(ユーザーごとにダウンロード可能か)
- 監査ログ(誰がいつ何をダウンロードしたか)
- ダウンロード回数制限や期限
などを組み合わせていくことで、セキュアかつ運用しやすい大容量配信基盤が構築できます。
運用時の注意点とチューニングのヒント
App Service のスケールとスループット
- 大容量ファイルを同時に大量配信すると、App Service のアウトバウンド帯域がボトルネックになります。
- 同時ダウンロード数とファイルサイズをもとに、必要なスループットを概算し、プラン(S1 / P1v3 など)を選定しましょう。
- 負荷が高い時間帯が決まっているなら、その時間帯だけスケールアウトする運用も検討価値があります。
レスポンスバッファリングとログ
- リバースプロキシ(Application Gateway / Nginx / IIS など)のレスポンスバッファリング設定に注意し、中継側で全量バッファされないようにします。
- ダウンロード処理に関しては、開始・終了・キャンセル・エラーなどをログに残すことで、トラブルシューティングやキャパシティ計画が行いやすくなります。
タイムアウトとアイドルタイム
- サーバー側・クライアント側ともに、タイムアウト設定が短すぎると大容量ダウンロードが途中で切れてしまいます。
- ロードバランサ、アプリ、ストレージのそれぞれで、「長時間転送中でも切断されないか」を確認しましょう。
まとめ:大容量(18GB 以上)でも現実的に運用できる構成
本記事で扱ったポイントを改めて整理します。
- 障害の主因は
- サーバー側での全量メモリ確保(MemoryStream + ToArray)と、
- ストレージをプライベート化したことによるネットワーク到達性の不足
- 解決策は、
- ストリーミング配信(FileStreamResult や CopyToAsync)に切り替えることと、
- Web アプリ → BLOB 間のネットワークを適切に設計すること。
- この構成を取れば、10GB〜20GB クラスのファイルも、メモリプレッシャーを抑えつつ現実的に運用できます。
「BLOB は必ずストリーミングで扱う」「ネットワーク到達性と認可を Web アプリ側でコントロールする」という 2 つの原則を押さえておけば、Azure のプライベート BLOB からの大容量ダウンロードは決して難しくありません。既存実装が MemoryStream ベースになっている場合は、この機会にストリーミング方式への移行を検討してみてください。

コメント