C#のWebアプリをIISなしでWindowsサービスとして動かす|ASP.NET Core

C#・.NET

社内のPCで動くWebシステムを、IISを使わずに動かしたい

C#(ASP.NET Core)で作ったWebアプリを社内で使うとき、まず思いつくのは、Windowsに入っているWebサーバ「IIS」に載せる方法です。ところが、サーバ用ではないWindowsでは、次のことが起きます。

Windows 11 ProのPCをサーバにしたいが、IISでは同時に処理できる要求が10件までに限られている

Windows 10・11 ProのIISは、同時に処理できる要求の数が10件までに制限されています。社員が何人も同時に画面を開くと、待たされる人が出てきます。

ASP.NET Coreには、KestrelというWebサーバが組み込まれています。アプリをそのままWindowsサービスとして動かせば、IISを使わずに済みます。PCを起動すると自動で動き、誰もログオンしていなくても止まりません。

やることは、次の3つです。

  1. Program.cs に UseWindowsService() を1行足す
  2. 基準のフォルダ(ContentRoot)を、exeのフォルダにする
  3. 発行したフォルダをサーバのPCにコピーし、sc.exe でサービスに登録する

本記事では、C#のWebアプリをIISなしでWindowsサービスとして動かす方法を紹介します。自作の立替精算システムで使っている方法です。

立替精算システムで使ったところ

私が自作した立替精算システムは、社内の1台のWindows PCをサーバにして、Windowsサービスとして動かしています。社員はそれぞれのPCやスマートフォンのブラウザからつなぎます。

立替精算システムのメニューの画面。申請・承認・経理などの画面へのボタンが並んでいる

社員のPCのブラウザで開いた、立替精算システムのメニューの画面です。サーバのPCには、IISも.NETのインストールも要りません。お試し版は、同じexeをダブルクリックするだけで動かせるようにしています。

IISとWindowsサービスの違い

項目IISに載せるWindowsサービスとして動かす
同時に処理できる要求(Windows 10・11 Pro)10件までIISのような上限はない(PCの性能しだい)
サーバのPCの準備IISの機能を有効にし、ASP.NET Core Hosting Bundleを入れるexeを置いて、サービスに登録するだけ
.NETのインストール必要(Hosting Bundle)不要(.NETを同梱して発行した場合)
自動で起動IISが起動するサービスの「自動」で起動する
HTTPS・複数のサイトIISの画面で設定できるアプリ側で設定する

社内の1台のPCで1つのシステムを動かすなら、Windowsサービスの方が準備が少なく、同時に処理できる要求の上限もありません。ただし、Windows 11 Proにつなげる端末の数のライセンスの決まりは、IISを使わなくても変わりません(注意点で説明します)。

C#のWebアプリをWindowsサービスとして動かすサンプルコード

Visual Studioで「ASP.NET Core (空)」のプロジェクトを作り(ここではプロジェクト名を TatekaeSample とします)、NuGetで Microsoft.Extensions.Hosting.WindowsServices を追加して、Program.cs を次の内容にしてください。NuGetの版は、プロジェクトの.NETと揃えてください(.NET 10 なら 10.0.x、.NET 8 なら 8.0.x)。

1・2行目の #: で始まる行は消してください。残したままだと、ビルドでエラー(CS9298)になります。この2行は、.NET 10 でファイルに保存して dotnet run ファイル名.cs で実行するときのための指定です。動作確認は Windows 11・.NET 10 です。

#:sdk Microsoft.NET.Sdk.Web
#:package Microsoft.Extensions.Hosting.WindowsServices@10.0.12
using Microsoft.Extensions.Hosting.WindowsServices;

var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
    Args = args,

    // サービスとして動くと、作業フォルダが C:\Windows\System32 になる。
    // 設定ファイルや画面の部品が見つかるよう、exe のフォルダを基準にする
    ContentRootPath = WindowsServiceHelpers.IsWindowsService() ? AppContext.BaseDirectory : null,
});

// Windows サービスとして動けるようにする(ふつうに起動したときは、これまでどおりコンソールで動く)。
// サービス名は、sc.exe create で登録する名前と揃える
builder.Host.UseWindowsService(options => options.ServiceName = "TatekaeSample");

// 社内のほかの PC からもつなげるよう、すべての宛先で待ち受ける。
// 「*」は IPv4 と IPv6 の両方で待ち受ける(0.0.0.0 は IPv4 だけなので、localhost で開くと待たされることがある)
builder.WebHost.UseUrls("http://*:5000");

var app = builder.Build();

app.MapGet("/", () => Results.Text($"""
    動いています
    Windows サービスとして動いている:{WindowsServiceHelpers.IsWindowsService()}
    基準のフォルダ:{app.Environment.ContentRootPath}
    """));

app.Run();

実行結果(ふつうに起動した場合)

起動すると、コンソールに次のように出ます(抜粋)。

info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:5000
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.

ブラウザで http://localhost:5000/ を開くと、次のように表示されます。

動いています
Windows サービスとして動いている:False
基準のフォルダ:C:\Users\(ユーザー名)\samples

