Azure App ServiceでBlob Storageのファイル名に+や空白があるとダウンロードできない原因と解決策(ASP.NET Core)

Azure App ServiceにデプロイしたASP.NET Core Web APIで、Azure Blob Storageのファイルをダウンロードするとき、ファイル名に「+」や空白が入ると404になってアクションに入らない――そんな現象の原因と、手戻りが少ない解決策(パス→クエリへ設計変更)を実装例つきで整理します。

目次

現象:Blobは存在するのに、Azure App Serviceだけダウンロードできない

ローカル(開発PC上のKestrel)では問題なくダウンロードできるのに、Azure App Serviceへデプロイすると特定のファイル名だけ失敗する、というケースがあります。典型的には次のような特徴を持ちます。

  • ASP.NET Core Web API から Azure Blob Storage 上のファイルを返したい
  • Blob自体は実在し、ローカル実行では正常に取得できる
  • しかし App Service 上では 「The resource you are looking for has been removed, had its name changed, or is temporarily unavailable.」 のような404系のエラーページになり、APIのアクションに到達していないように見える
  • 失敗するファイル名の例:...GCSU+Tree+Campus+Plan+2022 .docx(+ や空白を含む)
  • + を含まないファイル名はローカル/App Serviceの両方で正常

ポイントは「Blobの取得処理が失敗している」のではなく、Web API側に到達する前に弾かれている可能性があることです。実際、App Service(特にWindows)ではIIS(フロントエンド)やルーティング層が間に入るため、URLの解釈差が表面化します。

原因の核心:ファイル名をURLパスに載せると「予約文字・特殊文字」の影響を直撃する

結論から言うと、ファイル名をURLのパス(ルートパラメータ)に載せる設計が、+ や空白などの扱いに引っかかることが多いです。

例として、次のようなエンドポイント設計を考えます。

GET /api/files/{filename}

この形は一見シンプルですが、{filename} が「URLのパスの一部」になるため、URLとして意味を持つ文字(予約文字)や、HTTPサーバーのフィルタリング対象になりやすい文字を含むと、アプリまで届く前に404や400になります。

「+」と空白が厄介な理由

+ と空白は、どちらも実務でハマりやすい代表例です。

  • 空白(スペース):URLのパスにそのまま入れるのは不可。通常は %20 にパーセントエンコードされます。サーバーや中間層が厳格だと、空白を含むリクエストを早期に拒否します。
  • 「+」(プラス):URLの仕様上、パスでは「単なる文字」として扱われることが多い一方、クエリ文字列や一部のデコード実装では「空白の代替」として扱われる(application/x-www-form-urlencodedの慣習)ため、層をまたぐと解釈差が出ます。

さらに「空白を含むファイル名をエンコードしたつもりが、空白が + に変換される」「%2B が二重エンコードで %252B になる」といった事故が重なると、Blob名との一致が崩れます。

パスとクエリで何が違うのか:トラブルの発生点を整理する

同じ「ファイル名をURLで渡す」でも、パスに入れるのか、クエリに入れるのかで、扱いは大きく変わります。まずは違いを表で押さえましょう。

項目パス(例:/files/{filename})クエリ(例:/files?filename=…)
URLとしての意味ルーティングやサーバーのフィルタリング対象になりやすい値として扱われやすく、フレームワークが安全にパースしやすい
空白の扱い基本は %20 が必要。未エンコードだと拒否されやすい%20 か + になりやすい(クライアント実装次第)。多くのライブラリが自動処理
「+」の扱い通常はそのまま「+」として扱われるが、中間層が正規化すると事故が起きることがあるフォームURLエンコードの文脈では空白として扱われることがあり、文字としての「+」は %2B が推奨
失敗時の挙動Webサーバー側で404になり、アプリに届かないことがある(「IISのエラーページ」が出る等)アプリ側のModel Bindingまで到達しやすく、原因切り分けがしやすい

今回のように「App Serviceでだけ失敗し、アクションに入らない」場合、パスに載せたファイル名がサーバーの受け付け条件に引っかかっていると考えるのが近道です。

