IIS上のASP.NET CoreでFileSystemWatcherが接続中しか動かない原因と対策(Idle Time-out/AlwaysRunning/Preload)

Windows Server の IIS で ASP.NET Core を公開しているのに、FileSystemWatcher の Created イベントが「アクセスがあるときだけ」発火する——これは珍しくありません。IIS がアイドル時にワーカープロセスを停止し、プロセス内で動く監視処理も一緒に止まるためです。原因の切り分け方と、IIS 側の設定・実装・運用での対策をまとめます。

目次

現象を整理:アクセスがないと FileSystemWatcher が動かない

典型的には、次のような症状として表面化します。

  • 監視フォルダーにファイルを投入しても、誰も Web サイトを開いていない時間帯は CreatedWatcher_Created)が発火しない
  • サイトを開く(=リクエストが来る)と、突然イベントが発火し始める/処理が再開したように見える
  • 複数ファイルを処理中でも、アクセスが途切れると途中で止まることがある
  • 夜間バッチのつもりで置いたファイルが、翌朝最初のアクセスまで放置される

「FileSystemWatcher が不安定なのでは?」と疑いがちですが、まず押さえるべきは 監視処理が“どこで動いているか” です。FileSystemWatcher は OS のサービスとして常駐する仕組みではなく、あなたのアプリ(プロセス)の中で動くコンポーネントです。つまり、プロセスが止まれば監視も止まります。

状況見え方実際に起きている可能性
アクセスがあるCreated が発火して処理が進むアプリが起動しており、ワーカープロセスが生存している
アクセスがないCreated が発火しない/止まるIIS のアイドル判定でワーカープロセスが停止・休止している
再アクセス突然動く/再開したように見える新しいリクエストでアプリが再起動し、監視が作り直される

原因の本質:IIS のアイドル停止でプロセスが止まり、監視も止まる

IIS は「Web サイトを常に起動し続ける仕組み」ではなく、負荷やリクエスト状況に応じてプロセスを管理する仕組みです。既定設定では、一定時間リクエストが来ないとアプリケーションプールは “アイドル” とみなされ、ワーカープロセス(w3wp.exe)を停止します。

IIS のアプリケーションプールが担っていること

アプリケーションプールは、Web アプリを実行する “入れ物” です。IIS はこの単位でプロセスを起動・停止・リサイクル(再起動)し、リソース(メモリ/ハンドル)を管理します。Web リクエストがない間にプロセスを止めるのは、サーバー資源を節約するための合理的な動作です。

FileSystemWatcher は「プロセス内のイベント購読」なので、プロセス停止=監視停止

FileSystemWatcher のイベントは、アプリのメモリ上で購読されます。監視の登録も、イベントを受け取って処理するスレッドも、すべてプロセスが生きていることが前提です。したがって、IIS がアイドルでプロセスを止めると、FileSystemWatcher も次のように振る舞います。

IIS の状態内部で起きていることFileSystemWatcher への影響
起動中(リクエストあり)w3wp.exe が稼働し、ASP.NET Core アプリがロードされるイベント購読が有効で、Created などが発火する
アイドル判定一定時間リクエストがないため、IIS が停止/休止の対象にする監視処理が止まる(発火しない/キューされない)
次のアクセスリクエスト到来でプロセスが再起動し、アプリが再ロードされる監視は“再作成”されるが、停止中のイベントは基本的に取り戻せない

ASP.NET Core のホスティング方式(in-process / out-of-process)でも結末は同じ

ASP.NET Core を IIS で動かす場合、ホスティング方式は大きく 2 つあります(環境により既定が異なります)。しかし、どちらも 根っこは IIS のプロセス管理に依存しているため、アイドル停止の影響を受けます。

方式プロセス構成のイメージアイドル停止が起きたとき
in-processw3wp.exe の中で ASP.NET Core が動くw3wp.exe 停止でアプリも停止。FileSystemWatcher も停止
out-of-processw3wp.exe(IIS)→ dotnet.exe(Kestrel)を起動して中継w3wp.exe 停止に伴い中継が止まり、dotnet プロセスも終了/再起動されやすい

解決策:IIS の設定で「アイドルで止めない」構成にする

今回の症状に対して最も効果が高いのは、IIS がアプリを止めない(止まりにくい)設定に寄せることです。監視処理の性質上、「リクエストがない=止めてよい」では困るため、アプリケーションプール側の設定を見直します。

アプリケーションプールで調整すべき項目(最重要)

IIS マネージャーで対象のアプリケーションプールを選び、[詳細設定]を開いて確認します。

