しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

Azure Pipelines を利用した App Service へのアプリケーションデプロイ戦略

Azure App Service を使っている場合のアプリケーションデプロイ方法をいろいろと極めておきたいと思ったので、実際に考え付いたデプロイ方法を Azure Pipelines を使って実装してみました。

最適な定義になっているかは分からないですし、Azure Pipelines 側で対応してほしい部分もありますが、個人的に確認したい動作は全て調べたかなと思います。

どの方法を使うかはケースバイケースですが、最低でも Deployment slot を使った Swap でのウォームアップとリリースは本番向けではやっておく内容でしょう。

そして Deployment jobs は Approvals and checks を設定できるので便利です。Swap 前にテスト実行を行うことも可能になるので、安定したリリースを実現するのに重要になります。

共通する情報

既に何回も書いてきていますが、Run From Package を使った安全かつ Atomic なデプロイは必須です。

この後に出てくる Pipeline の定義はデプロイに関係する定義のみ書いているので、それ以前のビルドや変数定義などは以下の YAML を参照してください。

Windows Latest なイメージを使って ASP.NET Core 3.1 アプリケーションをデプロイする想定です。

trigger:
- master

variables:
  BuildConfiguration: Release
  AzureSubscription: 'Azure Sponsorship'
  WebAppName: webapp-deploy-1
  ResourceGroupName: 'WebAppDeploy-RG'

stages:
- stage: Build
  jobs:
  - job: Build_App
    pool:
      vmImage: 'windows-latest'
    steps:
    - task: UseDotNet@2
      inputs:
        packageType: 'sdk'
        version: '3.1.x'

    - task: DotNetCoreCLI@2
      inputs:
        command: 'publish'
        publishWebProjects: false
        projects: '**/*.csproj'
        arguments: '-c $(BuildConfiguration) -o ./output'

    - publish: output
      artifact: webapp

ビルドするアプリケーションは適宜読み替えてください。基本は Run From Package でデプロイ可能なパッケージを作成して、そのまま Pipeline Artifact に保存すれば良いです。

Deployment jobs に必要な Environment は以下のように Staging と Production の 2 種類を作成しました。

f:id:shiba-yan:20200421212817p:plain

この後からは Deployment jobs については説明しないのでドキュメントを参照してください。

同様に Environment に設定可能な Approvals and checks についても説明をしないので、ドキュメントや以前に書いたエントリを参照してください。

ここまでの情報を理解すると、この後から出てくる Pipeline 定義などをスムーズに理解できるはずです。

Simple Deployment

最も基本となるのが Zip Deploy を使って Production へ直接デプロイする方法で、非常にシンプルです。Pipelines 自体も Deployment job を使っているので少し定義は多いですが、Task は 1 つしか使っていません。

- stage: Production
  jobs:
  - deployment: Deploy
    environment: production
    strategy:
      runOnce:
        deploy:
          steps:
          - task: AzureWebApp@1
            inputs:
              azureSubscription: '$(AzureSubscription)'
              appType: 'webApp'
              appName: '$(WebAppName)'
              package: '$(Pipeline.Workspace)/**/*.zip'
              deploymentMethod: 'runFromPackage'

この方法ではデプロイ後のアプリケーション再起動時に僅かにダウンタイムが発生するのと、ウォームアップが行われないため初回の応答が遅いという弱点があります。

特に名前が付いているわけではないのですが、最低限のデプロイ方法という感じです。

Blue-Green Deployment

App Service でのデプロイとしては最も一般的なのが Blue-Green でしょう。Deployment slot を使って Swap を実行するだけで実現できるので、意識しないうちに行っているケースもありそうです。

Cloud Services の時から VIP Swap が使われていたので、Azure を使っている人には馴染み深い方法だと思います。しかし App Service では実現方法にいくつかのパターンがあるので、それぞれについて検証しました。

自動で Slot の Swap を行う

Deployment slot を使ったデプロイ方法の基本的な考え方は、まず Staging 用の slot にデプロイした後に Swap を実行して Production 用の slot と入れ替えることです。

