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 | %252B | Blob名と一致せず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)でダウンロードさせ、ファイル名はメタデータとして扱うのが堅牢

コメント