予約文字・特殊文字の早見表(ファイル名をURLで扱う前に確認)

Blob名(ファイル名)は自由度が高い一方、URLには「意味を持つ文字」があります。パスに載せる設計を採るなら、少なくとも次の文字はそのまま入れない前提で考えると安全です。

文字URL上での意味パスに入れると…クエリに入れると…推奨(文字として渡す場合)
(空白)URLとしては不正になりやすい未エンコードだと拒否・404の原因多くのライブラリが自動エンコードするが、実装差が出やすい%20
+フォームURLエンコードでは空白扱いになり得る中間層の正規化で別文字に見えることがある+ が空白に見える実装がある%2B
#フラグメント開始(サーバーに送られないことがある)意図したパスが届かない同左%23
?クエリ開始パスが途中で切れる値としてはOK(ただしエンコード推奨)%3F
&クエリの区切りパスに入れると解釈が割れる別パラメータに見える%26
%パーセントエンコードの開始不正なエスケープ扱いで拒否されることがある同様%25

ここで重要なのは、「Blob名として許される文字」と「URLとして安全な文字」は一致しない、という点です。ファイル名をそのままURLに載せるほど、サーバーや中間層の挙動差に影響されやすくなります。

エンコードAPI選びで迷わないための実践ルール

エンコードは“どれを使うか”よりも、“どこで・一度だけ行うか”が重要ですが、それでもAPI選びで事故が増えるのは事実です。現場で再現しやすい落とし穴を、よく使うAPIを例に整理します。

用途おすすめ理由避けたい例ハマりどころ
JavaScriptでクエリを組み立てURL + URLSearchParams自動で適切にエンコードされ、手書きミスが減る文字列連結で ?filename= を作る+ や & を含むと一瞬で壊れる
.NETでクエリを組み立てUri.EscapeDataString や QueryHelpers.AddQueryString値としてのエンコードを明示できるHttpUtility.UrlEncode を無意識に使う空白が + になる挙動が混乱を生むことがある
パスに“どうしても”載せるパスセグメント単位でのエンコード(空白は%20)パス全体ではなくセグメントとして扱うのが安全全体を一括エンコード/デコードスラッシュや%が混ざると二重エンコードになりやすい

特に「サーバー側で受け取った後にUrlDecodeする」のは、今回のようなケースでは事故の原因になりがちです。フレームワークのModel Bindingがすでにデコードを済ませている前提で、余計な変換を挟まない方が結果的に安定します。

最短で堅い解決策:ファイル名をパスパラメータからクエリパラメータへ移す

この問題で最も手戻りが少なく、再発もしにくい解決策はシンプルです。

  • 変更前:/baseurl/files/{filename}
  • 変更後:/baseurl/files?filename={filename}

この設計変更だけで、ローカル/Azure App Serviceの両方で安定して動作することが多いです。理由は、ファイル名がルーティングの構成要素から単なる値になり、IISやフロントエンドの「URLとして危険かどうか」の判定に引っかかりにくくなるからです。

ASP.NET Core Web API 側の実装例(推奨)

ダウンロードAPIは「ファイル名を受け取り、Blobからストリームで返す」が基本形です。ポイントは、受け取った値に対して手動でUrlDecodeしないことと、存在チェックとContent-Type/ダウンロード名をきちんと設定することです。

using Azure.Storage.Blobs;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api")]
public class FilesController : ControllerBase
{
    private readonly BlobContainerClient _container;

    public FilesController(BlobServiceClient blobServiceClient)
    {
        _container = blobServiceClient.GetBlobContainerClient("your-container-name");
    }

    // GET /api/files?filename=... 
    [HttpGet("files")]
    public async Task<IActionResult> Download([FromQuery] string filename, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(filename))
        {
            return BadRequest("filename is required.");
        }

        // 注意:ここで HttpUtility.UrlDecode などをかけない(+ が空白になる等の事故が起きやすい)
        BlobClient blob = _container.GetBlobClient(filename);

        if (!await blob.ExistsAsync(ct))
        {
            return NotFound();
        }