- stage: Production
  jobs:
  - deployment: Deploy
    environment: production
    strategy:
      runOnce:
        deploy:
          steps:
          - task: AzureWebApp@1
            inputs:
              azureSubscription: '$(AzureSubscription)'
              appType: 'webApp'
              appName: '$(WebAppName)'
              slotName: 'staging'
              package: '$(Pipeline.Workspace)/**/*.zip'
              deploymentMethod: 'runFromPackage'

        routeTraffic:
          steps:
          - task: AzureAppServiceManage@0
            inputs:
              azureSubscription: '$(AzureSubscription)'
              Action: 'Swap Slots'
              WebAppName: '$(WebAppName)'
              ResourceGroupName: '$(ResourceGroupName)'
              SourceSlot: 'staging'

この例では Staging へのデプロイ後、間髪を入れずに Swap を実行しているので Staging 上での動作確認は行えませんが、Swap 時に App Service がアプリケーションのウォームアップを行ってくれるため、コールドスタートでのパフォーマンス悪化を避けることが出来ます。

本番向けのデプロイでは最低でも Swap を使ったリリースを行うようにしましょう。

Swap 前に Approvals and checks を利用する

良くある要求としては、Swap 前に動作確認を行って問題がなければ本番にリリースしたいというものがあります。Azure Pipelines では Approvals and checks の設定を行うだけで実現できます。

ただし stage 単位での承認やチェックになるので、Staging へのデプロイと Production への Swap は以下のように別の stage として定義する必要があります

- stage: Staging
  jobs:
  - deployment: Deploy
    environment: staging
    strategy:
      runOnce:
        deploy:
          steps:
          - task: AzureWebApp@1
            inputs:
              azureSubscription: '$(AzureSubscription)'
              appType: 'webApp'
              appName: '$(WebAppName)'
              slotName: 'staging'
              package: '$(Pipeline.Workspace)/**/*.zip'
              deploymentMethod: 'runFromPackage'

- stage: Production
  jobs:
  - deployment: Deploy
    environment: production
    strategy:
      runOnce:
        deploy:
          steps:
          - download: none
          - task: AzureAppServiceManage@0
            inputs:
              azureSubscription: '$(AzureSubscription)'
              Action: 'Swap Slots'
              WebAppName: '$(WebAppName)'
              ResourceGroupName: '$(ResourceGroupName)'
              SourceSlot: 'staging'

Production 用の Deployment jobs では Swap だけを実行するようにしています。この時 production の Environment に Approvals 設定を付けておけば、デプロイが Review 待ちで一時停止します。

Staging 環境で Review を行い、Approve を選ぶと停止していたデプロイが再開され、Production に Swap が行われて本番リリースが完了するという流れです。

f:id:shiba-yan:20200421212623p:plain

大抵のケースではこのデプロイ方法で問題ないのではないかと思っています。Azure Pipelines と App Service を組み合わせたデプロイ方法としては非常に一般的な流れです。

Production 設定での Preview を行う

Deployment slot の Swap には事前に Production slot の設定を使って、Staging slot 上でアプリケーションをプレビューする機能もあります。ちょっとマイナーですが、Staging と Production で別の App Settings / Connection Strings を設定している場合に有効です。

実際に Preview 付き Swap を行うためには Slot sticky な設定が必要なので注意が必要です。

- stage: Staging
  jobs:
  - deployment: Deploy
    environment: staging
    strategy:
      runOnce:
        deploy:
          steps:
          - task: AzureWebApp@1
            inputs:
              azureSubscription: '$(AzureSubscription)'
              appType: 'webApp'
              appName: $(WebAppName)
              slotName: 'staging'
              package: '$(Pipeline.Workspace)/**/*.zip'
              deploymentMethod: 'runFromPackage'
          - task: AzureAppServiceManage@0
            inputs:
              azureSubscription: '$(AzureSubscription)'
              Action: 'Start Swap With Preview'
              WebAppName: '$(WebAppName)'
              ResourceGroupName: '$(ResourceGroupName)'
              SourceSlot: 'staging'

