しばやん雑記

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

Templates を使って Azure Pipelines から複数 App Service へのデプロイを効率化する

現実問題として 1 つのリポジトリに 1 つだけアプリケーションが存在するというケースはあまりなく、大体は複数のアプリケーションをそれぞれデプロイする必要があります。

アプリケーションが 1 つだけであっても、別のリージョンに DR としてデプロイしておく必要があるかも知れませんし、ステージング向けに別スロットにデプロイすることも多いです。

アプリケーションやデプロイ先が異なるといっても、処理自体は同じことの繰り返しなので Templates を使って共通化しておくと、大幅に分かりやすくかつメンテナンス性を保てます。

記法は難しい部分もありますが、非常に柔軟な処理を YAML で書くことが出来ます。処理の共通化が行えるのは勿論のこと、中でも each でループが書けるのはかなり便利です。上手く使えばかなり共通化出来ます。

今回テスト用に Web App と Azure Function を別々のリージョンに作成しました。

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

このリソースに対して、Azure Pipelines から一度にデプロイを実行します。

作成した YAML はベースとなる azure-pipelines.yml 以外に templates 以下にいくつか存在します。

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

デプロイするアプリケーションはそれぞれ FunctionApp1 と WebApplication1 です。単にテンプレートから作成した Azure Function と ASP.NET Core を使ったアプリケーションです。

方針としてはリソースの種類毎にデプロイ処理をテンプレート化しつつ、更にデプロイする環境毎に更にテンプレートを用意することにしました。ベースとなる azure-pipelines.yml では Deployment job で環境毎のテンプレートを参照するだけにしました。

Web App / Function 向けテンプレート

1 つのアプリケーションパッケージを複数の Web Apps / Azure Functions にデプロイする必要があるため、テンプレートのパラメータはアプリ名を配列で受け取ってループでデプロイタスクを生成しています。

ループでタスクを生成すると displayName をちゃんと指定しないと実行ログが分かりにくくなるので、パラメータを使って分かりやすい名前を表示するように設定します。

parameters:
  package: ''
  azureSubscription: ''
  appNames: []
  slotName: ''

steps:
- ${{ each appName in parameters.appNames }}:
  - task: AzureWebApp@1
    inputs:
      azureSubscription: ${{ parameters.azureSubscription }}
      appType: 'webApp'
      appName: ${{ appName }}
      slotName: ${{ parameters.slotName }}
      package: ${{ parameters.package }}
      deploymentMethod: 'runFromPackage'
    displayName: Deploy to ${{ appName }} (${{ coalesce(parameters.slotName, 'production') }})

Web Apps の場合はスロットへのデプロイも行いたかったので、パラメータで受け取るようにしました。未指定の場合は Production にデプロイされるので、必要な場合だけスロット名を指定すれば良い仕組みです。

Azure Functions もほぼ同じテンプレートでタスク名だけが異なっています。こっちはスロットを使わないので少しシンプルになっています。

parameters:
  package: ''
  azureSubscription: ''
  appNames: []

steps:
- ${{ each appName in parameters.appNames }}:
  - task: AzureFunctionApp@1
    inputs:
      azureSubscription: ${{ parameters.azureSubscription }}
      appType: 'functionApp'
      appName: ${{ appName }}
      package: ${{ parameters.package }}
      deploymentMethod: 'runFromPackage'
    displayName: Deploy to ${{ appName }}

両方のテンプレートはデプロイ先の ARM Service Connection が異なる可能性もあるので、パラメータで azureSubscription を受け取るようにしています。大体は環境毎で同じになるはずなので、上位のテンプレートで上手く解決します。

Production 向けテンプレート

各リソースへのデプロイは先ほどのテンプレートで行えるようになりましたが、デプロイというのは環境やリージョンなどで何らかの単位を持っているため、その単位ごとに実際にリソースをデプロイするテンプレートを作成しました。

例えば今回の場合は本番環境向けにデプロイを行いたいため、FunctionApp1 と WebApplication1 のそれぞれを Production 用リソースやスロットへデプロイするように YAML で定義を書きます。

