ツナ缶雑記

ぐうたらSEのブログです。主にマイクロソフト系技術を中心に扱います。

Azure Pipelines で Windows サービスの停止/開始を安全に行うテンプレートを作る

f:id:masatsuna:20200508164333p:plain

Windows サービスをデプロイするリリースパイプラインを作ろうとすると、リリースパイプライン内で Windows サービスを停止したり開始したりする必要があります。 Windows サービスは常駐プロセスなので、一度停止しないとモジュールの交換ができません。 ということで、今回は Windows サービスの停止/開始を行う Azure Pipelines の YAML テンプレートのご紹介です。

環境

前提条件

今回紹介するビルド/リリースパイプラインは、インストール済みの Windows サービスを置き換えるためのパイプラインです。 初回の配置は手動で行うことを前提としています。

またリリース先の VM は、 Environments に登録しておいてください。 Environment の登録については以下の記事を参考にして下さい。

tsuna-can.hateblo.jp

リリースパイプラインの全体像

まずはリリースパイプラインの本体を作っていきます。 まずは全体を先に示しておきます。

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 サービスを配置する場合は、 preDeployon をうまく活用して、 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 のサマリーページに以下のようなメッセージを出力できるようになります。

f:id:masatsuna:20200508125758p:plain
エラーメッセージ

f:id:masatsuna:20200508125737p:plain
警告メッセージ

そして、最終的に 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 について

原本は以下の GitHub リポジトリに公開してあります。

AzurePipelinesYAMLSample/WindowsServices at master · tsuna-can-se/AzurePipelinesYAMLSample · GitHub