- stage: Production
  jobs:
  - deployment: Deploy
    environment: production
    strategy:
      runOnce:
        deploy:
          steps:
          - download: none
          - task: AzureAppServiceManage@0
            inputs:
              azureSubscription: '$(AzureSubscription)'
              Action: 'Complete Swap'
              WebAppName: '$(WebAppName)'
              ResourceGroupName: '$(ResourceGroupName)'
              SourceSlot: 'staging'

- stage: Rollback
  condition: failed('Production')
  jobs:
  - job: Rollback
    steps:
    - task: AzureAppServiceManage@0
      inputs:
        azureSubscription: '$(AzureSubscription)'
        Action: 'Swap Slots'
        WebAppName: '$(WebAppName)'
        ResourceGroupName: '$(ResourceGroupName)'
        SourceSlot: 'staging'

Environment に対して Approvals 設定を入れているので Staging へのデプロイ後に Review が要求されますが、そこで Reject が選ばれると Preview 付きの Swap を中止する必要があります。最後に Rollback stage を Production stage が失敗した時に走るようにしているのはそのためです。

Review で Approve を選べば Swap の完了処理が実行されて、これまで通りウォームアップ後に Production と入れ替わるようになります。ここまでは普通のフローです。

f:id:shiba-yan:20200421212505p:plain

ここで Approve ではなく Reject を選んでみると、Swap の完了処理は実行されませんが Rollback stage が実行されるので Swap のキャンセル処理が実行されています。

f:id:shiba-yan:20200421212517p:plain

このように Preview 付きの Swap では元に戻す処理が必要になりますが、上手く stage と condition を組み合わせることで実現できました。condition は例外ハンドリングに使えるので重要です。

Rolling Update

複数 App Service (マルチリージョン) の場合

App Service はスケールアウトによって複数インスタンスで動作している場合でも、デプロイは全てのインスタンスに対して同時に実行されるので、Rolling Update は HA / DR などでマルチリージョンを使っている場合にのみ選ぶことが出来ます。

複数の App Service がデプロイされている状態なので、それぞれ順番にデプロイしていけば良いことが分かります。愚直に Pipeline を作成してみると以下のようになります。

- stage: Production_japaneast
  jobs:
  - deployment: Deploy
    environment: production
    strategy:
      runOnce:
        deploy:
          steps:
          - task: AzureWebApp@1
            inputs:
              azureSubscription: '$(AzureSubscription)'
              appType: 'webApp'
              appName: '$(WebAppName)-japaneast'
              slotName: 'staging'
              package: '$(Pipeline.Workspace)/**/*.zip'
              deploymentMethod: 'runFromPackage'

        routeTraffic:
          steps:
          - task: AzureAppServiceManage@0
            inputs:
              azureSubscription: '$(AzureSubscription)'
              Action: 'Swap Slots'
              WebAppName: '$(WebAppName)-japaneast'
              ResourceGroupName: '$(ResourceGroupName)'
              SourceSlot: 'staging'

  - job: Delay
    dependsOn: Deploy
    pool: server
    steps:
    - task: Delay@1
      inputs:
        delayForMinutes: '5'

- stage: Production_eastus2
  jobs:
  - deployment: Deploy
    environment: production
    strategy:
      runOnce:
        deploy:
          steps:
          - task: AzureWebApp@1
            inputs:
              azureSubscription: '$(AzureSubscription)'
              appType: 'webApp'
              appName: '$(WebAppName)-eastus2'
              slotName: 'staging'
              package: '$(Pipeline.Workspace)/**/*.zip'
              deploymentMethod: 'runFromPackage'

        routeTraffic:
          steps:
          - task: AzureAppServiceManage@0
            inputs:
              azureSubscription: '$(AzureSubscription)'
              Action: 'Swap Slots'
              WebAppName: '$(WebAppName)-eastus2'
              ResourceGroupName: '$(ResourceGroupName)'
              SourceSlot: 'staging'

少し長いですが、単純にそれぞれのリージョンに対してのデプロイ処理を stage として分割して定義しているだけです。やっていることは単純ですが冗長です。