基準のフォルダには、起動したときのフォルダが出ます(Visual Studioから起動した場合は、プロジェクトのフォルダです)。

UseWindowsService は、サービスとして起動されたときだけ働きます。ふつうに起動すると、これまでどおりコンソールで動くので、開発中のデバッグもそのままできます。

Visual Studioで実行(F5)すると、Properties\launchSettings.json に書かれたURL(http://localhost:5123 のように、プロジェクトを作ったときに決まる番号)でブラウザが開きます。コードの UseUrls の方が優先されて5000番で待ち受けるので、そのURLではつながりません。launchSettings.json の applicationUrl を http://localhost:5000 に直すか、ブラウザで http://localhost:5000/ を開いてください。

コードの説明

  • builder.Host.UseWindowsService() で、Windowsサービスとして起動されたときに、サービスの開始・停止の合図を受け取れるようにしています。ServiceName は、後で sc.exe create で登録する名前と揃えています。.NET 8 以降は builder.Services.AddWindowsService(options => options.ServiceName = "TatekaeSample") とも書けます(同じ働きです)。
  • Windowsサービスとして動くと、作業フォルダが C:\Windows\System32 になります。そのままでは appsettings.json などが見つからないので、WindowsServiceHelpers.IsWindowsService() でサービスかどうかを調べ、サービスのときは ContentRootPath をexeのフォルダ(AppContext.BaseDirectory)にしています。
  • UseUrls("http://*:5000") で、社内のほかのPCからもつなげるように、すべての宛先で待ち受けています。* は IPv4 と IPv6 の両方で待ち受けます(ログには http://[::]:5000 と出ます)。
  • http://0.0.0.0:5000 と書くと、IPv4だけで待ち受けます。サーバのPCで localhost につなぐと、WindowsはまずIPv6の [::1] につなごうとして断られ、つなぎ直してからIPv4に切り替えます。試したところ、Pythonのプログラムから http://localhost:5000/ を取ると毎回2,032〜2,094ミリ秒かかりました(curlは早めに切り替えるので約0.2秒)。* にすると数ミリ秒です。
  • ポートの番号をお客様ごとに変えたい場合は、UseUrls の行を消して、appsettings.json に "Urls": "http://*:5000" と書きます。プログラムを作り直さずに、設定ファイルだけで変えられます(立替精算システムはこの形です)。

Windowsサービスとして登録する

発行は、開発用のPC(.NETのSDKが入っているPC)で行います。.NETを同梱して発行するので、サーバのPCに.NETを入れる必要はありません。

# 開発用の PC:発行する(.NET を入れていない PC でも動くよう、ランタイムを同梱する)
dotnet publish -c Release -r win-x64 --self-contained true -o C:\publish\TatekaeSample

できたファイル(300個あまり)を、フォルダごとサーバのPCの C:\apps\TatekaeSample にコピーします。exeの名前は、プロジェクト名と同じ TatekaeSample.exe になります。

次に、サーバのPCで、管理者として開いたPowerShellから次の順に実行します。PowerShellでは sc が別のコマンド(Set-Content)の意味になるので、sc.exe と書いてください。また、binPath= のように、= の後ろには必ず空白を1つ入れます。

# 1. サービスに登録する(PC の起動時に自動で開始)
sc.exe create TatekaeSample binPath= "C:\apps\TatekaeSample\TatekaeSample.exe" start= auto DisplayName= "立替精算サンプル"

# 2. 異常終了したときは、1 分後に自動で起動し直す
sc.exe failure TatekaeSample reset= 86400 actions= restart/60000/restart/60000/restart/60000

# 3. 開始する
sc.exe start TatekaeSample

# 4. ほかの PC からつなげるよう、ファイアウォールでポート 5000 を開ける(社内のネットワークだけ)
New-NetFirewallRule -DisplayName "TatekaeSample" -Direction Inbound -Protocol TCP -LocalPort 5000 -Action Allow -Profile Domain,Private

この手順をWindows 11のPCで試したところ、sc.exe query TatekaeSample は次のようになりました。

SERVICE_NAME: TatekaeSample
        TYPE               : 10  WIN32_OWN_PROCESS
        STATE              : 4  RUNNING
                                (STOPPABLE, NOT_PAUSABLE, ACCEPTS_SHUTDOWN)
        WIN32_EXIT_CODE    : 0  (0x0)
        SERVICE_EXIT_CODE  : 0  (0x0)

ブラウザで http://localhost:5000/ を開くと、サービスとして動いていることと、基準のフォルダがexeのフォルダになっていることが分かります。

動いています
Windows サービスとして動いている:True
基準のフォルダ:C:\apps\TatekaeSample\

サービスの一覧(services.msc)には「立替精算サンプル」の名前で、開始の種類「自動」、アカウント「LocalSystem」で出ます。

異常終了したときに起動し直すかも試しました。サービスのプロセスを強制終了すると、62秒後に新しいプロセスで起動し直し、画面も開けました。sc.exe stop でふつうに止めたときは、70秒待っても止まったままでした(起動し直すのは、異常終了したときだけです)。

ファイアウォールの規則は、ドメインとプライベートのネットワークだけで開けています。サーバのPCのネットワークが「パブリック」になっていると、ほかのPCからつながりません。Windowsの設定で「プライベート」にしてください。

外すときは、sc.exe stop TatekaeSample で止めてから sc.exe delete TatekaeSample で削除し、ファイアウォールの規則も Remove-NetFirewallRule -DisplayName "TatekaeSample" で消します。

立替精算システムでの工夫

  • イベントログ:Windowsでは、ログの出力先にイベントログが自動で加わります。ところが、権限を絞ったPCでexeをダブルクリックして起動したとき、イベントログに書けずに起動そのものが失敗したことがありました。サービスとして動いていないときは、イベントログへの出力を外しています。
  • ブラウザを自動で開く:お試し版はexeをダブルクリックして使うので、起動したらブラウザで画面を開きます。サービスとして動いているときは、画面を出せないので開きません。
  • 基準のフォルダ:サービスのときだけでなく、ショートカットなどから別の作業フォルダで起動されたときも、exeのフォルダに設定ファイルがあればそちらを使うようにしています。
  • 待ち受けの宛先:立替精算システムでは、待ち受けの宛先を設定ファイル(appsettings.json)で変えられます。0.0.0.0 と書かれていても遅くならないよう、そのときは同じポートで [::1] も足して待ち受けます。ただし、IPv6を切ったPCでは [::1] で待ち受けられず起動できなくなるので、足さないようにしています。
// サービスとして動いていないときは、イベントログへの出力を外す
if (!WindowsServiceHelpers.IsWindowsService())
{
    var eventLog = builder.Logging.Services
        .Where(x => x.ServiceType == typeof(ILoggerProvider)
                    && x.ImplementationType?.FullName
                       == "Microsoft.Extensions.Logging.EventLog.EventLogLoggerProvider")
        .ToList();

    foreach (var descriptor in eventLog)
    {
        builder.Logging.Services.Remove(descriptor);
    }
}

// (app.Run() の前)サービスでないときだけ、起動したらブラウザを開く
if (!WindowsServiceHelpers.IsWindowsService())
{
    app.Lifetime.ApplicationStarted.Register(() =>
        Process.Start(new ProcessStartInfo("http://localhost:5000/") { UseShellExecute = true }));
}

Process を使うので、Program.cs の先頭に using System.Diagnostics; を足してください。

注意点

  • サービスは、誰もログオンしていない状態で動きます。ウィンドウやメッセージボックスは出せないので、知らせたいことはログに書いてください。
  • サービスのときのログは、イベントビューアーの「Windows ログ」→「Application」に、発生元「TatekaeSample」で出ます。サービスの開始・停止は記録されますが、アプリのログは既定では警告以上だけで、LogInformation で書いたものは出ません。ふだんの動きも残したい場合は、ファイルに書くログの仕組みを足してください。
  • 動いているかは sc.exe query TatekaeSample で確かめられます(「STATE」が RUNNING なら動いています)。サービスが異常終了したことは、「Windows ログ」→「システム」に記録されます。
  • プログラムを更新するときは、sc.exe stop で止めてからファイルを置き換え、sc.exe start で開始します。動いたままでは、exeやDLLを上書きできません(試したところ「別のプロセスで使用されているため…アクセスできません」になりました)。
  • サーバにするPCは、スリープしない設定にしてください。スリープ中はつながりません。Windows Updateで再起動したときは、サービスの「自動」で起動し直します(ログオンは要りません)。更新の再起動が業務時間に重ならないよう、「アクティブ時間」も設定しておくと安心です。
  • サービスは既定で「LocalSystem」というアカウントで動きます。強い権限を持つアカウントなので、気になる場合は、サービスの一覧(services.msc)の「ログオン」タブで専用のアカウントに切り替えます。切り替えた後は、フォルダやイベントログに書けずに止まることがあるので、サービスを開始できるか、画面が開くかを確かめてください。データベースやバックアップを置くフォルダを別の場所にする場合も、そのフォルダにサービスのアカウントが書き込めるか確かめてください。
  • 社内でもパスワードを送るので、できればHTTPSにしてください。Kestrelに証明書を設定すれば、IISなしでもHTTPSで待ち受けられます。
  • Windows 11 Proをサーバにする場合、つなげる端末の数にはWindowsのライセンス上の決まりがあります。IISを使わなくても、この決まりは変わりません。立替精算システムでは、同時に使う端末が20台未満ならWindows 11 Pro、20台以上ならWindows ServerとCALで導入していただいています。

最後に

以上が、C#のWebアプリをIISなしでWindowsサービスとして動かす方法です。

コードに足すのは UseWindowsService() と基準のフォルダの指定だけで、あとは発行したフォルダをコピーして sc.exe で登録するだけです。サーバのPCに.NETもIISも入れずに済むので、お客様のPCに導入するときの手順も短くなります。

この方法で動かしている立替精算システムは、こちらで紹介しています。社内のPCで動かす経費精算の仕組みをお探しの方は、導入のご相談もお受けしています。

C#でIPアドレスからPC名を調べる方法は、こちらで紹介しています。

コメント

タイトルとURLをコピーしました