設定項目推奨理由注意点
Idle Time-out(アイドル時間のタイムアウト)0(無効)または十分長くアイドル停止そのものを起こさないリソース節約より「常時監視」を優先する用途でのみ推奨
Start Mode(開始モード)AlwaysRunning(常に実行)アプリプールを常駐させ、初回アクセス待ちを減らすOS/IIS のバージョンや構成によって表示が異なる
Idle Time-out Action(アイドル時の動作)(可能なら)アイドル自体を無効化Terminate/Suspend のどちらでも監視は止まり得るSuspend は“再開”できても停止中のイベントは取り戻せない
Regular Time Interval(定期リサイクル)用途に応じて見直しリサイクル=一瞬でもプロセスが切れ、監視が途切れる無効化は慎重に。必要なら夜間などに時間指定する
Rapid-Fail Protection(高速フェール保護)ログと併せて監視例外で連続クラッシュすると自動停止する停止すると監視も完全停止。根本原因(例外)を潰す

特に Idle Time-out を 0 にするだけで、今回の「接続中しか動かない」現象は改善するケースが多いです。AlwaysRunning も合わせると、リサイクル後の立ち上がりや初回アクセス待ちのストレスが減ります。

Preload / Auto Start で「最初のアクセスまで起動しない」を避ける

Idle Time-out を無効化しても、サーバー再起動やアプリプール再起動直後は「最初のアクセスが来るまでアプリが起動しない」設定になっていると、監視が始まりません。そこで Preload(事前読み込み)を有効にし、必要に応じて Application Initialization(アプリケーション初期化)を組み合わせます。

  • サイト(またはアプリケーション)の Preload Enabled を True にする
  • アプリケーションプールの Start Mode を AlwaysRunning にする
  • サーバー機能として Application Initialization を追加し、起動直後に叩く URL(例:/health)を用意する

ポイントは、「誰もアクセスしていなくてもアプリが起動した状態を作る」ことです。監視処理のような常駐タスクは、ここが満たされないと設計通りに動きません。

設定をコマンドで反映する例(appcmd)

GUI 変更が難しい運用(IaC/手順書の標準化)では、コマンドで設定を揃えると再現性が上がります。環境により項目名が異なる場合があるため、適用後は必ず IIS マネージャーでも値を確認してください。

REM アプリケーションプール名を MyAppPool に置き換え
%windir%\system32\inetsrv\appcmd set apppool /apppool.name:"MyAppPool" /processModel.idleTimeout:00:00:00
%windir%\system32\inetsrv\appcmd set apppool /apppool.name:"MyAppPool" /startMode:AlwaysRunning

落とし穴:アイドル停止以外でも「監視が途切れる」ことはある

Idle Time-out を無効化しても、次の要因でプロセスは止まったり再起動したりします。FileSystemWatcher はプロセスに紐づくため、“止まる前提”の備えもセットで考えると堅くなります。

要因起きやすい兆候対策の方向性
アプリケーションプールの定期リサイクル決まった周期で一瞬止まる/ログに recycle時間指定にする、負荷の低い時間へ寄せる、再起動後に取りこぼし回収
例外によるクラッシュ → Rapid-Fail Protection突然止まり、その後復旧しないイベントログとアプリログで原因の例外を潰す。停止検知を入れる
web.config / 設定ファイル更新デプロイ直後に再起動が走るデプロイ手順と監視処理の再開・回収設計を整える
メモリ制限・ハンドル枯渇重くなる→落ちる/突然の再起動リソース監視、処理を非同期化、ファイル処理を別プロセスへ分離

実装の鉄則:FileSystemWatcher を “Web アプリ内” で使うなら、作り方で差が出る

IIS 設定だけで直ることも多い一方、実装が雑だと「発火はしたのに処理が落ちる」「取りこぼす」「重くて Web 応答が遅い」など別の問題が出ます。ここでは運用で破綻しにくい実装のコツを押さえます。

イベントハンドラで重い処理をしない(キューに積む)

Created のイベントは連続で大量に飛ぶことがあります。イベントハンドラ内でコピー・解析・DB 登録など重い処理をすると、次のイベント処理が詰まり、最悪取りこぼし(内部バッファあふれ)につながります。基本は 「検知」と「処理」を分離し、検知したパスをキューに積んでバックグラウンドで順に処理します。

IHostedService / BackgroundService で起動・停止を管理する

ASP.NET Core では、バックグラウンド処理は IHostedService(または BackgroundService)として実装すると、アプリのライフサイクルに沿って起動・停止が管理できます。たとえば次のように登録します。

var builder = WebApplication.CreateBuilder(args);

