Azure Functions(Flex Consumption / Linux / .NET 8 Isolated)で、ビルド成果物に含めた HTML テンプレートを File.ReadAllText() で読むと「ローカルでは動くのに Azure だけ失敗する」ことがあります。さらに /home/site/wwwroot 配下へファイルを書き込もうとして UnauthorizedAccessException になるケースも頻出です。この記事では、原因の整理から、実運用で壊れにくいパス解決・保存先設計までを具体例つきで解説します。
発生する現象:テンプレート HTML の相対パスが Azure 上で壊れる
たとえばプロジェクトに Templates/template1.html を置き、次のように読み込む実装はローカル Windows では動きがちです。
var html = File.ReadAllText("Templates/template1.html");
しかし Azure Functions(Linux / Flex Consumption)にデプロイすると、次のような症状が出ることがあります。
- ローカルでは成功するのに、Azure では
FileNotFoundException/DirectoryNotFoundException相当で落ちる - ログ上の解決パスが不自然で、区切り文字が混在する(例:
/tmp/functions\standby\wwwroot/Templates/template1.htmlのように/と\が混ざる) - 「実行時にどこがルート(wwwroot)なのか」が把握しづらく、相対パスが不安定に見える
この手の問題は、Flex Consumption のようにホスト側の都合(起動形態・スタンバイ領域・デプロイ方式)で作業ディレクトリが動きやすい環境ほど再現しやすいです。
原因:相対パスは「カレントディレクトリ基準」になり、Linux ではバックスラッシュが“区切り”にならない
ポイントは次の2つです。
相対パスはカレントディレクトリ(作業ディレクトリ)を起点に解決される
File.ReadAllText("Templates/template1.html") のような相対パスは、コード配置場所やプロジェクトルートではなく、Directory.GetCurrentDirectory()(カレントディレクトリ)を起点に解決されます。
ローカル開発では、起動ツール(func host / Visual Studio / dotnet run)が「たまたま」プロジェクト直下をカレントにしてくれることが多く、相対パスでも動いてしまいます。ところが Azure 上では、ホストプロセスの都合でカレントが変わったり、起動タイミングで別ディレクトリになったりし、相対パス解決が不安定になります。
Linux では「\(バックスラッシュ)」がパス区切りとして扱われない
Windows では \ が区切り文字ですが、Linux の基本は / です。そして重要なのは、Linux では \ が“区切り”として解釈されず、単なる文字として扱われ得ることです。
その結果、/tmp/functions\standby\wwwroot/... のように区切りが混在すると、Linux 側では「functions\standby\wwwroot という名前のディレクトリが /tmp 直下にある」と解釈され、当然存在せずに失敗します。つまり、混在パスは“見た目はそれっぽいのに存在しないパス”になってしまいます。
推奨解:AppContext.BaseDirectory をルートにして絶対パスを作る
結論から言うと、テンプレートのような「デプロイ成果物に含まれる静的ファイル」は、相対パスで読まずに “実行基準ディレクトリ + Path.Combine” で絶対パスを組み立てるのが安全です。
とくに .NET 8 Isolated の Azure Functions では、AppContext.BaseDirectory が「実行中アセンブリの配置ディレクトリ」を指しやすく、テンプレート読込の基準として扱いやすい選択肢になります。
| 取得方法 | 何を指しやすいか | 安定性 | 用途の目安 |
|---|---|---|---|
AppContext.BaseDirectory | 実行中アセンブリ配置先 | 高い | 静的ファイル(Templates など)読込の基準に向く |
Directory.GetCurrentDirectory() | プロセスの作業ディレクトリ | 低〜中 | ホスト都合で変わり得るため、テンプレート読込の基準には不向き |
| 環境変数から組み立て | 実行環境に依存 | 環境差が出やすい | どうしても必要な場合の補助(ただし依存度が高い) |
実装例:テンプレートパスを安全に組み立てて読み込む
テンプレート名を受け取り、Templates 配下の HTML を読む例です。重要なのは「手で / や \ をつなげない」「Path.Combine で区切りを OS に合わせる」「テンプレート名を検証してパストラバーサルを防ぐ」の3点です。
using System.Text;
public static class TemplateReader
{
public static async Task<string> ReadHtmlAsync(string templateFileName)
{
if (string.IsNullOrWhiteSpace(templateFileName))
throw new ArgumentException("templateFileName is required.", nameof(templateFileName));
// 例: "template1.html" のようにファイル名のみ許可("../" や "/" "\" を禁止)
if (templateFileName.Contains("..") ||
templateFileName.Contains(Path.DirectorySeparatorChar) ||
templateFileName.Contains(Path.AltDirectorySeparatorChar))
{
throw new ArgumentException("Invalid template file name.", nameof(templateFileName));
}
var baseDir = AppContext.BaseDirectory;
// OS 依存の区切り文字は Path.Combine が吸収
var templatePath = Path.GetFullPath(Path.Combine(baseDir, "Templates", templateFileName));
if (!File.Exists(templatePath))
{
throw new FileNotFoundException($"Template not found: {templatePath}", templatePath);
}
// 文字化け対策:テンプレートの実体が UTF-8 前提なら明示
return await File.ReadAllTextAsync(templatePath, Encoding.UTF8);
}
}
これで、Windows でも Linux でも区切り文字の違いを意識せずに読み込めます。加えて、テンプレート名を外部入力(HTTP パラメータ等)から受ける場合でも、パストラバーサル(../ で上位へ抜ける)を抑止できます。
テンプレート読込時に“場所が分からない”を潰すログ
現場で一番効くのは「ベースディレクトリとカレントディレクトリをログに出す」ことです。Azure 側のパスが想定外でも、原因が特定しやすくなります。
// ILogger を想定
_logger.LogInformation("BaseDirectory: {BaseDirectory}", AppContext.BaseDirectory);
_logger.LogInformation("CurrentDirectory: {CurrentDirectory}", Directory.GetCurrentDirectory());
これを初期化時やテンプレート読込直前に一度出しておくと、環境差に引きずられた調査時間が一気に減ります。
前提:テンプレートをビルド成果物・発行物に確実に含める
パスの組み立てが正しくても、そもそもテンプレート HTML がデプロイに含まれていなければ読み込めません。ローカルは存在するのに Azure では存在しない場合、csproj のコピー設定が原因のことが多いです。
たとえば Templates 配下を「ビルド出力」と「発行(publish)先」に含めるには、次のような設定を入れます。
<ItemGroup>
<None Update="Templates\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</None>
</ItemGroup>
| 設定 | 効く場面 | おすすめ値 | 補足 |
|---|---|---|---|
CopyToOutputDirectory | ローカル実行 / ビルド出力 | PreserveNewest | 頻繁に変えるテンプレートなら Always でも可 |
CopyToPublishDirectory | Azure へデプロイする発行物 | PreserveNewest | publish に入らないと Azure で見つからない |
「ローカルは動くのに Azure で FileNotFoundException」の多くは、ここで publish 先に入っていないのが原因です。
まだ見つからないときに確認したい落とし穴
パスとコピー設定を直しても残るケースがあります。Linux 特有の落とし穴を、短時間で潰すためのチェックポイントをまとめます。
| チェック項目 | よくあるミス | 対処 |
|---|---|---|
| ディレクトリ名・ファイル名の大文字小文字 | Windows では通るが Linux では別物扱い | Templates と templates を混ぜない(Git 上の表記も統一) |
Path.Combine の渡し方 | "Templates/template1.html" を1要素で渡して区切り混在 | 必ず "Templates", "template1.html" のように要素を分ける |
| 実行時に参照しているベース | カレントディレクトリ基準で相対パス解決 | AppContext.BaseDirectory 基準に統一し、ログで確認 |
| デプロイ方式の差 | 発行物に入っていない / 取り込み漏れ | CopyToPublishDirectory を設定し、デプロイ成果物を確認 |
代替案:環境変数で wwwroot を組み立てる方法は“最後の手段”
質問で挙がっているように、環境変数を使って wwwroot 相当を組み立てるアプローチもあります。ただし、OS・プラン・ホスティング形態(コンテナ / パッケージ実行など)で構造が変わり得るため、安定運用の観点では優先度は下がります。
どうしても必要なら、次のように「環境依存であることを明示」したうえで、フォールバック的に使うのが無難です。
- 最優先は
AppContext.BaseDirectory - それで見つからない場合に限り、環境変数・既知パスを補助的に参照
- 本番ログに実際の解決パスを必ず出す
別の解決策:テンプレートを埋め込みリソースにしてファイルシステム依存を消す
「テンプレートはデプロイ物に含まれていればいい」「実行時に差し替えない」のであれば、HTML をアセンブリに埋め込む(Embedded Resource)手もあります。ファイルシステムの差が消えるので、パス問題そのものを回避できます。
一方で、テンプレートを運用中に差し替えたい場合や、サイズが大きい場合はファイルのままが扱いやすいです。どちらが向くかは運用方針次第です。
発生する現象:/home/site/wwwroot 配下へのファイル作成が UnauthorizedAccessException になる
Azure Functions の配置ディレクトリ(例:/home/site/wwwroot)へファイルを書こうとすると、次のような例外に当たることがあります。
System.UnauthorizedAccessException: Access to the path '/home/site/wwwroot/...' is denied
結論として、wwwroot(デプロイされるコード領域)へ実行時に書き込む運用は基本的に避けるべきです。Flex Consumption のような環境では特に、コード領域が読み取り専用に近い扱いになりやすく、「動く前提で設計する」ほど後で詰みます。
なぜ書き込めないのか:コード領域は“デプロイ物”であり、実行時の保存先ではない
wwwroot 配下は、アプリのコードやリソースが置かれる領域です。ここに実行時データ(生成ファイル、ログ、変換結果など)を置こうとすると、次の問題に直面します。
- 権限上書けない(読み取り専用化、実行形態による制限)
- スケールアウト時にインスタンスごとに状態が分散して整合性が崩れる
- 再起動・差し替え・デプロイで消える(または想定外に残る)
つまり、wwwroot は「実行時にデータを貯める場所」ではなく、「動かすものを置く場所」です。
書き込みが必要な場合の定石:一時領域か外部ストレージに逃がす
Functions でファイルを書きたいときは、目的に応じて保存先を分けます。
| 保存先 | 用途 | 永続性 | 注意点 |
|---|---|---|---|
/tmp(一時領域) | 変換中の一時ファイル、短命なキャッシュ | なし(揮発性) | 再起動・スケールで消える前提。容量制限にも注意 |
| Azure Storage(Blob / Files) | 生成物の保管、配布、監査ログ、長期保存 | あり | 接続設定、権限、コスト、命名規則、ライフサイクル管理が必要 |
| DB(必要なら) | メタデータ管理、検索、状態管理 | あり | ファイル本体は Blob、管理は DB といった分離が定石 |
/tmp に書き込む実装例(短命でよい場合)
Path.GetTempPath() を使うと、環境ごとのテンポラリディレクトリを取得できます(Linux では /tmp 相当になることが多いです)。ファイル名は衝突しないように GUID を使うのが安全です。
using System.Text;
public static class TempFileWriter
{
public static async Task<string> WriteUtf8Async(string fileName, string content)
{
// fileName は用途に応じて固定名でも良いが、衝突と並列性に注意
var safeName = $"{Guid.NewGuid():N}_{fileName}";
var tempPath = Path.Combine(Path.GetTempPath(), safeName);
await File.WriteAllTextAsync(tempPath, content, Encoding.UTF8);
return tempPath;
}
}
ただし、Flex Consumption はスケールアウトしやすく、インスタンスも入れ替わりやすいので「/tmp に書いたファイルを次のリクエストで読む」ような設計は不安定になりがちです。/tmp はあくまで“その処理の間だけ存在すれば良い”用途に寄せるのが安全です。
永続化が必要なら Blob Storage へ(実運用の基本)
生成した HTML・PDF・画像・CSV などを後で参照する必要があるなら、外部ストレージへ出す設計が定石です。Azure Storage Blob は「ファイルを置く場所」として素直で、スケールアウト環境でも扱いやすいです。
以下はイメージ例です(接続情報はアプリ設定から取り、コンテナ名・パス設計・アクセス制御は運用に合わせてください)。
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using System.Text;
public static class BlobUploader
{
public static async Task UploadTextAsync(
string connectionString,
string containerName,
string blobName,
string content)
{
var service = new BlobServiceClient(connectionString);
var container = service.GetBlobContainerClient(containerName);
await container.CreateIfNotExistsAsync(PublicAccessType.None);
var blob = container.GetBlobClient(blobName);
using var ms = new MemoryStream(Encoding.UTF8.GetBytes(content));
await blob.UploadAsync(ms, overwrite: true);
}
}
「テンプレートを読み込んで HTML を生成し、その結果を永続保存して配布する」ような用途では、
- テンプレート:
AppContext.BaseDirectory基準で読み取り(デプロイ物) - 生成物:Blob など外部ストレージへ保存(実行時データ)
と役割分担すると、権限・スケール・保守性の面で破綻しにくくなります。
実装のコツ:テンプレート読込と保存先設計を“同じ思想”で揃える
今回の2つの問題(テンプレートパスが壊れる、wwwroot に書けない)は別々に見えて、根っこは同じです。
- 相対パスやカレントディレクトリに依存すると、ホスト都合で壊れる
- コード領域(wwwroot)と実行時データ領域を混ぜると、権限とスケールで破綻する
そこで、設計ルールを次のように固定すると一気に安定します。
| 対象 | 基準 | 方針 |
|---|---|---|
| テンプレート / 静的リソース | AppContext.BaseDirectory | 絶対パス化して読み取り専用で扱う |
| 一時ファイル | Path.GetTempPath() | 揮発前提。処理が終わったら不要になる作りにする |
| 永続ファイル | 外部ストレージ(Blob 等) | スケールアウトしても同じ場所に置ける前提で設計 |
トラブルシュート用の最小チェックリスト
最後に、障害対応時に「まずここを見る」項目をまとめます。
- テンプレート読込の直前で
AppContext.BaseDirectoryとDirectory.GetCurrentDirectory()をログ出力しているか - テンプレートは publish 成果物に入っているか(
CopyToPublishDirectoryを確認) - Linux の大文字小文字を踏んでいないか(
Templatesとtemplates) - パスは
Path.Combineで組んでいるか(手結合していないか) - 書き込み先が
/home/site/wwwrootになっていないか(実行時データを置いていないか) - 一時ファイルは
/tmp、永続化は Blob などに逃がしているか
まとめ
- Azure Functions(Linux / Flex Consumption / .NET 8 Isolated)では、相対パスはホストのカレントディレクトリに左右されて壊れやすい
- 区切り文字混在(
/と\)は Linux で致命傷になりやすいので、Path.Combineに寄せる - テンプレート読込は
AppContext.BaseDirectoryを基準に絶対パス化するのが堅い /home/site/wwwrootは“デプロイ物の領域”として扱い、実行時の書き込み先にしない- 一時ファイルは
/tmp、永続化は Blob など外部ストレージへ、という分離が最も事故が少ない

コメント