        var response = await blob.DownloadStreamingAsync(cancellationToken: ct);

        // ContentType は Blob に設定されていないこともあるため保険をかける
        var contentType = response.Value.Details.ContentType ?? "application/octet-stream";

        // ブラウザの保存名としては末尾のファイル名だけを使うのが無難
        var downloadName = Path.GetFileName(filename);

        return File(response.Value.Content, contentType, downloadName);
    }
}

この方法だと、URLのパスで特殊文字に悩む場面が大幅に減ります。加えて、クライアント側で URLSearchParams(JavaScript)や Uri.EscapeDataString(C#)を使えば、明示的に「+は%2B」「空白は%20」といった管理をしなくても、正しい形で送られやすくなります。

フロント(JavaScript)からの呼び出し例

// fileName に「+」や空白が含まれていてもOKな呼び出し例
const fileName = "GCSU+Tree+Campus+Plan+2022 .docx";

const url = new URL("/api/files", "[https://example.azurewebsites.net](https://example.azurewebsites.net)");
url.searchParams.set("filename", fileName);

// ダウンロード開始(認証が必要ならfetchでblob化する等に置き換え)
window.location.href = url.toString();

C#(HttpClient)からの呼び出し例

var fileName = "GCSU+Tree+Campus+Plan+2022 .docx";
var url = $"https://example.azurewebsites.net/api/files?filename={Uri.EscapeDataString(fileName)}";

using var resp = await httpClient.GetAsync(url, ct);
resp.EnsureSuccessStatusCode();

var bytes = await resp.Content.ReadAsByteArrayAsync(ct);
await File.WriteAllBytesAsync("downloaded.docx", bytes, ct);

補足:なぜ「エンコードしたのに直らない」ことがあるのか

「URLエンコードすれば解決」と言われがちですが、実務ではエンコードの選び方や適用タイミングで簡単に噛み合わなくなります。特に次の2パターンが多いです。

空白が「+」に変換され、Blob名と一致しなくなる

たとえば HttpUtility.UrlEncode() は、空白を + に置き換える挙動を持ちます(フォームURLエンコードの文脈)。一方、Blob名としては空白がそのまま保持されているため、クライアント/サーバー間のデコードで + が空白になったりならなかったりすると、最終的な「一致判定」が崩れます。

二重エンコードで別物になる

たとえば「+」を文字として送るために %2B にしたのに、途中の処理でさらにエンコードされ %252B になってしまう、という事故です。%25 は「%」そのものを表すため、%252B は結果として「%2B」という文字列になり、復元しても「+」に戻りません。

やりたいこと正しい表現例よくある事故起きる問題
「+」を文字として送る%2B%252BBlob名と一致せず404
空白を送る%20(パスでもクエリでも安全)+(空白の代替として扱われる場合がある)「+」と空白が混在して意図不明になる

このように、エンコードは「やればOK」ではなく、どの層がいつデコードするのかまで含めて設計しないと再発します。だからこそ、今回のようなケースでは「ファイル名をURLパスに入れない」設計へ寄せるのが堅い対処になります。

どうしてもパスに入れたい場合に押さえるべきポイント

「RESTっぽく /files/{filename} にしたい」「リンクとして見栄えがいい」など、パスに入れたい事情もあります。その場合は、最低限次を守らないとトラブルが再発しやすくなります。

  • パスセグメントとしてエンコードする:空白は %20、文字としての + は %2B。
  • フォームURLエンコード系のAPIをそのまま使わない:空白を + にする実装は混乱の元になりやすい。
  • サーバー側で「手動デコード」をしない:フレームワークのModel Bindingに任せ、余計な変換を挟まない。
  • ログで「受け取った生の値」を確認する:どの段階で変換されたかを追えるようにする。

また、ファイル名に /(スラッシュ)などが含まれる可能性があるなら、ASP.NET Coreではキャッチオール({*filename})が必要になります。ただし、キャッチオールは取り込む範囲が広く、WAFやサーバー側の制約により弾かれやすくなるため、設計としてはクエリに逃がす方が安全です。

実務でさらに推奨:ファイル名を露出させず、安全なIDでダウンロードさせる

根本的に安全で運用しやすいのは、ファイル名(Blob名)をそのままURLに載せない方法です。たとえばDBに「ダウンロード用のID(GUID)」と「Blob名」を紐づけ、クライアントはIDだけを渡します。

GET /api/files/{id}

この形ならURLに特殊文字が入りません。さらに、次のメリットがあります。

  • URLに実ファイル名が出ないため、情報漏えいリスクが下がる(ファイル名に個人名などが含まれるケース)
  • 権限制御がやりやすい(ID→メタデータ→権限チェック→Blob取得)
  • 将来Blob名の命名規則を変えても、外部APIを壊しにくい
方式URL例メリットデメリットおすすめ度
パスにファイル名/files/{filename}見た目が直感的予約文字・特殊文字で壊れやすい。情報露出も多い△
クエリにファイル名/files?filename=...実装変更が少なく、トラブルが減りやすいURLが長くなりやすい○
IDで指定/files/{id}最も堅牢。権限管理・監査・将来変更に強いDB等で紐づけが必要◎

切り分け手順:本当に「APIに到達していない」のかを確認する

今回のような症状では、まず「アプリが404を返しているのか」「IISなど手前が404を返しているのか」を切り分けると最短で原因に辿り着きます。

確認のコツ

  • レスポンスのContent-Typeを確認する:IISの404だとHTMLのエラーページになりやすい。API側の404ならJSONや独自フォーマットになりがち。
  • レスポンスヘッダーのServerを確認する:Microsoft-IIS/10.0 が強く見える場合、手前で落ちている可能性が高い。
  • App Serviceのログストリームでリクエストが見えているか:アプリ側のログ(コントローラの開始ログ等)が出ないなら、到達していない可能性が高い。

代表的な「手前で弾かれる」パターン

状況よくある見え方主な原因対処の方向性
アクションに入らないIIS風の404ページ、アプリログが出ないURLの正規化/リクエストフィルタ/ルーティング不一致ファイル名をパスに載せない(クエリ化/ID化)
アクションには入るがBlobが見つからないAPIの404(JSON)、アプリログは出るエンコード/デコード不一致、二重エンコードエンコード方式を統一、手動デコードを排除
一部の文字だけ壊れる「+」が空白になっている、末尾スペースが消える等フォームURLエンコードの影響、トリム、正規化URLSearchParams等に任せる/ID化

よくある質問と現場での落とし穴

「URLエンコードすればパスでも大丈夫」では?

理屈の上では可能です。ただし、実務では「クライアント」「CDN/プロキシ」「Webサーバー」「フレームワーク」がそれぞれ別のタイミングで解釈・正規化を行うため、どこか1か所でも想定外の変換が入ると壊れます。特に運用環境(App Service)でだけ再現するのはこのパターンが多いです。

「+」を含むファイル名をユーザーが勝手に付けるのを防ぐべき?

アップロード時に命名規則を強制するのも有効です。たとえば「英数字とハイフン、アンダースコアのみ」などに制限すれば、URLでの事故は激減します。ただし既存データの互換や、ユーザー体験(元のファイル名を尊重したい)とのトレードオフになります。

「表示名」と「保存名」を分けるのが現実的

Blob名はシステム都合(安全な文字セット、GUIDなど)で管理し、画面表示やダウンロード時のファイル名はメタデータとして別に持つ、という設計が現場では扱いやすいです。URL安全性とUXの両立ができます。

まとめ:再発しにくい設計に寄せる

  • Azure App Serviceで「+」や空白を含むBlobだけダウンロードできない場合、アプリに到達する前にルーティングやサーバーで弾かれていることがある
  • 最短で効く解決策は、ファイル名をURLパスに載せず、クエリパラメータで渡すこと
  • エンコードで直そうとすると、空白が + になる・二重エンコードになる等で再発しやすい
  • 長期運用を見据えるなら、ID(GUID)でダウンロードさせ、ファイル名はメタデータとして扱うのが堅牢

この記事を書いた人

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

コメント

コメントする

目次