Windowsサービスの開始種類を安全に変更する:.NET Framework 4.8+P/Invoke(ChangeServiceConfigとSafeHandle徹底解説)

「サービスの開始種類をコードから変更したいのに、ServiceController にはプロパティがない。P/Invoke で ChangeServiceConfig を呼ぶとハンドル型でつまずく。閉じ方も不安」──.NET Framework 4.8 の現場で誰もが一度はぶつかる壁です。本稿はその悩みを一気に解消します。安全なハンドル管理、正しい P/Invoke 宣言、アクセス権の落とし穴、遅延自動開始(ChangeServiceConfig2)まで、実運用に耐えるコードと共に徹底解説します。

目次

背景と課題の整理

.NET Framework 4.8 の ServiceController クラスは、サービス開始種類(自動/自動(遅延)/手動/無効)の変更 API を公開していません。そのため Win32 API の ChangeServiceConfig(および遅延自動開始には ChangeServiceConfig2)を P/Invoke で呼ぶのが定石です。ところが次の2点でよくハマります。

  • ハンドル型の不一致:ServiceController.ServiceHandle は SafeHandle ですが、P/Invoke を IntPtr 受けで宣言していると「SafeHandle を IntPtr に変換できない」コンパイル エラーになります。
  • ハンドルのクローズ規約:ServiceController が返すハンドルを自分で CloseServiceHandle すべきか、ServiceHandle.Close()/ServiceController.Close() を呼ぶべきか分からない。

結論(まずは最短の実装)

以下の2点を守れば、.NET 4.8 でも安全・簡潔に開始種類を変更できます。

  1. P/Invoke の第一引数は IntPtr で宣言し、呼び出し側では sc.ServiceHandle.DangerousGetHandle() を渡す。
  2. 自前で CloseServiceHandle は呼ばない。ServiceController を Using で囲み、Dispose に任せる。
Imports System.Runtime.InteropServices
Imports System.ComponentModel
Imports System.ServiceProcess

Module ServiceStartType

    <DllImport("advapi32.dll", SetLastError:=True, CharSet:=CharSet.Unicode)>
    Private Function ChangeServiceConfig(
        hService As IntPtr,
        dwServiceType As UInteger,
        dwStartType As UInteger,
        dwErrorControl As UInteger,
        lpBinaryPathName As String,
        lpLoadOrderGroup As String,
        lpdwTagId As IntPtr,
        lpDependencies As String,
        lpServiceStartName As String,
        lpPassword As String,
        lpDisplayName As String
    ) As <MarshalAs(UnmanagedType.Bool)> Boolean
    End Function

    Public Sub SetStartupType(serviceName As String, startType As UInteger)
        Const SERVICE_NO_CHANGE As UInteger = &HFFFFFFFFUI
        Using sc As New ServiceController(serviceName)
            Dim h As IntPtr = sc.ServiceHandle.DangerousGetHandle()
            Dim ok = ChangeServiceConfig(h, SERVICE_NO_CHANGE, startType, SERVICE_NO_CHANGE,
                                         Nothing, Nothing, IntPtr.Zero, Nothing, Nothing, Nothing, Nothing)
            If Not ok Then Throw New Win32Exception(Marshal.GetLastWin32Error())
        End Using ' ← ここでハンドル自動解放
    End Sub
End Module

ポイント:DangerousGetHandle() は “生の” IntPtr を取り出す危険な API ですが、ServiceController を Using で囲めばライフタイムが担保されます。二重解放(double free)を防ぐため、返ってきたハンドルを自分で閉じないでください。

開始種類の値一覧(Win32 定数)

開始種類定数値備考
変更しないSERVICE_NO_CHANGE0xFFFFFFFF他パラメータのみ変更
自動SERVICE_AUTO_START0x00000002「遅延」は別 API(後述)
手動SERVICE_DEMAND_START0x00000003オンデマンド
無効SERVICE_DISABLED0x00000004起動不可
Public Module ServiceStartTypeConst
    Public Const SERVICE_NO_CHANGE As UInteger = &HFFFFFFFFUI
    Public Const SERVICE_AUTO_START As UInteger = 2UI
    Public Const SERVICE_DEMAND_START As UInteger = 3UI
    Public Const SERVICE_DISABLED As UInteger = 4UI
