行き先なし

PowerShellでWindowsサービスを登録する

PowerShellスクリプトを使って任意のexeファイルをサービス化します

WindowsPowerShellWindowsサービス

ほぼ Windows PowerShell - PowerShell での Windows サービスの作成 のままです。

環境は Windows11 / PowerShell5.1

人生には適当な exe ファイルを Windows サービスにしたくなるときが何度かあると思います。
CoreDNS をサービス化することにします。

サービスは Win32API や.NET で実装することができます。

検索で見つけた CoreDNS を Windows のサービスとして登録するためのラッパを Go で書いてみた では https://github.com/kardianos/service が紹介されています。Go 言語には https://pkg.go.dev/golang.org/x/sys/windows パッケージで呼べるようになっていますね。

とはいえ小さい目的にコンパイル言語を持ち出すのもなと探したら出てきたのが PowerShell でサービスを作成する方法です。

コンパイル言語を持ち出すのもと書いていますが PowerShell スクリプトから .NET C# を実行ファイルにする仕組みが使われています。

やってみましょう。

スクリプト

ファイル配置

D:\MyDNS\
├─ MyDNS.ps1
├─ coredns.exe
├─ Corefile

[Windows PowerShell] Writing Windows Services in PowerShell または https://github.com/JFLarvoire/SysToolsLib/tree/master/PowerShell から PSService.ps1 スクリプトをダウンロードできます。
(日本語ページからだと bit.ly のリンクから飛べなかったので英語ページです。)

PSService.ps1MyDNS.ps1 にリネームしました。

Windows サービスやスクリプトについては Microsoft の元記事を参照してもらうとして、変更するのは 1000 行目以降にある # Run the service のところです。

サンプルはタイマークラス $timer = new-object System.Timers.Timer を実行していますがここでは 1 つの exe ファイルの起動・終了をします。

PowerShell では Start-ProcessStop-Process を使います。

# -FilePath : 実行ファイルパス指定
# -WindowStyle Hidden : ウインドウ非表示
# -passthru : プロセスオブジェクトを返す
$app = Start-Process -FilePath "D:\MyDNS\coredns.exe" -WindowStyle Hidden -passthru

$ -Id : プロセスID指定
Stop-Process -Id $app.id

スクリプトを書き換えたあとの Diff はこうなります。書き換え後の形は記事末尾に書いています。

PS D:\MyDNS> diff (cat .\PSService.ps1) (cat .\MyDNS.ps1)

InputObject                                                                                              SideIndicator
-----------                                                                                              -------------
$serviceDisplayName = "MyDNS"                                                                            =>
$ServiceDescription = "MyHOME DNS with CoreDNS"                                                          =>
                                                                                                         =>
  $app = $null                                                                                           =>
                                                                                                         =>
                                                                                                         =>
    # Start a process                                                                                    =>
    $app = Start-Process -FilePath "D:\MyDNS\coredns.exe" -WindowStyle Hidden -passthru                  =>
                                                                                                         =>
    # Cleanup (Close) a Process                                                                          =>
    if ($app -ne $null) {                                                                                =>
      Stop-Process -Id $app.id                                                                           =>
    }                                                                                                    =>
                                                                                                         =>
$serviceDisplayName = "A Sample PowerShell Service"                                                      <=
$ServiceDescription = "Shows how a service can be written in PowerShell"                                 <=
    ######### TO DO: Implement your own service code here. ##########                                    <=
    ###### Example that wakes up and logs a line every 10 sec: ######                                    <=
    # Start a periodic timer                                                                             <=
    $timerName = "Sample service timer"                                                                  <=
    $period = 10 # seconds                                                                               <=
    $timer = new-object System.Timers.Timer                                                              <=
    $timer.Interval = ($period * 1000) # Milliseconds                                                    <=
    $timer.AutoReset = $true # Make it fire repeatedly                                                   <=
    Register-ObjectEvent $timer -EventName Elapsed -SourceIdentifier $timerName -MessageData "TimerTick" <=
    $timer.start() # Must be stopped in the finally block                                                <=
        "TimerTick" { # Example. Periodic event generated for this example                               <=
          Log "$scriptName -Service # Timer ticked"                                                      <=
        }                                                                                                <=
    # Cleanup the periodic timer used in the above example                                               <=
    Unregister-Event -SourceIdentifier $timerName                                                        <=
    $timer.stop()                                                                                        <=