// 監視サービスを常駐させる
builder.Services.AddHostedService<FileWatchHostedService>();

var app = builder.Build();
app.MapGet("/", () => "OK");
app.Run();

サービス側は、イベントでパスを受け取り、キューで順次処理する形にします(例)。

using System.Threading.Channels;

public sealed class FileWatchHostedService : BackgroundService
{
    private readonly ILogger<FileWatchHostedService> _logger;
    private FileSystemWatcher? _watcher;

    // 受信と処理を分離するためのキュー
    private readonly Channel<string> _queue =
        Channel.CreateUnbounded<string>(new UnboundedChannelOptions { SingleReader = true });

    private readonly string _inDir = @"D:\Watch\In";
    private readonly string _outDir = @"D:\Watch\Out";

    public FileWatchHostedService(ILogger<FileWatchHostedService> logger)
    {
        _logger = logger;
    }

    public override Task StartAsync(CancellationToken cancellationToken)
    {
        Directory.CreateDirectory(_inDir);
        Directory.CreateDirectory(_outDir);

        _watcher = new FileSystemWatcher(_inDir)
        {
            Filter = "*.*",
            IncludeSubdirectories = false,
            NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime | NotifyFilters.Size,
            EnableRaisingEvents = true,
        };

        // Created だけでなく Renamed も拾うと“書き込み完了後にリネーム”のパターンに強い
        _watcher.Created += (_, e) => Enqueue(e.FullPath);
        _watcher.Renamed += (_, e) => Enqueue(e.FullPath);
        _watcher.Error += (_, e) => _logger.LogError(e.GetException(), "FileSystemWatcher error");

        // 起動時に「置きっぱなし」ファイルを回収(取りこぼし対策)
        foreach (var file in Directory.EnumerateFiles(_inDir))
        {
            Enqueue(file);
        }

        return base.StartAsync(cancellationToken);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var path in _queue.Reader.ReadAllAsync(stoppingToken))
        {
            try
            {
                // ファイルコピー中だと失敗するので、完成を待つ
                await WaitForFileReadyAsync(path, stoppingToken);

                var name = Path.GetFileName(path);
                var dest = Path.Combine(_outDir, name);

                // 既存がある場合の扱いは要件で決める(上書き/リネーム/エラー)
                File.Move(path, dest, overwrite: true);

                _logger.LogInformation("Moved: {Path} -> {Dest}", path, dest);
            }
            catch (OperationCanceledException)
            {
                // 停止処理
                break;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to process: {Path}", path);
            }
        }
    }

    public override Task StopAsync(CancellationToken cancellationToken)
    {
        _watcher?.Dispose();
        return base.StopAsync(cancellationToken);
    }

    private void Enqueue(string path)
    {
        // 同じファイルに対して複数イベントが来ることがあるため、必要なら重複排除を追加する
        _queue.Writer.TryWrite(path);
    }

    private static async Task WaitForFileReadyAsync(string path, CancellationToken token)
    {
        // “ファイルがロックされていない=書き込みが終わった” を目安にする簡易実装
        const int retry = 60;
        for (int i = 0; i < retry; i++)
        {
            token.ThrowIfCancellationRequested();

            try
            {
                using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None);
                if (stream.Length >= 0) return;
            }
            catch (IOException)
            {
                await Task.Delay(250, token);
            }
            catch (UnauthorizedAccessException)
            {
                // 生成直後の一瞬だけアクセスできないケースにも備える
                await Task.Delay(250, token);
            }
        }

        throw new IOException($"File is not ready: {path}");
    }
}

上記は最小構成の例ですが、運用で強くするなら次の追加が効きます。

  • 重複排除:同一ファイルに対して Created/Renamed/Changed が複数回飛ぶことがあるため、一定時間は同じパスを 1 回だけ処理する
  • エラー隔離:失敗したファイルを “error フォルダー” へ退避し、無限リトライで詰まらないようにする
  • 内部バッファ対策:大量投入があり得るなら InternalBufferSize を増やし、Error イベント(バッファあふれ)を監視する
  • 取りこぼし回収:起動時・定期的にフォルダーをスキャンして、監視停止中に置かれたファイルを拾う

「Created が来たのに開けない」問題は仕様に近い

ファイルは、作成イベントが発生した時点でまだコピー中・書き込み中のことが多く、すぐに移動や読み取りをすると失敗します。運用でよくあるパターンは次の通りです。