この Pipeline を実行すると japaneast と eastus2 上の App Service へのデプロイが順番に行われます。

f:id:shiba-yan:20200421235808p:plain

単純に必要な数だけ stage をコピペで増やすのもありですが、Deployment job を動かす各 stage は App Service の名前以外は基本的に共通なので、Template を使うと stage を動的に生成することが出来ます。

以下のようにパラメータとしてデプロイするリージョン名を受け取って、その数だけ Deployment job を持つ stage を生成する Template を用意してみました。

parameters:
- name: locations
  default: []
  type: object

stages:
- ${{ each location in parameters.locations }}:
  - stage: ${{ format('Production_{0}', location) }}
    displayName: ${{ format('Production({0})', location) }}
    jobs:
    - deployment: Deploy
      environment: production
      strategy:
        runOnce:
          deploy:
            steps:
            - task: AzureWebApp@1
              inputs:
                azureSubscription: '$(AzureSubscription)'
                appType: 'webApp'
                appName: ${{ format('$(WebAppName)-{0}', location) }}
                slotName: 'staging'
                package: '$(Pipeline.Workspace)/**/*.zip'
                deploymentMethod: 'runFromPackage'

          routeTraffic:
            steps:
            - task: AzureAppServiceManage@0
              inputs:
                azureSubscription: '$(AzureSubscription)'
                Action: 'Swap Slots'
                WebAppName: ${{ format('$(WebAppName)-{0}', location) }}
                ResourceGroupName: '$(ResourceGroupName)'
                SourceSlot: 'staging'

親の YAML で以下のようにデプロイしたいリージョン名をパラメータとして Template を呼び出すと、指定したリージョン分だけ stage が自動的に生成されます。

- template: deployment.yml
  parameters:
    locations:
    - japaneast
    - eastus2
    - westus
    - northeurope

実際に Terraform を使って App Service を各リージョンに作成してデプロイを試してみました。Template を使って stage を実行時に生成しているので、全体的な Pipeline としては長くなっています。

f:id:shiba-yan:20200422005126p:plain

リージョン毎に stage が生成されてデプロイが実行されていますが、YAML の記述量は最小限に抑えられてメンテナンス性も高まっています。途中で問題があった時にデプロイを停止したいケースがあると思いますが、その場合は Environment の Checks で Azure Monitor の監視処理を追加することで対応できます。

これで各リージョンへ順番に新しいアプリケーションがデプロイされるようになりました。

Canary Release

単一 App Service の場合

App Service の Deployment slot には複数の slot に対するトラフィックを分割する機能が用意されているので、それを利用して新バージョンを一部のユーザーのみに公開することが出来ます。

今回はまず Staging にアプリケーションをデプロイ後、全体の 20% のトラフィックを Staging slot に流すようにして問題ないかを確認し、Swap を行って本番へのリリースを行う方法を選んでみました。

App Service では Swap を行うとトラフィックの設定が自動でクリアされるので、コマンドの実行回数を削減できました。この挙動はドキュメント化されていない気がします。

- stage: Canary
  jobs:
  - deployment: Deploy
    environment: production
    strategy:
      runOnce:
        deploy:
          steps:
          - task: AzureWebApp@1
            inputs:
              azureSubscription: '$(AzureSubscription)'
              appType: 'webApp'
              appName: '$(WebAppName)'
              slotName: 'staging'
              package: '$(Pipeline.Workspace)/**/*.zip'
              deploymentMethod: 'runFromPackage'

        routeTraffic:
          steps:
          - task: AzureCLI@2
            inputs:
              azureSubscription: '$(AzureSubscription)'
              scriptType: 'bash'
              scriptLocation: 'inlineScript'
              inlineScript: 'az webapp traffic-routing set --distribution staging=20 --name $(WebAppName) --resource-group $(ResourceGroupName)'

- stage: Production
  jobs:
  - deployment: Deploy
    environment: production
    strategy:
      runOnce:
        deploy:
          steps:
          - download: none
          - task: AzureAppServiceManage@0
            inputs:
              azureSubscription: '$(AzureSubscription)'
              Action: 'Swap Slots'
              WebAppName: '$(WebAppName)'
              ResourceGroupName: '$(ResourceGroupName)'
              SourceSlot: 'staging'