使い方

管理者権限の PowerShell で実行します。

PS D:\MyDNS> .\MyDNS.ps1 -Status
Not Installed

# サービス有効化
# 特に強い権限は必要無いので LocalService で走らせます
# 未指定(System), LocalService, NetworkService, LocalSystem が選択できます。
# 使用しているローカルユーザー名を入れても Microsoft アカウント名の資格情報の入力フォームしか出てこなかったので試していません。
PS D:\MyDNS> .\MyDNS.ps1 -Setup -UserName LocalService
PS D:\MyDNS> .\MyDNS.ps1 -Status
Stopped

# この時点で C:\Windows\System32\ に MyDNS.exe と MyDNS.ps1 が存在します。
# Windowsのサービス管理でも存在が確認できます。

# 開始
PS D:\MyDNS> .\MyDNS.ps1 -Start
PS D:\MyDNS> .\MyDNS.ps1 -Status
Running

# タスクマネージャーのサービス一覧に現れたのはStartをした後からでした。
# スタートアップ:自動なので再起動後も有効です。

# 停止
PS D:\MyDNS> .\MyDNS.ps1 -Stop
PS D:\MyDNS> .\MyDNS.ps1 -Status
Stopped

# サービス無効化
PS D:\MyDNS> .\MyDNS.ps1 -Remove
PS D:\MyDNS> .\MyDNS.ps1 -Status
Not Installed

これでオレオレサービスを起動できるようになりました。

補足

書き換え後の if ($Service) {} ブロック

if ($Service) {                 # Run the service
  Write-EventLog -LogName $logName -Source $serviceName -EventId 1005 -EntryType Information -Message "$scriptName -Service # Beginning background job"
  # Do the service background job

  $app = $null

  try {
    # Start the control pipe handler thread
    $pipeThread = Start-PipeHandlerThread $pipeName -Event "ControlMessage"

    # Start a process
    $app = Start-Process -FilePath "D:\MyDNS\coredns.exe" -WindowStyle Hidden -passthru

    # Now enter the main service event loop
    do { # Keep running until told to exit by the -Stop handler
      $event = Wait-Event # Wait for the next incoming event
      $source = $event.SourceIdentifier
      $message = $event.MessageData
      $eventTime = $event.TimeGenerated.TimeofDay
      Write-Debug "Event at $eventTime from ${source}: $message"
      $event | Remove-Event # Flush the event from the queue
      switch ($message) {
        "ControlMessage" { # Required. Message received by the control pipe thread
          $state = $event.SourceEventArgs.InvocationStateInfo.state
          Write-Debug "$script -Service # Thread $source state changed to $state"
          switch ($state) {
            "Completed" {
              $message = Receive-PipeHandlerThread $pipeThread
              Log "$scriptName -Service # Received control message: $Message"
              if ($message -ne "exit") { # Start another thread waiting for control messages
                $pipeThread = Start-PipeHandlerThread $pipeName -Event "ControlMessage"
              }
            }
            "Failed" {
              $error = Receive-PipeHandlerThread $pipeThread
              Log "$scriptName -Service # $source thread failed: $error"
              Start-Sleep 1 # Avoid getting too many errors
              $pipeThread = Start-PipeHandlerThread $pipeName -Event "ControlMessage" # Retry
            }
          }
        }
        default { # Should not happen
          Log "$scriptName -Service # Unexpected event from ${source}: $Message"
        }
      }
    } while ($message -ne "exit")
  } catch { # An exception occurred while runnning the service
    $msg = $_.Exception.Message
    $line = $_.InvocationInfo.ScriptLineNumber
    Log "$scriptName -Service # Error at line ${line}: $msg"
  } finally { # Invoked in all cases: Exception or normally by -Stop
    # Cleanup (Close) a Process
    if ($app -ne $null) {
      Stop-Process -Id $app.id
    }

    ############### End of the service code example. ################
    # Terminate the control pipe handler thread
    Get-PSThread | Remove-PSThread # Remove all remaining threads
    # Flush all leftover events (There may be some that arrived after we exited the while event loop, but before we unregistered the events)
    $events = Get-Event | Remove-Event
    # Log a termination event, no matter what the cause is.
    Write-EventLog -LogName $logName -Source $serviceName -EventId 1006 -EntryType Information -Message "$script -Service # Exiting"
    Log "$scriptName -Service # Exiting"
  }
  return
}