End Module

ハンドル管理の正解/やってよいこと・ダメなこと

状況やることやってはいけないこと
ServiceController.ServiceHandle を使うUsing sc ... End Using で囲み、sc.ServiceHandle.DangerousGetHandle() を IntPtr として渡すCloseServiceHandle を自分で呼ぶ/ServiceHandle.Close() を勝手に呼ぶ
P/Invoke で自前に OpenService した必ず CloseServiceHandle で閉じる(Try...Finally または SafeHandle で管理)閉じ忘れ(ハンドルリーク)
64/32bit 混在CharSet.Unicode と SetLastError:=True を宣言に付けるANSI 宣言/エラーコード未取得

アクセス権の落とし穴:SERVICE_CHANGE_CONFIG が必要

ChangeServiceConfig/ChangeServiceConfig2 を呼ぶには、対象サービスを SERVICE_CHANGE_CONFIG 権限で開いている必要があります。多くの環境では管理者(UAC 昇格済み)でないと ERROR_ACCESS_DENIED (5) になります。

通常は ServiceController が十分な権限でハンドルを開いてくれますが、ドメイン環境やリモート接続では不足する場合があります。その場合だけ「自前で開く→閉じる」のパスを用意しておくと堅牢です。

Imports System.Runtime.InteropServices
Imports System.ComponentModel

Module NativeOpen

    Private Const SC_MANAGER_CONNECT As UInteger = &H1
    Private Const SERVICE_CHANGE_CONFIG As UInteger = &H2

    <DllImport("advapi32.dll", SetLastError:=True, CharSet:=CharSet.Unicode)>
    Private Function OpenSCManager(machineName As String, databaseName As String, dwDesiredAccess As UInteger) As IntPtr
    End Function

    <DllImport("advapi32.dll", SetLastError:=True, CharSet:=CharSet.Unicode)>
    Private Function OpenService(hSCManager As IntPtr, lpServiceName As String, dwDesiredAccess As UInteger) As IntPtr
    End Function

    <DllImport("advapi32.dll", SetLastError:=True)>
    Private Function CloseServiceHandle(hSCObject As IntPtr) As <MarshalAs(UnmanagedType.Bool)> Boolean
    End Function

    Public Sub SetStartupTypeFallback(serviceName As String, startType As UInteger, Optional machine As String = Nothing)
        Const SERVICE_NO_CHANGE As UInteger = &HFFFFFFFFUI
        Dim scm As IntPtr = IntPtr.Zero, svc As IntPtr = IntPtr.Zero
        Try
            scm = OpenSCManager(machine, Nothing, SC_MANAGER_CONNECT)
            If scm = IntPtr.Zero Then Throw New Win32Exception(Marshal.GetLastWin32Error())
            svc = OpenService(scm, serviceName, SERVICE_CHANGE_CONFIG)
            If svc = IntPtr.Zero Then Throw New Win32Exception(Marshal.GetLastWin32Error())

            ' Change
            If Not ChangeServiceConfig(svc, SERVICE_NO_CHANGE, startType, SERVICE_NO_CHANGE,
                                       Nothing, Nothing, IntPtr.Zero, Nothing, Nothing, Nothing, Nothing) Then
                Throw New Win32Exception(Marshal.GetLastWin32Error())
            End If
        Finally
            If svc <> IntPtr.Zero Then CloseServiceHandle(svc) : svc = IntPtr.Zero
            If scm <> IntPtr.Zero Then CloseServiceHandle(scm) : scm = IntPtr.Zero
        End Try
    End Sub
End Module