今回の例では FunctionApp1 は East US と West US の両方にデプロイし、WebApplication1 は Japan West にデプロイする必要があるため、以下のような YAML 定義を用意しました。

parameters:
  azureSubscription: 'Azure Sponsorship'

steps:
- template: deploy-function.yml
  parameters:
    package: $(Pipeline.Workspace)/**/FunctionApp1.zip
    azureSubscription: ${{ parameters.azureSubscription }}
    appNames:
    - pipeline-test-westus
    - pipeline-test-eastus

- template: deploy-webapp.yml
  parameters:
    package: $(Pipeline.Workspace)/**/WebApplication1.zip
    azureSubscription: ${{ parameters.azureSubscription }}
    appNames:
    - pipeline-app-japanwest

この時 azureSubscription パラメータのデフォルト値を変数として利用することで、それぞれのタスクに渡す値を共通化しています。

今回は 1 つのテンプレートで複数のリージョンにデプロイを行っていますが、もう少し規模が大きい場合の DR や HA で使う場合には、全く同じリソースをクローンのようにデプロイしているはずなので、リソースの命名規約を利用してリージョン名をパラメータとして受け取ってしまう方法もあります。

parameters:
  azureSubscription: 'Azure Sponsorship'
  regionName: ''

steps:
- template: deploy-function.yml
  parameters:
    package: $(Pipeline.Workspace)/**/FunctionApp1.zip
    azureSubscription: ${{ parameters.azureSubscription }}
    appNames:
    - pipeline-test-${{ parameters.regionName }}

- template: deploy-webapp.yml
  parameters:
    package: $(Pipeline.Workspace)/**/WebApplication1.zip
    azureSubscription: ${{ parameters.azureSubscription }}
    appNames:
    - pipeline-app-${{ parameters.regionName }}

テンプレートを参照する時に、パラメータとして westusjapanwest といったリージョン名を渡せば、命名規約に従って特定のリージョンのリソースのみデプロイが行われます。

デプロイ単位でテンプレートを分けておくのは冗長かと感じるかも知れませんが、過剰な共通化は逆にメンテナンス性を下げるので、多少冗長でも分かりやすさ重視で分けていく方が良いです。

Deployment job からテンプレートを参照

最後にこれまで作成したテンプレートと Deployment job を組み合わせてデプロイを行います。Deployment jobs は何回も使っていますが、内容については例によって以下のエントリを参照してください。

非常に残念なことに Deployment jobs は Kubernetes にしか対応していないので、そのポテンシャルの半分も引き出すことが出来ないですが、App Service に対応しない未来はあり得ないので、使うことにします。

Deployment jobs と組み合わせる Environments はデプロイ単位を定義するための機能なので、今回のテンプレートの分け方と相性は最高です。今回は Production しか用意していないので、以下のように production だけ作成していますが、環境名 + リージョン名といった分け方も当然あり得ます。

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

要するにリソースのグルーピング機能なので、リソースグループと同じような分け方で良いと思います。

今回は Deployment job 自体をテンプレートにはせず、Steps をテンプレートにしたので、以下のような形でテンプレートを参照します。現在は runOnce だけですが、将来的には rollingblueGreen なども追加が予定されているので、かなり便利になるでしょう。

- stage: Deploy
  jobs:
  - deployment: Deploy_to_Prod
    environment: production
    strategy:
      runOnce:
        deploy:
          steps:
          - template: templates/production.yml

Environment に App Service が追加可能になれば、1 リソースへのデプロイあたり 1 つ Deployment job を書くことになるので、Job レベルでテンプレートにすることになります。

それぞれの環境へのデプロイは Stage を分けて対応する方向にもなるかと思います。

多少脱線しましたが、テンプレートは全て準備できたので Azure Pipelines で実行させます。デプロイが成功すると Environment からトラッキング可能なので便利です。

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

Job のログを確認すると、配列で指定したデプロイタスクが展開されていることが確認できます。

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

単純に定義をずらずら上から並べるだけだとあっという間にメンテナンス性が下がってしまうので、上手く規約やテンプレートを利用してメンテナンスしやすい形でパイプラインを維持するのが大切ですね。

その為にはリソースも ARM Template や Terraform を使ってコードで管理するのも重要になってきます。