Windows サービスをデプロイするリリースパイプラインを作ろうとすると、リリースパイプライン内で Windows サービスを停止したり開始したりする必要があります。 Windows サービスは常駐プロセスなので、一度停止しないとモジュールの交換ができません。 ということで、今回は Windows サービスの停止/開始を行う Azure Pipelines の YAML テンプレートのご紹介です。
環境
- Windows 10 1909
- .NET Framework 4.8
前提条件
今回紹介するビルド/リリースパイプラインは、インストール済みの Windows サービスを置き換えるためのパイプラインです。 初回の配置は手動で行うことを前提としています。
またリリース先の VM は、 Environments に登録しておいてください。 Environment の登録については以下の記事を参考にして下さい。
リリースパイプラインの全体像
まずはリリースパイプラインの本体を作っていきます。 まずは全体を先に示しておきます。
trigger: none pool: vmImage: 'windows-latest' variables: solution: '**/Samples.sln' windowsServiceProject: '**/Samples.WinService.csproj' buildPlatform: 'AnyCPU' buildConfiguration: 'Release' stages: - stage: jobs: - job: steps: - task: NuGetToolInstaller@1 inputs: versionSpec: checkLatest: true - task: NuGetCommand@2 inputs: restoreSolution: '$(solution)' - task: VSBuild@1 inputs: solution: '$(windowsServiceProject)' platform: '$(buildPlatform)' configuration: '$(buildConfiguration)' msbuildArgs: '/p:OutPutPath="$(Build.Artifactstagingdirectory)/WinService"' - task: PublishBuildArtifacts@1 inputs: PathtoPublish: '$(Build.Artifactstagingdirectory)/WinService' ArtifactName: 'WinService' publishLocation: 'Container' - stage: variables: winServiceModulePath: '<Windows サービスの配置先>' winServiceName: '<Windows サービスの名前>' jobs: - deployment: environment: name: '<リリース先 Environment の名前>' resourceType: VirtualMachine strategy: runOnce: preDeploy: steps: - download: none - template: stop-windows-service-template.yml parameters: windowsServiceName: '$(winServiceName)' deploy: steps: - download: none - task: DownloadPipelineArtifact@2 inputs: buildType: 'current' artifactName: 'WinService' targetPath: '$(winServiceModulePath)' on: success: steps: - template: start-windows-service-template.yml parameters: windowsServiceName: '$(winServiceName)'
ステージは 2 つ準備しておきます。 1 つ目のステージでは、 Windows サービスのアプリケーションをビルドし、パイプラインアーティファクトとしてアップロードしています。 今回のサンプルでは NuGet パッケージのリストアも実装してあります。 2 つ目のステージで Windows サービスのアプリケーションを配置しています。 配置をする前に、対象の Windows サービスを停止するテンプレートを呼び出しています。 また配置が成功した後、 Windows サービスを開始するテンプレートを呼び出しています。
Windows サービスを配置する場合は、 preDeploy
や on
をうまく活用して、 Windows サービスの停止/開始を組み込んであげる必要があります。
Windows サービスを停止する YAML テンプレート
続いて Windows サービスを停止するテンプレートを見ていきます。
stop-windows-service-template.yml
parameters: - name: windowsServiceName type: string default: '' - name: timeoutSec type: number default: '40' steps: - task: PowerShell@2 displayName: 'パラメーターチェック' inputs: targetType: 'inline' script: | If ( "${{ parameters.windowsServiceName }}" -eq "" ) { Write-Host "##vso[task.logissue type=error;]テンプレートパラメーター \"windowsServiceName\" が指定されていません。" Write-Host "##vso[task.complete result=Failed;]" exit } - task: PowerShell@2 displayName: '${{ parameters.windowsServiceName }} の停止' inputs: targetType: 'inline' script: | $SvcName = '${{ parameters.windowsServiceName }}' $Timeout = New-TimeSpan -Seconds ${{ parameters.timeoutSec }} $SvcCtrl = Get-Service | Where-Object { $_.Name -eq $SvcName } If ($SvcCtrl -eq $null) { Write-Host "##vso[task.logissue type=error;]"$SvcName" という名前の Windows サービスが見つかりません。" Write-Host "##vso[task.complete result=Failed;]" exit } Write-Host "サービス (" $SvcCtrl.Name "|" $SvcCtrl.DisplayName ") が見つかりました ( ステータス:" $SvcCtrl.Status ") 。" If ($SvcCtrl.Status -eq "StartPending") { Write-Host "サービス (" $SvcCtrl.Name "|" $SvcCtrl.DisplayName ") が開始処理中のため待機しています ( ステータス:" $SvcCtrl.Status ") 。" try { $SvcCtrl.WaitForStatus("Running", $Timeout) } catch { Write-Host "##vso[task.logissue type=warning;]"$SvcName" が開始するのを待機しましたが、タイムアウトしました。" Write-Host $_.Exception.ToString() } $SvcCtrl.Refresh() } If ($SvcCtrl.Status -eq "Running") { Write-Host "サービス (" $SvcCtrl.Name "|" $SvcCtrl.DisplayName ") の終了処理を実行します ( ステータス:" $SvcCtrl.Status ") 。" try { $SvcCtrl.Stop() } catch { Write-Host "##vso[task.logissue type=warning;]"$SvcName" の終了に失敗しました。" Write-Host $_.Exception.ToString() } $SvcCtrl.Refresh() } If ($SvcCtrl.Status -eq "StopPending") { Write-Host "サービス (" $SvcCtrl.Name "|" $SvcCtrl.DisplayName ") が終了処理中のため待機しています ( ステータス:" $SvcCtrl.Status ") 。" try { $SvcCtrl.WaitForStatus("Stopped", $Timeout) } catch { Write-Host "##vso[task.logissue type=warning;]"$SvcName" が終了するのを待機しましたが、タイムアウトしました。" Write-Host $_.Exception.ToString() } $SvcCtrl.Refresh() } If ($SvcCtrl.Status -eq "Stopped") { Write-Host "サービス (" $SvcCtrl.Name "|" $SvcCtrl.DisplayName ") が終了しました ( ステータス:" $SvcCtrl.Status ") 。" exit } Else { Write-Host "##vso[task.logissue type=error;]"$SvcName" が終了しませんでした。" Write-Host "サービス (" $SvcCtrl.Name "|" $SvcCtrl.DisplayName ") が終了しませんでした ( ステータス:" $SvcCtrl.Status ") 。" Write-Host "##vso[task.complete result=Failed;]" exit }
Windows サービスを安全に停止するためには、対象の Windows サービスの状態をよく確認して処理を行わなければなりません。 停止している Windows サービスに対して停止処理を行うと例外が発生してしまいます。 そのため、テンプレート内ではステータスを逐一チェックしながら、 Windows サービスが安全に停止するように実装しています。
とにかく Windows サービスが停止すれば良いので、細かく try-catch をかけて、何か問題があればコンソールに出力するようにしてあります。 Azure Pipelines にビルドの警告やエラーを通知する場合、 PowerShell なら以下のような実装を使います。
Write-Host "##vso[task.logissue type=warning;]<警告メッセージ>" Write-Host "##vso[task.logissue type=error;]<エラーメッセージ>"
このような実装を行っておくと、 Azure Pipelines のサマリーページに以下のようなメッセージを出力できるようになります。
そして、最終的に Windows サービスが停止しなかったときだけ、このタスクを失敗にするよう実装してあります。 タスクを明示的に失敗させたい場合は、以下のように実装します。
Write-Host "##vso[task.complete result=Failed;]"
Windows サービスを開始する YAML テンプレート
続いて Windows サービスを停止するテンプレートを見ていきます。
start-windows-service-template.yml
parameters: - name: windowsServiceName type: string default: '' - name: timeoutSec type: number default: '5' steps: - task: PowerShell@2 displayName: 'パラメーターチェック' inputs: targetType: 'inline' script: | If ( "${{ parameters.windowsServiceName }}" -eq "" ) { Write-Host "##vso[task.logissue type=error;]テンプレートパラメーター \"windowsServiceName\" が指定されていません。" Write-Host "##vso[task.complete result=Failed;]" exit } - task: PowerShell@2 displayName: '${{ parameters.windowsServiceName }} の起動' inputs: targetType: 'inline' script: | $SvcName = '${{ parameters.windowsServiceName }}' $Timeout = New-TimeSpan -Seconds ${{ parameters.timeoutSec }} $SvcCtrl = Get-Service | Where-Object { $_.Name -eq $SvcName } If ($SvcCtrl -eq $null) { Write-Host "##vso[task.logissue type=error;]"$SvcName" という名前の Windows サービスが見つかりません。" Write-Host "##vso[task.complete result=Failed;]" exit } Write-Host "サービス (" $SvcCtrl.Name "|" $SvcCtrl.DisplayName ") が見つかりました ( ステータス:" $SvcCtrl.Status ") 。" try { Write-Host "サービス (" $SvcCtrl.Name "|" $SvcCtrl.DisplayName ") を起動します ( ステータス:" $SvcCtrl.Status ") 。" $SvcCtrl.Start() } catch { Write-Host "##vso[task.logissue type=warning;]"$SvcName" の起動に失敗しました。" Write-Host $_.Exception.ToString() } $SvcCtrl.Refresh() If ($SvcCtrl.Status -eq "StartPending") { Write-Host "サービス (" $SvcCtrl.Name "|" $SvcCtrl.DisplayName ") が起動するのを待機しています ( ステータス:" $SvcCtrl.Status ") 。" try { $SvcCtrl.WaitForStatus("Running", $Timeout) } catch { Write-Host "##vso[task.logissue type=warning;]"$SvcName" が起動するのを待機しましたが、タイムアウトしました。" Write-Host $_.Exception.ToString() } $SvcCtrl.Refresh() } If ($SvcCtrl.Status -eq "Running") { Write-Host "サービス (" $SvcCtrl.Name "|" $SvcCtrl.DisplayName ") が起動しました ( ステータス:" $SvcCtrl.Status ") 。" exit } Else { Write-Host "##vso[task.logissue type=error;]"$SvcName" が起動しませんでした。" Write-Host "サービス (" $SvcCtrl.Name "|" $SvcCtrl.DisplayName ") が起動しませんでした ( ステータス:" $SvcCtrl.Status ") 。" Write-Host "##vso[task.complete result=Failed;]" exit }
Windows サービスの開始処理も、停止処理と同様、ステータスを参照しながら処理を行っています。 そして最終的にサービスが起動したことを確認するまで処理を行っています。
手元で動作確認をした限りでは、 Windows サービスの開始処理を実行し、処理が戻ってきた直後は、ステータスが StartPending
の状態に一瞬なっているようです。
このテンプレートでは、このあたりの動作を反映した実装を行っています。
タイムアウトについて
今回紹介したテンプレートでは、 .NET Framework の ServiceController クラス を使用して Windows サービスの停止/開始処理を行っています。 このクラスを用いて Windows サービスの停止を行った場合、 Windows サービスが停止するのを待たずに処理が返ってきます。 逆に Windows サービスの開始を行った場合、 Windows サービスが開始するのを待ってから処理が返ってきます。 どちらのテンプレートも、パラメーターとしてタイムアウト時間を指定できるようになっていますが、上記の仕様の都合上、タイムアウト時間の意味が異なる点に注意してください。
まとめ
Windows サービスのアプリケーションを Azure Pipelines で配置するために、 Windows サービスの開始/停止を行うテンプレートを作ってみました。 マーケットプレースにも同じようなタスクがいくつか存在しますので、そういったタスクを使うか、このように自前で実装するか、選択していただければと思います。
本稿で解説した YAML について
AzurePipelinesYAMLSample/WindowsServices at master · tsuna-can-se/AzurePipelinesYAMLSample · GitHub