C#定石 - サービス制御 - SQL Server

## はじめに

SQL Server 2025 Private Preview が始まっているので、今更ですが、SQL Server 2022 で SQL Server サービスが遅延開始となりましたね。
今回は、SQL Server をサンプルとして、遅延開始、サービスの状態確認/起動待機、サービスの開始/停止について記載します。

::: note
SQL Server 2025 Private Preview については、下記アナウンス内にリンクがあります。
[Announcing Microsoft SQL Server 2025: Enterprise AI-ready database from ground to cloud](https://www.microsoft.com/en-us/sql-server/blog/2024/11/19/announcing-microsoft-sql-server-2025-apply-for-the-preview-for-the-enterprise-ai-ready-database/)
:::

### 素材

アイコン素材として下記サイトを利用させて頂きました。

 - [データベースのアイコン素材 | FLAT ICON DESIGN -フラットアイコンデザイン-](http://flat-icon-design.com/?p=410)
- [「セキュリティ（盾）」アイコンのフリー素材（商用可）](https://kotonohaworks.com/free-icons/security/)

### テスト環境

ここに記載した情報/ソースコードは、Visual Studio Community 2022 を利用した下記プロジェクトで生成したモジュールを Windows 11 24H2 で動作確認しています。
- Windows Forms - .NET Framework 4.8
- Windows Forms - .NET 8
- WPF - .NET Framework 4.8
- WPF - .NET 8

::: note
記載したソースコードは .NET 8 ベースとしています。
.NET Framework 4.8 の場合は、コメントで記載している null 許容参照型の明示 `?` を削除してください。
:::

::: note
Visual Studio 2022 - .NET Framework 4.8 は、C# 7.3 が既定です。
このため、サンプルコードは、C# 7.3 機能範囲で記述しています。
:::


## サービス制御

### ServiceController

https://learn.microsoft.com/ja-jp/dotnet/api/system.serviceprocess.servicecontroller

サービス制御は ServiceController を利用します。
事前準備として、下記を行ってください。

- .NET Framework 4.8
  - ソリューションエクスプローラ［参照］［参照の追加］で System.ServiceProcess 追加
  - もしくは、.NET 8 と同様に Nuget で System.ServiceProcess.ServiceController 追加
- .NET 8 の場合
  - [NuGet Gallery | System.ServiceProcess.ServiceController](https://www.nuget.org/packages/System.ServiceProcess.ServiceController/) を導入します
  - `PM> NuGet\Install-Package System.ServiceProcess.ServiceController`

ServiceController では、「サービス名」を利用します。
サービス コンソールでは「表示名」一覧が表示されます。
対象を選択して、右クリック「プロパティ」を選択すると「サービス名」を確認できます。

![service-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/8f5830c3-ce8d-4551-8ca1-a6b6dd0365e6.png)

![service-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/8057c421-0c3f-4bff-b86e-56c7a361207c.png)

::: note
スタートメニュー［Windowsツール］［サービス］は、[Windows のユーザーごとのサービス](https://learn.microsoft.com/ja-jp/windows/application-management/per-user-services-in-windows) で、「サービス コンソール」という呼称となっているので、本記事では、この呼称を利用します。
:::

### 遅延開始

サービス コントロール マネージャは、サービスを起動して、所定待機時間（30秒）以内に該当サービスが起動完了しないと、タイムアウトとして処理を中断します。
「スタートアップの種類 - 自動」のサービスが多数存在して、Windows 起動時処理と重なり高負荷状態の場合、このタイムアウト発生の可能性があがるので、直ちに起動しなくてもいいサービスは遅延開始とすることが推奨されています。

::: note
サービス起動待機時間は、下記レジストリで変更ができます。

```
キー：\HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control
変数：ServicesPipeTimeout 
データ型：DWORD
値：ミリ秒で設定（60秒 = 60000、既定値は 30000）
```
:::

SQL Server 2022 から SQL Server サービスが遅延開始となった理由のひとつに、安定性（Windows 起動時の高負荷環境だと、タイムアウトで起動されない可能性があることを排除）があげられています。

https://learn.microsoft.com/ja-jp/sql/sql-server/sql-server-2022-release-notes?view=sql-server-ver16&preserve-view=true#sql-server-services-are-set-to-automatic-delayed-start-start-mode

> SQL Server サービスが［自動（遅延開始）］開始モードに設定されている
> SQL Server 2022 (16.x) では、SQL Server サービスの［開始モード］を構成マネージャーで［自動］に設定すると、［スタート モード］が［自動］と表示されている場合でも、サービスが代わりに［自動（遅延開始）］モードで開始するように構成されます。

![SqlServerConfigurationManager.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/7c65278d-6d7c-4ec8-9b1c-d5884443e50c.png)

::: note
上記、構成マネージャではなく、サービス コンソールで、スタートアップの種類を「 自動（遅延開始）」から「自動」に変更すれば、遅延開始でなく（Windows 起動時に）開始させることは可能でした。（Windows 11 24H2 で確認しましたが、非推奨と思われます）
:::

遅延開始の場合、Windows 起動してから待機時間（120秒）経過後にサービス起動を行います。
このため、PC起動 - 自動サインインの環境で、スタートアップなどにソフトを登録した場合、該当ソフト起動時では SQL Server が未起動で利用不可という状態が起こり得ます。

::: note
遅延開始の待機時間については、Microsoft 公式の情報ではありませんが、レジストリ AutoStartDelay を用いて、個々のサービスに対する設定は無効だが、システム全体の設定は有効という情報があります。
[Individual Service AutoStartDelay time not working](https://learn.microsoft.com/en-us/answers/questions/1082771/individual-service-autostartdelay-time-not-working)

本情報は、Microsoft 公式情報ではないので、動作保証された仕様ではありません。
:::


## 状態確認/開始待機

サービスの状態確認、および、開始待機は、一般ユーザ権限で利用可能です。

### ServiceController 生成

SQL Server の場合、サービス名は「MSSQL$インスタンス名」となります。

```CSharp
using System.ServiceProcess;
```
```CSharp
string instanceName = "Hoge";
string serviceName = $"MSSQL${instanceName}";
var sc = new ServiceController(serviceName, ".");

// ServiceController - System.ComponentModel.Component
//   - MarshalByRefObject, IDisposable, System.ComponentModel.IComponent
// と IDisposable なので、リソース解放 sc.Dispose() が必要です。
// using を利用すると自動的にリソース解放されます。
```

### スタートアップの種類

サービスの開始モードは、ServiceController.StartType で確認できます。

```CSharp
ServiceStartMode startType = sc.StartType;

// ServiceStartMode.Automatic : 自動
// ServiceStartMode.Manual    : 手動
// ServiceStartMode.Disabled  : 無効
```

しかし、サービスコンソールで表示される「遅延開始」「トリガー開始」に関する情報は、ServiceController からは取得できず、WMI から情報取得、もしくは、レジストリ参照が必要で、双方とも ServiceController.StartType 相当の情報も存在しています。
今回は、サービスコンソールで、スタートアップの種類として表示される「自動（遅延開始、トリガー開始）」形式の文字列を、レジストリ参照で作成するサンプルを記載します。

```CSharp
string instanceName = "Hoge";
var startupKind = GetStartupKind($"MSSQL${instanceName}");
```

```CSharp
private string GetStartupKind(string serviceName)
{
  string startupKind = string.Empty;

  using (var regKey = 
    Registry.LocalMachine.OpenSubKey($@"SYSTEM\CurrentControlSet\services\{serviceName}"))
  {
    if (regKey != null)
    {
      Int32 start = (Int32)regKey.GetValue("Start", 0); // 2:自動, 3:手動, 4:無効
      Int32 delayed = (Int32)regKey.GetValue("DelayedAutostart", 0); // 1:遅延開始
      bool bTrigger = regKey.GetSubKeyNames().Any(x => x == "TriggerInfo"); // true:トリガー開始

      var sb = new StringBuilder();
      switch(start)
      {
      case 2: sb.Append("自動");
              if (delayed == 1)
              {
                if (bTrigger) sb.Append("（遅延開始、トリガー開始）");
                else          sb.Append("（遅延開始）");
              }
              else if (bTrigger) sb.Append("（トリガー開始）");
              break;
      case 3: sb.Append("手動");
              if (bTrigger) sb.Append("（トリガー開始）");
              break;
      case 4: sb.Append("無効");
              break;
      }
      startupKind = sb.ToString();
    }
  }
  return startupKind;
}
```

### サービスの状態

サービスの状態は、ServiceController.Status で確認できます。

```CSharp
var status = sc.Status;

// ServiceControllerStatus.ContinuePending :  再開中
// ServiceControllerStatus.Paused          :  一時停止
// ServiceControllerStatus.PausePending    :  一時停止中
// ServiceControllerStatus.Running         :  実行中
// ServiceControllerStatus.StartPending    :  開始中
// ServiceControllerStatus.StopPending     :  停止中
// ServiceControllerStatus.Stopped         :  停止
```

### サービスが指定した状態になるまで待機

ServiceController.WaitForStatus を利用すると、サービスが指定したステータスになる、もしくは、指定したタイムアウトに達するまで待機することができます。

本メソッドを利用することで、遅延開始されるサービスが実行中になるまで待機させることが可能です。
数分間の待機となるので、待機をキャンセル可能にすることが望ましいので、[C#定石 - ワーキングダイアログ（プログレスダイアログ）](https://qiita.com/chai0917/items/2a3e6ca8de0116f47601) を利用したサンプルコードを記載します。

```CSharp:WorkingDialogManager経由で実行
// SQL Server に接続できず、SQL Server サービスが実行中でない場合
// → 実行中になるまでワーキングダイアログで待機、キャンセル可
var result = wdManager.ShowDialog(this, WaitForRunning);
```

```CSharp
// .NET Framework 時 object? の ? 不要
private void WaitForRunning(object? sender, DoWorkEventArgs e)
{
  string instanceName = "Hoge";
  using (var sc = new ServiceController($"MSSQL${instanceName}", "."))
  {
    if (sc != null 
     && sc.StartType != ServiceStartMode.Disabled     // 無効でない
     && sc.Status != ServiceControllerStatus.Running) // 実行中でない
    {
      // 最大待機時間：3秒間隔 x 80回 = 240秒（4分)
      for (int span = 3, max = 240; max > 0; max -= span)
      {
        // キャンセル確認
        if (wdManager.IsCancelling)
        {
          e.Cancel = true;
          return;
        }
        // ワーキングダイアログ表示更新
        wdManager.SetStepMessage($"SQL Server 起動待機中...\r\n待機残り時間 {max}秒");
        // キャンセル受付可能とするため タイムアウトを 3秒で待機
        try
        {
          // 対象サービスが Running になる、もしくは、3秒でタイムアウトまで待機
          sc.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(span));
        }
        catch (System.ServiceProcess.TimeoutException)
        {
           // 指定時間内に Running にならない場合の例外
           // 3秒間隔の待機なので無視
           continue;
        }
        // 実行中になった - TODO
        break;
      }
    }
  }
}
```

![WaitForRunning.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/955aa0cb-bad2-4600-b26b-83eff21710a3.png)

::: note
上記は、ワーキングダイアログ サンプルを利用した表示です。
ワーキングダイアログ サンプルでは、タイトルバー表示文字列を変更するインターフェイスを用意していなかったので、「処理中」という表示のままです。
必要に応じて、タイトルバー表示文字列を変更するインターフェイスを追加して対処してください。
:::



## 開始/停止

サービスの開始、および、停止操作は、管理者権限が必要となります。
まずは、それぞれのサンプルコードを記載します。
末尾に、ユーザ権限の範囲でサービスの状態表示をして、開始/停止は管理者権限で実行するサンプルを記載します。

### 開始

```CSharp
private void StartService(string serviceName)
{
  int waitSec = 30;  // TODO
  using (var sc = new ServiceController(serviceName, "."))
  {
    if (sc?.Status == ServiceControllerStatus.Stopped   // 停止中
     && sc.StartType != ServiceStartMode.Disabled)      // 無効以外
    {
      // サービス開始
      sc.Start();
      // 対象サービスが Running になる、もしくは、タイムアウトまで待機
      try
      {
        sc.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(waitSec));
      }
      catch (System.ServiceProcess.TimeoutException)
      {
        // タイムアウト - TODO
      }
    }
  }
}
```

::: note
管理者権限を持たないユーザで、ServiceController.Start を実行すると、System.ComponentModel.Win32Exception が発生します。
:::

::: note
上記ソースでは、サービス開始 待機時間は、サービス コントロール マネージャでの既定値 30秒としています。
本記事「サービス - 遅延開始」で記載しているレジストリ ServicesPipeTimeout の値を利用など、適切な待機時間としてください。
:::

### 停止

```CSharp
private void StopService(string serviceName)
{
  int waitSec = 30;  // TODO
  using (var sc = new ServiceController(serviceName, "."))
  {
    if (sc?.Status == ServiceControllerStatus.Running)  // 実行中
    {
      if (sc.CanStop)
      {
        // サービス停止
        sc.Stop();
        // 対象サービスが Stopped になる、もしくは、タイムアウトまで待機
        try
        {
          sc.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(waitSec));
        }
        catch (System.ServiceProcess.TimeoutException)
        {
          // タイムアウト - TODO
        }
      }
    }
  }
}
```

::: note
管理者権限、待機時間 については、開始と同様です。
:::


### 管理者権限でサービス開始

一般ユーザ権限で、スタートアップの種類 / サービスの状態を表示して、サービス開始 / サービス停止は管理者権限で実施するサンプルとして、下記レイアウトを用意します。

![aplication.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/49d3695c-e0ec-44a8-8adb-c26ca8618111.png)

| コントール              | 内容              |
| ---------------------- | ------------------|
| TextBox txtStartupKind | スタートアップ種類  |
| TextBox txtStatus      | サービスの状態      |
| Button btnRefresh      | 状態更新           |
| Button btnStart        | サービス開始        |
| Button btnStop         | サービス停止        |
| Button btnExit         | 終了               |

「サービス開始」「サービス停止」ボタンは、管理者権限が必要なので、盾マークを付与しています。

::: note
Microsoft ツールなどで、ボタンで実行する処理が管理者権限を要求する場合、盾マーク付きボタンとなっているので、同様にしています。
:::

サービス開始（サービス停止）を管理者権限で実行する手法として、今回は、下記実装とします。

- 管理者権限で実行されている場合
  - そのままサービス開始（サービス停止）
- 管理者権限で実行されていない場合
  - 自分自身を管理者権限で起動して終了

自分自身を管理者権限で起動する際に、利用局面によって、下記項目の情報を引数（それぞれ引数とするか、XMLファイルに情報を格納して XMLファイルを引数とする）で伝達すべきかをご検討ください。

- 引数有りで起動されていた場合、同一起動引数を指定
- 画面表示位置/サイズ（元画面と同一表示）
- 管理者権限昇格する起因の処理（再起動で該当処理を自動実行）
- 対象アプリで表示/入力/選択していた情報（アプリ状態の復元）

管理者権限で実行されているかは、下記コードで確認できます。

```CSharp
using System.Security.Principal;
```

```CSharp
private static bool IsRunAsAdmin()
{
  var principal = new WindowsPrincipal(WindowsIdentity.GetCurrent());
  return principal.IsInRole(WindowsBuiltInRole.Administrator);
}  
```

全体処理のサンプルコードは、Windows Forms - .NET 8 のみの記載とさせて頂きます。
本記事内の GetStartupKind、StartService、StopService、IsRunAsAdmin を利用するので、該当ソースは割愛します。

::: note
GetStartupKind、StartService、StopService は、引数で serviceName を指定する形式です。
下記サンプルでは、メンバ変数 ServiceName でサービス名を保持する形態なので、引数ではなく、メンバ変数を参照するように修正してください。
:::

```CSharp
public partial class Form1 : Form
{
  private const string ServiceName = "MSSQL$Hoge";

  public Form1()
  {
    InitializeComponent();

    // 配置コントール
    btnExit.Click += btnExit_Click;
    btnRefresh.Click += btnRefresh_Click;
    btnStart.Click += btnStart_Click;
    btnStop.Click += btnStop_Click;

    // 初期値設定
    txtStartupKind.Text = GetStartupKind(ServiceName);　// TODO：引数 → メンバ変数
    RefreshStatus();
  }

  // .NET Framework 時 object? の ? 不要
  private void btnExit_Click(object? sender, EventArgs e)
  {
    this.Close();
  }

  // .NET Framework 時 object? の ? 不要
  private void btnRefresh_Click(object? sender, EventArgs e)
  {
    RefreshStatus();
  }

  // .NET Framework 時 object? の ? 不要
  private void btnStart_Click(object? sender, EventArgs e)
  {
    if (IsRunAsAdmin())
    {
      // サービス開始 - TODO：引数 → メンバ変数
      StartService(ServiceName);
      // 表示更新
      RefreshStatus();
    }
    else
    {
      // 自分自身を管理者権限で起動して終了
      RunAsMyself();
    }
  }

  // .NET Framework 時 object? の ? 不要
  private void btnStop_Click(object? sender, EventArgs e)
  {
    if (IsRunAsAdmin())
    {
      // サービス停止 - TODO：引数 → メンバ変数
      StopService(ServiceName);
      // 表示更新
      RefreshStatus();
    }
    else
    {
      // 自分自身を管理者権限で起動して終了
      RunAsMyself();
    }
  }

  // サービスの状態 - 更新
  private void RefreshStatus()
  {
    // 初期化
    btnStart.Enabled = false;
    btnStop.Enabled = false;
    string sts = string.Empty;

    using (var sc = new ServiceController(ServiceName, "."))
    {
      if (sc != null)
      {
        if (sc.Status == ServiceControllerStatus.ContinuePending)
        {
          sts = "再開中";
        }
        else if (sc.Status == ServiceControllerStatus.Paused)
        {
          sts = "一時停止";
        }
        else if (sc.Status == ServiceControllerStatus.PausePending)
        {
          sts = "一時停止中";
        }
        else if (sc.Status == ServiceControllerStatus.Running)
        {
          sts = "実行中";
          btnStop.Enabled = true;
        }
        else if (sc.Status == ServiceControllerStatus.StartPending)
        {
          sts = "開始中";
        }
        else if (sc.Status == ServiceControllerStatus.StopPending)
        {
          sts = "停止中";
        }
        else if (sc.Status == ServiceControllerStatus.Stopped)
        {
          sts = "停止";
          btnStart.Enabled = true;
        }
      }
    }
    txtStatus.Text = sts;
  }

  // 自分自身を管理者権限で起動して終了
  private void RunAsMyself()
  {
    // .NET Framework 時は string? の ? 不要
    string? target = Process.GetCurrentProcess()?.MainModule?.FileName;
    if (target != null)
    {
      var proc = new ProcessStartInfo()
      {
        FileName = target,
        WorkingDirectory = Environment.CurrentDirectory,
        UseShellExecute = true,
        Verb = "RunAs"  // 管理者権限で実行
      };
      // 自分自身を管理者権限で起動
      Process.Start(proc);

     // 自分自身を終了
     this.Close();
    }
  }
}
```

::: note
WPF の場合、下記相違があります。
- Form ではなく Window
- Button.Enabled ではなく Button.IsEnabled 
:::

::: note
管理者権限昇格をするソフトを配布する場合、証明書付与を行ってください。
証明書付与をしないと、UAC 昇格ダイアログで「この不明な発行元のアプリがデバイスに変更を加えることを許可しますか？」と表示されてしまいます。
:::