注意:上の関数は 自前で開いたハンドル なので、CloseServiceHandle を必ず呼びます。ServiceController から取得したハンドルを渡しているわけではありません。

「自動(遅延開始)」を設定する:ChangeServiceConfig2

GUI の「自動(遅延開始)」に相当する設定は ChangeServiceConfig2 の SERVICE_CONFIG_DELAYED_AUTO_START_INFO で行います。開始種類を SERVICE_AUTO_START にした上で、遅延フラグを True に設定します。

Imports System.Runtime.InteropServices
Imports System.ComponentModel
Imports System.ServiceProcess

Module DelayedAutoStart

    Private Const SERVICE_CONFIG_DELAYED_AUTO_START_INFO As UInteger = 3UI

    &lt;StructLayout(LayoutKind.Sequential)&gt;
    Private Structure SERVICE_DELAYED_AUTO_START_INFO
        &lt;MarshalAs(UnmanagedType.Bool)&gt;
        Public fDelayedAutostart As Boolean
    End Structure

    &lt;DllImport("advapi32.dll", SetLastError:=True, CharSet:=CharSet.Unicode)&gt;
    Private Function ChangeServiceConfig2(
        hService As IntPtr,
        dwInfoLevel As UInteger,
        ByRef lpInfo As SERVICE_DELAYED_AUTO_START_INFO
    ) As &lt;MarshalAs(UnmanagedType.Bool)&gt; Boolean
    End Function

    Public Sub SetDelayedAutoStart(serviceName As String, enable As Boolean)
        Using sc As New ServiceController(serviceName)
            Dim h = sc.ServiceHandle.DangerousGetHandle()

            ' 1) 開始種類を自動へ(必要なら)
            Const SERVICE_NO_CHANGE As UInteger = &amp;HFFFFFFFFUI
            Const SERVICE_AUTO_START As UInteger = 2UI
            If Not ChangeServiceConfig(h, SERVICE_NO_CHANGE, SERVICE_AUTO_START, SERVICE_NO_CHANGE,
                                       Nothing, Nothing, IntPtr.Zero, Nothing, Nothing, Nothing, Nothing) Then
                Throw New Win32Exception(Marshal.GetLastWin32Error())
            End If

            ' 2) 遅延自動のフラグ設定
            Dim info As New SERVICE_DELAYED_AUTO_START_INFO With {.fDelayedAutostart = enable}
            If Not ChangeServiceConfig2(h, SERVICE_CONFIG_DELAYED_AUTO_START_INFO, info) Then
                Throw New Win32Exception(Marshal.GetLastWin32Error())
            End If
        End Using
    End Sub
End Module

現在の開始種類を読み取る(検証用)

テストやログのために、QueryServiceConfig で現在の開始種類を取得する関数も用意しておくと便利です。

Imports System.Runtime.InteropServices
Imports System.ComponentModel
Imports System.ServiceProcess

Module QueryConfig

    &lt;StructLayout(LayoutKind.Sequential, CharSet:=CharSet.Unicode)&gt;
    Private Structure QUERY_SERVICE_CONFIG
        Public dwServiceType As UInteger
        Public dwStartType As UInteger
        Public dwErrorControl As UInteger
        Public lpBinaryPathName As IntPtr
        Public lpLoadOrderGroup As IntPtr
        Public dwTagId As UInteger
        Public lpDependencies As IntPtr
        Public lpServiceStartName As IntPtr
        Public lpDisplayName As IntPtr
    End Structure

    &lt;DllImport("advapi32.dll", SetLastError:=True, CharSet:=CharSet.Unicode)&gt;
    Private Function QueryServiceConfig(hService As IntPtr, lpServiceConfig As IntPtr,
                                        cbBufSize As Integer, ByRef pcbBytesNeeded As Integer) As Boolean
    End Function

    Public Function GetStartType(serviceName As String) As UInteger
        Using sc As New ServiceController(serviceName)
            Dim h = sc.ServiceHandle.DangerousGetHandle()
            Dim needed As Integer = 0
            QueryServiceConfig(h, IntPtr.Zero, 0, needed)
            Dim last = Marshal.GetLastWin32Error()
            Dim buf = Marshal.AllocHGlobal(needed)
            Try
                If Not QueryServiceConfig(h, buf, needed, needed) Then
                    Throw New Win32Exception(Marshal.GetLastWin32Error())
                End If
                Dim cfg = Marshal.PtrToStructure(Of QUERY_SERVICE_CONFIG)(buf)
                Return cfg.dwStartType
            Finally
                Marshal.FreeHGlobal(buf)
            End Try
        End Using
    End Function