- stage: Rollback
  condition: failed('Production')
  jobs:
  - job: Rollback
    steps:
    - task: AzureCLI@2
      inputs:
        azureSubscription: '$(AzureSubscription)'
        scriptType: 'bash'
        scriptLocation: 'inlineScript'
        inlineScript: 'az webapp traffic-routing clear --name $(WebAppName) --resource-group $(ResourceGroupName)'

今回も production の Environment に対して Approvals and checks の設定を入れているので、Production への Swap 前にデプロイが一時停止します。Azure Monitor Alerts のチェックを入れて、アラートがアクティブになった場合には失敗する設定も有効でしょう。

Pipeline を実行し、Canary stage の完了後に Deployment slot の設定を確認すると、ちゃんと 20% が Staging slot に流れるような設定になっていることが分かります。

f:id:shiba-yan:20200424011213p:plain

そして問題ないことを確認後に Approve を選んで Swap を実行すると、100% が Production slot に流れる設定に戻ります。Reject を選ぶと Rollback stage が実行されるので、トラフィックは Production に戻ります。

f:id:shiba-yan:20200424011328p:plain

トラフィックルーティング設定は A/B テストにも使えるので、上手く自動化に組み込んでいきたいですね。

上の例では固定で 20% のトラフィックを Staging slot に流すようにしていましたが、もっと段階的にトラフィックを増やしていきたい時もあると思うので、追加で以下のような Pipeline 定義を作成してみました。

Template を使ってパラメータで指定された順番でトラフィックを増やしていく stage を作成します。本当は job にしたかったですが、依存関係の解決が難しかったので stage にしています。

parameters:
- name: steps
  default: []
  type: object
- name: interval
  default: 5
  type: number

stages:
- ${{ each step in parameters.steps }}:
  - stage: ${{ format('Canary_{0}', step) }}
    displayName: ${{ format('Canary({0}%)', step) }}
    jobs:
    - job: Routing
      steps:
      - task: AzureCLI@2
        inputs:
          azureSubscription: '$(AzureSubscription)'
          scriptType: 'bash'
          scriptLocation: 'inlineScript'
          inlineScript: 'az webapp traffic-routing set --distribution staging=${{ step }} --name $(WebAppName) --resource-group $(ResourceGroupName)'

    - job: Delay
      dependsOn: Routing
      pool: server
      steps:
      - task: Delay@1
        inputs:
          delayForMinutes: ${{ parameters.interval }}

- stage: Canary_100
  displayName: 'Canary(100%)'
  jobs:
  - job: Routing
    steps:
    - task: AzureAppServiceManage@0
      inputs:
        azureSubscription: '$(AzureSubscription)'
        Action: 'Swap Slots'
        WebAppName: '$(WebAppName)'
        ResourceGroupName: '$(ResourceGroupName)'
        SourceSlot: 'staging'

- stage: Rollback
  condition: or(canceled(), failed())
  jobs:
  - job: Rollback
    steps:
    - task: AzureCLI@2
      inputs:
        azureSubscription: '$(AzureSubscription)'
        scriptType: 'bash'
        scriptLocation: 'inlineScript'
        inlineScript: 'az webapp traffic-routing clear --name $(WebAppName) --resource-group $(ResourceGroupName)'

定義は複雑に見えますが、やっていることはさっきの 20% 固定になっていた stage を自動生成しているだけです。ただし自動的にトラフィックを増やしていくので、Delay task を使って多少の遅延を入れています。

Template が大きくなった分、Pipeline 定義は非常にシンプルになりました。Template の参照も steps に配列で増やしたい % を指定するだけでなので簡単です。

- stage: Production
  jobs:
  - deployment: Deploy
    environment: production
    strategy:
      runOnce:
        deploy:
          steps:
          - task: AzureWebApp@1
            inputs:
              azureSubscription: '$(AzureSubscription)'
              appType: 'webApp'
              appName: '$(WebAppName)'
              slotName: 'staging'
              package: '$(Pipeline.Workspace)/**/*.zip'
              deploymentMethod: 'runFromPackage'