よくある状況起きる問題おすすめの対策
大きなファイルをネットワーク越しにコピーCreated は来るが、読み取り/移動が IOException完成待ち(ロック解除待ち)、または “tmp で置いて最後にリネーム” 方式に統一
アプリが停止/リサイクルしている間に投入イベント自体が発生しない(取りこぼし)起動時スキャン、一定間隔スキャンで回収
短時間に大量投入内部バッファあふれでイベント欠落処理をキュー化して軽くする、バッファ拡張、投入側の間引き

権限・パス・実行ユーザー:IIS ならではのチェックポイント

「アクセスがあるときだけ動く」という主因はアイドル停止であることが多いですが、IIS 特有の権限設定が絡むと “動いたり動かなかったり” に見えることがあります。念のため、次も確認しておくと切り分けが早くなります。

チェック項目確認方法目安
アプリケーションプールの実行ユーザーIIS マネージャー → アプリプール → 詳細設定 → Identity監視フォルダー/移動先に読み書き(作成/削除/移動)権限がある
監視対象が UNC パス(ネットワーク共有)監視パスが \\server\share になっていないか可能ならローカルディスク推奨。共有なら権限と接続切れを考慮
ウイルス対策ソフト/EDR の影響投入直後にスキャンが走ってロックされないか除外設定(投入/退避フォルダー)を検討
移動先の競合(同名ファイル)同名が出る運用か、上書き可否上書き/リネーム/エラー退避などポリシーを明確化

切り分け手順:本当に IIS のアイドル停止が原因かを確認する

再現テストは難しくありません。既定では Idle Time-out が 20 分前後の環境が多いので、次の流れで確認できます。

  • サイトにアクセスし、監視が動いている状態を作る
  • その後、一定時間アクセスしない(待機)
  • 監視フォルダーにファイルを投入しても処理されない
  • サイトへアクセスすると、監視処理が再開するように見える

「待機中に本当にプロセスが消えたか」を見るには、サーバー上でワーカープロセスを確認します。

REM 稼働中のワーカープロセス一覧(IIS)
%windir%\system32\inetsrv\appcmd list wp
# w3wp.exe の有無(簡易)
Get-Process w3wp -ErrorAction SilentlyContinue

w3wp.exe が消える/止まるタイミングと、FileSystemWatcher が止まるタイミングが一致するなら、原因の特定としてはかなり強い証拠になります。

運用として堅い選択肢:監視・バッチは IIS から分離する

ここまでの設定で改善できることは多いですが、ファイル監視とバッチ処理は本質的に “Web リクエスト” とは別物です。IIS は再起動・リサイクル・デプロイでプロセスが入れ替わることを前提にしているため、監視が重要なシステムほど、Web アプリに内包しない方が安全です。

構成メリットデメリット向いているケース
IIS(Web アプリ)に内包デプロイが 1 本化、実装が手軽IIS のライフサイクルに影響される。取りこぼし対策が必須軽量で、多少の遅延/回収が許容される
.NET Worker Service を Windows サービス化OS と同じ感覚で常駐でき、IIS 変更の影響を受けない運用対象が増える(サービス監視、ログ、デプロイ)夜間も確実に処理したい、止まると困る
キュー/ジョブ基盤へ移行(例:メッセージキュー)耐障害性・拡張性が高い。取りこぼしを設計で潰せる構成が大きくなり、学習/運用コストが上がる処理量が多い、複数サーバーで分散したい

Windows サービス化する場合は、ASP.NET Core の Worker Service テンプレートを使い、ホストに UseWindowsService() を追加してサービスとして実行するのが定番です。Web とは別プロセスにすることで、IIS の「アクセスがないと止める」挙動と完全に切り離せます。

Host.CreateDefaultBuilder(args)
    .UseWindowsService()
    .ConfigureServices(services =>
    {
        services.AddHostedService<FileWatchHostedService>();
    })
    .Build()
    .Run();

まとめ:設定で止めない、設計で取りこぼさない

「ユーザー接続中しか FileSystemWatcher が動かない」現象の多くは、FileSystemWatcher 自体よりも IIS がアイドル時にプロセスを止めることが原因です。まずはアプリケーションプールの Idle Time-out を見直し、AlwaysRunning と Preload を組み合わせて “常時実行” に寄せます。

  • IIS の Idle Time-out を無効化/延長し、Start Mode を AlwaysRunning にする
  • Preload / Application Initialization で「最初のアクセス待ち」をなくす
  • 実装は HostedService 化し、イベントはキューで捌いて、起動時・定期スキャンで取りこぼしを回収する
  • 重要度が高いなら、監視処理は Windows サービス等に分離して IIS の影響を受けない構成にする

この順番で対策すると、原因が明確になり、再発もしにくい運用に近づきます。

この記事を書いた人

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

コメント

コメントする

目次