End Module

本番で使えるヘルパー API(まとめ)

上記をひとつのモジュールにまとめ、用途別の関数を公開しておくと再利用しやすくなります。

Imports System.ComponentModel
Imports System.ServiceProcess

Public Module ServiceConfigurator

    Public Enum StartupType As UInteger
        NoChange = &amp;HFFFFFFFFUI
        AutoStart = 2UI
        DemandStart = 3UI
        Disabled = 4UI
    End Enum

    Public Sub SetStartupType(serviceName As String, type As StartupType)
        NativeGuard.AssertElevated() ' 任意:昇格チェック(実装例は後述)
        ServiceStartType.SetStartupType(serviceName, CUInt(type))
    End Sub

    Public Sub EnableDelayedAutoStart(serviceName As String, enable As Boolean)
        NativeGuard.AssertElevated()
        DelayedAutoStart.SetDelayedAutoStart(serviceName, enable)
    End Sub

    Public Function ReadStartupType(serviceName As String) As StartupType
        Return CType(QueryConfig.GetStartType(serviceName), StartupType)
    End Function
End Module

昇格(UAC)と権限のチェック

開始種類の変更には通常ローカル管理者のトークンが必要です。テスト時に権限不足で悩まないよう、プロセスが昇格しているかを先に検査すると UX が向上します。

Imports System.Security.Principal

Public Module NativeGuard
    Public Sub AssertElevated()
        Dim id = WindowsIdentity.GetCurrent()
        Dim principal = New WindowsPrincipal(id)
        If Not principal.IsInRole(WindowsBuiltInRole.Administrator) Then
            Throw New UnauthorizedAccessException("管理者として実行してください(UAC昇格が必要)。")
        End If
    End Sub
End Module

典型エラーと対処

Win32 エラー状況対処
5 (ERROR_ACCESS_DENIED)権限不足/UAC 未昇格/サービスの ACL が厳しい管理者で実行、または OpenService(..., SERVICE_CHANGE_CONFIG) のフォールバックを使用
87 (ERROR_INVALID_PARAMETER)開始種類の値が不正、または組み合わせが矛盾定数を確認。遅延開始は ChangeServiceConfig2 を併用
1060 (ERROR_SERVICE_DOES_NOT_EXIST)サービス名の誤り/ターゲット環境に存在しないServiceController.GetServices() で列挙して確認
6 (ERROR_INVALID_HANDLE)無効ハンドルを渡したハンドルのライフタイムを Using で保証。自前で閉じない

動作確認のミニスクリプト

テストは専用の検証用サービスで行うことを推奨します(OS 既定の重要サービスは避ける)。以下は一連の流れの例です。

Sub Demo()
    Dim name = "YourTestService"
    Console.WriteLine("Before: " &amp; ServiceConfigurator.ReadStartupType(name))

    ServiceConfigurator.SetStartupType(name, ServiceConfigurator.StartupType.DemandStart)
    Console.WriteLine("Set to Manual.")
    Console.WriteLine("Now: " &amp; ServiceConfigurator.ReadStartupType(name))

    ServiceConfigurator.SetStartupType(name, ServiceConfigurator.StartupType.AutoStart)
    ServiceConfigurator.EnableDelayedAutoStart(name, True)
    Console.WriteLine("Set to Automatic (Delayed).")
    Console.WriteLine("Now: " &amp; ServiceConfigurator.ReadStartupType(name))