- template: canary.yml
  parameters:
    steps: [10, 25, 50]

Pipeline を実行すると以下のように、各 stage が自動で生成されていることが確認できるはずです。今回の例では 10% -> 25% -> 50% -> 100% の順に 5 分間隔で新バージョンにトラフィックを流しています。

f:id:shiba-yan:20200427203908p:plain

これまでは手動で実行してきたであろうトラフィックルーティングの設定を、Azure Pipelines を上手く使うことで自動化することが出来ました。

このあたりの定義は Environment が App Service に正式対応されれば、もう少しシンプルに書けるはずなので楽しみに待っていたいと思います。

複数 App Service (マルチリージョン) の場合

複数の App Service に対する Canary Release の場合は、まず特定のリージョンにのみデプロイして検証するという方法が取れるので、考え方としては Rolling Update と同じで、デプロイの順番だけが異なります。

今度は最初から Template を作成しますが、Rolling Update の時は stage に対して書いていたのに対して、Canary Release の場合は job に対して Template を用意します。

parameters:
- name: locations
  default: []
  type: object

jobs:
- ${{ each location in parameters.locations }}:
  - deployment: ${{ format('Deploy_{0}', location) }}
    environment: production
    displayName: ${{ format('Deploy({0})', location) }}
    strategy:
      runOnce:
        deploy:
          steps:
          - task: AzureWebApp@1
            inputs:
              azureSubscription: '$(AzureSubscription)'
              appType: 'webApp'
              appName: ${{ format('$(WebAppName)-{0}', location) }}
              slotName: 'staging'
              package: '$(Pipeline.Workspace)/**/*.zip'
              deploymentMethod: 'runFromPackage'

        routeTraffic:
          steps:
          - task: AzureAppServiceManage@0
            inputs:
              azureSubscription: '$(AzureSubscription)'
              Action: 'Swap Slots'
              WebAppName: ${{ format('$(WebAppName)-{0}', location) }}
              ResourceGroupName: '$(ResourceGroupName)'
              SourceSlot: 'staging'

今回は最初に特定のリージョンだけにデプロイして、新しいバージョンに問題がない場合は残りのリージョンに全てにデプロイしたいので、stage は個別に定義することにします。Checks で自動化も出来ます。

定義としては japaneast にだけ先にデプロイして、問題なければ残りのリージョン全てにデプロイするために、以下のような Pipeline を作成しました。

- stage: Canary
  jobs:
  - template: deployment.yml
    parameters:
      locations:
      - japaneast

- stage: Production
  jobs:
  - template: deployment.yml
    parameters:
      locations:
      - eastus2
      - westus
      - northeurope

- stage: Rollback
  condition: failed('Production')
  jobs:
  - job: Rollback
    steps:
    - task: AzureAppServiceManage@0
      inputs:
        azureSubscription: '$(AzureSubscription)'
        Action: 'Swap Slots'
        WebAppName: '$(WebAppName)-japaneast'
        ResourceGroupName: '$(ResourceGroupName)'
        SourceSlot: 'staging'

実行すると先に Canary stage が実行されて japaneast へのデプロイが行われます。その後は Environment の Approvals を設定しているので Review 待ち状態になります。

f:id:shiba-yan:20200422015622p:plain

問題がないと判断して、Approve を選ぶと残りのリージョン全てにデプロイが行われます。この時、同時にデプロイされるリージョン数は Azure Pipelines の並列実行数に依存します。

f:id:shiba-yan:20200422015836p:plain

この時に問題が発覚してロールバックが必要な場合は Reject を選ぶと、先にデプロイされた japaneast に対して再度 Swap が行われて元のバージョンのアプリケーションに戻ります。

デプロイに問題が発生した時のロールバックを手動運用にしているケースが多いと思いますが、予測出来る範囲で自動化をしておくといろいろと楽です。Azure Pipelines には実現するための機能が用意されています。