Azure プライベート BLOB から大容量ファイルを ASP.NET Core で安全にダウンロードする方法

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 が発生するのか

問題点を整理すると次の通りです。

  1. 全量をメモリに乗せている
    • MemoryStream 自体がファイル全体をメモリに持ちます。
    • ToArray() でさらに新しい byte[] を確保するため、実質 2倍近いメモリを消費します。
    • サーバーのメモリが 4GB〜8GB 程度だと、400MB〜1GB クラスのファイルでも簡単に OOM になります。
  2. 並列ダウンロードでさらにメモリを圧迫
    • TransferOptions で並列数を増やしている場合、内部バッファ分もメモリを消費します。
    • 「速くしたいから並列数を増やす」ほど、メモリプレッシャーが高まるというジレンマがあります。
  3. 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)と、
    • ストレージをプライベート化したことによるネットワーク到達性の不足
    の 2 点に集約されます。
  • 解決策は、
    • ストリーミング配信(FileStreamResult や CopyToAsync)に切り替えることと、
    • Web アプリ → BLOB 間のネットワークを適切に設計すること。
  • この構成を取れば、10GB〜20GB クラスのファイルも、メモリプレッシャーを抑えつつ現実的に運用できます。

「BLOB は必ずストリーミングで扱う」「ネットワーク到達性と認可を Web アプリ側でコントロールする」という 2 つの原則を押さえておけば、Azure のプライベート BLOB からの大容量ダウンロードは決して難しくありません。既存実装が MemoryStream ベースになっている場合は、この機会にストリーミング方式への移行を検討してみてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次