End Sub

ServiceController リフレッシュのコツ

ServiceController は状態をキャッシュします。設定変更直後に UI などへ反映する場合は、同一インスタンスで sc.Refresh() を呼ぶか、新しい ServiceController を作り直してください。特に「サービス管理コンソール(services.msc)」を開いたままの検証では表示が遅れて見えることがあります。

安全性と運用面のベストプラクティス

  • トランザクション性:ChangeServiceConfig は呼び出し単位で変更され、途中失敗時は既存設定が残ります。複数項目を変えるときは失敗時のロールバック方針を決めておきましょう。
  • 依存関係:依存サービスがある場合、無効化は可能ですが運用影響が大きいです。事前に依存関係を列挙し(QueryServiceConfig の lpDependencies)、変更計画に反映します。
  • 監査:だれが・いつ・どのサービスを・何に変更したかをアプリ側でログに残すと追跡が容易です。
  • リモート変更:OpenSCManager(machine, ...) で \\SERVER を指定すればリモートも可能。ファイアウォールと資格情報に注意。
  • ビルド構成:AnyCPU/x64 いずれでも動作しますが、宣言は CharSet.Unicode に統一し、ANSI にならないよう注意。

PowerShell との使い分け

運用ツールとしては PowerShell の Set-Service -StartupType が簡便です。アプリ内で完結させたい/GUI から切り替えたい/監査や権限昇格を自前で制御したい、といった要件では本稿の .NET 実装が有効です。

FAQ(よくある質問)

Q. ChangeServiceConfig の第一引数を SafeHandle で宣言してはダメ?

A. 型定義上は可能ですが、ServiceController の戻り値は内部型(派生 SafeHandle)であり、P/Invoke 側の宣言と完全一致させるのが面倒です。最も実務的なのは IntPtr 受け + DangerousGetHandle() の組み合わせです。

Q. CloseServiceHandle を自分で呼ぶべきケースは?

A. 自前で OpenSCManager/OpenService したときのみです。ServiceController から入手したハンドルは ServiceController.Dispose() が閉じます。

Q. 実行中のサービスを「無効」にしても大丈夫?

A. 実行中でも設定変更自体は可能です。次回起動がブロックされるだけで、直ちに停止はされません。運用上の影響を理解したうえで行いましょう。

Q. 設定変更が反映されない/GUI と食い違う

A. キャッシュの可能性があります。sc.Refresh()、新しいインスタンスの作成、あるいはサービス管理コンソールを開き直してください。

落とし穴チェックリスト

チェック項目説明
UAC 昇格済みか管理者トークンで実行しているかを確認(サンプルの AssertElevated() を活用)
開始種類の定数値Auto=2, Manual=3, Disabled=4, NoChange=0xFFFFFFFF
遅延自動の設定順序まず自動開始にし、その後 ChangeServiceConfig2 で遅延フラグを立てる
ハンドルの閉じ方ServiceController の場合は 閉じない/自前で開いた場合は 必ず閉じる
エラー番号の記録Marshal.GetLastWin32Error() を例外に乗せてログへ

(参考)C# での最小実装

読者の混在環境向けに、C# 版の最小コードも掲載しておきます。基本は VB と同じです。

using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.ServiceProcess;

static class ServiceTools
{
    [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
    static extern bool ChangeServiceConfig(
        IntPtr hService, uint type, uint startType, uint errorControl,
        string bin, string group, IntPtr tagId, string deps, string startName, string pwd, string display);

    const uint SERVICE_NO_CHANGE = 0xFFFFFFFF;
    public const uint SERVICE_AUTO_START = 2;
    public const uint SERVICE_DEMAND_START = 3;
    public const uint SERVICE_DISABLED = 4;

    public static void SetStartupType(string name, uint startType)
    {
        using var sc = new ServiceController(name);
        var h = sc.ServiceHandle.DangerousGetHandle();
        if (!ChangeServiceConfig(h, SERVICE_NO_CHANGE, startType, SERVICE_NO_CHANGE,
                                 null, null, IntPtr.Zero, null, null, null, null))
            throw new Win32Exception(Marshal.GetLastWin32Error());
    }
}

まとめ

  • P/Invoke の第一引数は IntPtr 受け、呼び出しは DangerousGetHandle() で OK。
  • ハンドル解放は Using/Dispose に任せる。ServiceController 由来のハンドルに CloseServiceHandle を掛けない。
  • 権限不足に配慮し、必要に応じて OpenSCManager/OpenService で SERVICE_CHANGE_CONFIG を確保。
  • 「自動(遅延)」は ChangeServiceConfig2 を併用してフラグを設定。
  • エラー処理・ログ・リフレッシュを整備すれば、.NET Framework 4.8 でも実運用で安全にサービスの開始種類を制御できます。

付録:よく使うコード断片(コピペ可)

定数・宣言ひとまとめ

Public Module ServiceNative
    Public Const SERVICE_NO_CHANGE As UInteger = &HFFFFFFFFUI
    Public Const SERVICE_AUTO_START As UInteger = 2UI
    Public Const SERVICE_DEMAND_START As UInteger = 3UI
    Public Const SERVICE_DISABLED As UInteger = 4UI
    Public Const SERVICE_CONFIG_DELAYED_AUTO_START_INFO As UInteger = 3UI


<DllImport("advapi32.dll", SetLastError:=True, CharSet:=CharSet.Unicode)>
Public Function ChangeServiceConfig(
    hService As IntPtr, dwServiceType As UInteger, dwStartType As UInteger, dwErrorControl As UInteger,
    lpBinaryPathName As String, lpLoadOrderGroup As String, lpdwTagId As IntPtr,
    lpDependencies As String, lpServiceStartName As String, lpPassword As String, lpDisplayName As String
) As <MarshalAs(UnmanagedType.Bool)> Boolean
End Function

<StructLayout(LayoutKind.Sequential)>
Public Structure SERVICE_DELAYED_AUTO_START_INFO
    <MarshalAs(UnmanagedType.Bool)> Public fDelayedAutostart As Boolean
End Structure

<DllImport("advapi32.dll", SetLastError:=True, CharSet:=CharSet.Unicode)>
Public Function ChangeServiceConfig2(
    hService As IntPtr, dwInfoLevel As UInteger, ByRef lpInfo As SERVICE_DELAYED_AUTO_START_INFO
) As <MarshalAs(UnmanagedType.Bool)> Boolean
End Function


End Module 

ユースケース別ワンライナー

' Manual にする
ServiceStartType.SetStartupType("YourService", SERVICE_DEMAND_START)

' Disabled にする
ServiceStartType.SetStartupType("YourService", SERVICE_DISABLED)

' Automatic(Delayed) にする
DelayedAutoStart.SetDelayedAutoStart("YourService", True) 

安全な例外化テンプレート

Private Sub ThrowOnLastErrorIfFalse(ok As Boolean, Optional action As String = Nothing)
    If Not ok Then
        Dim code = Marshal.GetLastWin32Error()
        Dim msg = If(String.IsNullOrEmpty(action), "", $"({action}) ")
        Throw New Win32Exception(code, $"{msg}Win32Error={code}")
    End If
End Sub

ServiceController のキャッシュ更新

Using sc As New ServiceController("YourService")
    ' ... 変更処理 ...
    sc.Refresh() ' ← これで最新状態を再取得
End Using

以上で、ハンドル管理と ChangeServiceConfig 呼び出しに関する“落とし穴”はすべて回避できます。現場のユースケースに合わせて、ここで示した最短パス(ServiceController のハンドルをそのまま使う)と、権限不足時のフォールバックの二段構えで実装しておくのがプロダクション品質です。

この記事を書いた人

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

コメント

コメントする

目次