tech.guitarrapc.cóm

Technical updates

Slotを用いたAppService のステージング環境とAzureDevOps PipelineのリリースによるBlueGreen Deployment

AzureのApp ServiceにはSlotがあります。

Slotはただ利用してもそれなりにうれしいのですが、Terraformでの構成とAzure DevOpsのリリースパイプラインでの展開を行えるようにすることで、「CI/CDによるApp ServiceのSlotによる展開前のステージング環境での確認」と「リリースゲートを使った任意でのSwapによるステージング->Production展開」「Azure上のSlot環境の明確な管理」ができるようになります。

AzureをTerraformで展開しておくことで、AppService + Slot構成がDev/Productionの両方で展開でき、運用上の負担も小さいのがいい感じです。 実際にどんな感じで組むのか紹介します。

こういうのよくありますが、実際に設定を含めてどう組めばいいのかまで含めた公開はあんまりされないのでしておきましょう。

www.edmondek.com

TL;DR

Slotを用いることで、本番環境への適用前のステージング環境確認が簡単に行えます。 また、IISなどのAppSettingsの入れ替えに伴う再起動でWebAppsが応答できず500を返すのもSwap入れ替え中のWarm upで解消されます。 Terraform + AzureDevOpsのCI/CDで自動化しつつ組んでみましょう。

ここで注意するべきは、Terraformのazurerm_app_service_active_slotを使ったSwapではないということです。このリソースは制約が大きいので、CDに利用することは避けたほうがいいでしょう。

www.terraform.io

概要

Azure AppServiceにはSlot機能があり、ステージング環境で検証、本番環境にSwapで反映できます。 Azure DevOpsのPipelineでリリース時にDeployとSwapを分けることで、「本番に反映」のタイミングだけ承認を求めて、普段のステージング展開は自動CI/CDを行い開発者はステージング環境で常に確認できます。

想定されるワークフロー

  • Git Commitにより、PRごとにCIが実行
  • CIの成功をtriggerに、CDのdeployタスクにより成果物を常にステージングslotにデプロイ
  • 開発者はAppServiceのステージングスロットを確認することでステージング環境を確認(ここまで毎PRで自動CD)
  • ステージングの動作に問題がなければ、CDのswapのゲートの承認
  • CDはswap承認を受けて、自動的にswapを実行しステージング環境がproductionと入れ替わり本番リリース (Swapによるユーザー影響は明確にリリース制御可能)

App Service Slot と AzureDevOps のリリースタスクによるBlue/Green デプロイ

Azure環境はTerraformで構成されており、AppServiceは各Envごとに自動的に構成されます。

Slotとは

どんな資料をみるよりも公式資料がわかりやすいので見てみましょう。

https://docs.microsoft.com/en-us/azure/app-service/deploy-ステージング-slots

Slotでポイントとなるのは、Slot専用の環境変数を持てることです。

  • Slot環境ではステージングへ接続
  • Slot環境では環境変数をステージングにして、アプリの動作を変える

といったことが可能です。

Slot の用意

ここではTerraformでSlotを用意します。Azure Portalから用意したいからはそちらでどうぞ。

今回はASP.NET Coreで組んでいますが、JavaやGolangも同様です。 Golangなどを使うならLinux Containerがいい感じです。*1

https://www.terraform.io/docs/providers/azurerm/r/app_service_slot.html

このようなWebAppsの構成がある前提です。

resource "azurerm_app_service_plan" "webapps" {
  name                = "prod-plan"
  location            = "${local.location}"
  resource_group_name = "${data.azurerm_resource_group.current.name}"
  kind                = "Windows"
  tags                = "${local.tags}"

  sku {
    tier = "Standard"
    size = "S1"
  }
}

resource "azurerm_app_service" "webapps" {
  name                    = "prod-webapp"
  location                = "${local.location}"
  resource_group_name     = "${data.azurerm_resource_group.current.name}"
  app_service_plan_id     = "${azurerm_app_service_plan.webapps.id}"
  client_affinity_enabled = false
  tags                    = "${local.tags}"

  app_settings {
    ASPNETCORE_ENVIRONMENT                   = "Production"
    APPINSIGHTS_INSTRUMENTATIONKEY           = "${azurerm_application_insights.webapps.instrumentation_key}"
    "ApplicationInsights:InstrumentationKey" = "${azurerm_application_insights.webapps.instrumentation_key}"
    "ConnectionStrings:Storage"              = "${azurerm_storage_account.webapps.primary_connection_string}"
    "ConnectionStrings:LogStorage"           = "${azurerm_storage_account.logs.primary_connection_string}"
  }
  site_config {
    dotnet_framework_version = "v4.0"
    always_on                = "true"
    remote_debugging_enabled = false
    remote_debugging_version = "VS2017"
    http2_enabled            = true
  }
  identity {
    type = "SystemAssigned"
  }
  lifecycle {
    ignore_changes = [
      "app_settings.%",
      "app_settings.MSDEPLOY_RENAME_LOCKED_FILES",
      "app_settings.WEBSITE_NODE_DEFAULT_VERSION",
      "app_settings.WEBSITE_RUN_FROM_PACKAGE",
      "app_settings.WEBSITE_HTTPLOGGING_CONTAINER_URL",
      "app_settings.WEBSITE_HTTPLOGGING_RETENTION_DAYS",
    ]
  }
}

Slotは次の内容で作成してみます。

  • ステージングというSlot名にする
  • ASPNETCORE_ENVIRONMENTをProductionとステージングで入れ替える

この場合、Slot名となるnameステージングapp_settingsASPNETCORE_ENVIRONMENT=ステージングを設定し、他はProductionとなるApp Service設定と同様に組んでおきます。

resource "azurerm_app_service_slot" "ステージング" {
  name                    = "ステージング"
  app_service_name        = "${azurerm_app_service.webapps.name}"
  location                = "${local.location}"
  resource_group_name     = "${data.azurerm_resource_group.current.name}"
  app_service_plan_id     = "${azurerm_app_service_plan.webapps.id}"
  client_affinity_enabled = false
  https_only              = true
  tags                    = "${local.tags}"

  app_settings {
    ASPNETCORE_ENVIRONMENT                   = "ステージング"
    APPINSIGHTS_INSTRUMENTATIONKEY           = "${azurerm_application_insights.webapps.instrumentation_key}"
    "ApplicationInsights:InstrumentationKey" = "${azurerm_application_insights.webapps.instrumentation_key}"
    "ConnectionStrings:Storage"              = "${azurerm_storage_account.webapps.primary_connection_string}"
    "ConnectionStrings:LogStorage"           = "${azurerm_storage_account.logs.primary_connection_string}"
  }
  site_config {
    dotnet_framework_version = "v4.0"
    always_on                = "true"
    remote_debugging_enabled = false
    remote_debugging_version = "VS2017"
    http2_enabled            = true
  }
  identity {
    type = "SystemAssigned"
  }

  lifecycle {
    ignore_changes = [
      "app_settings.%",
      "app_settings.MSDEPLOY_RENAME_LOCKED_FILES",
      "app_settings.WEBSITE_NODE_DEFAULT_VERSION",
      "app_settings.WEBSITE_RUN_FROM_PACKAGE",
      "app_settings.WEBSITE_HTTPLOGGING_CONTAINER_URL",
      "app_settings.WEBSITE_HTTPLOGGING_RETENTION_DAYS",
    ]
  }
}

あとはTerraformを実行することで、Slotが生成され維持されます。

AzureDevOps Pipeline

DevOps Pipelineでリリースパイプラインを組みます。Azure PortalでSwapしたい方はやる必要がありません。

完成図は次の通りです。

リリースパイプラインのステージ構成はDeployとSwapで分割する

  • Deploy: ステージングまでのPackage Deploy
  • Swap: ステージングSlotとProductionをSwap

DeployをSwapを分けることで、実際にProductionが入れ替わるタイミングだけGateをかけることができるようになります。

  • Deployまでを自動CI/CDにして事前動作確認
  • Swapは、Slackやほかの手段でチームの合意があった時だけデプロイ

Deploy

Deployタスクは、WebAppsのSlot環境へのデプロイを行います。

DeployはRun as package の構成まで(app servce in linux ならcliやFTP展開)

デプロイはRun from packageを用いていますが、このRun from zip/Run from packageはWindows Web Appsでのみ有効です。

https://docs.microsoft.com/en-us/azure/devops/pipelines/targets/webapp?view=azure-devops&tabs=yaml

もしLinuxにしたい場合は別の手段を使いましょう。ContainerであればDevOps Pipelineがあります。

https://docs.microsoft.com/en-us/azure/devops/pipelines/apps/cd/deploy-docker-webapp?view=azure-devops

このリリースタスクは、CIでPackageをArtifactに上がっている前提です。 あとは、CD時点の時間を取得、Zip生成、Blobにアップロード、SASを取得してSlotのAppSettingsに埋め込みをすることでRun from Packageが実行できます。

PowerShell Scriptのタスクは次の通りです。time環境変数を作っています。

steps:
- powershell: |
   $date=$([System.DateTimeOffset]::UtcNow.AddHours(9).ToString("yyyyMMddHHmmss"))
   Write-Host "##vso[task.setvariable variable=time]$date"

  displayName: 'PowerShell Script'

Zipタスクは次の通りです。time環境変数の時間をzipのファイル名に利用しています。

steps:
- task: ArchiveFiles@2
  displayName: 'Archive $(System.DefaultWorkingDirectory)/$(RELEASE.PRIMARYARTIFACTSOURCEALIAS)/drop'
  inputs:
    rootFolderOrFile: '$(System.DefaultWorkingDirectory)/$(RELEASE.PRIMARYARTIFACTSOURCEALIAS)/drop'
    includeRootFolder: false
    archiveFile: '$(Release.DefinitionName)-$(time)-$(Release.ReleaseName)-$(Build.SourceVersion).zip'

Blobコピータスクは次の通りです。Storage Blobにアップロードして、storageUri環境変数にURIを取得します。

steps:
- task: AzureFileCopy@1
  displayName: 'AzureBlob File Copy'
  inputs:
    SourcePath: '$(Release.DefinitionName)-$(time)-$(Release.ReleaseName)-$(Build.SourceVersion).zip'
    azureSubscription: YOUR_SUBSCRIPTION
    Destination: AzureBlob
    storage: YOUR_STORAGE_ACCOUNT
    ContainerName: YOUR_STORAGE_CONTAINER
    BlobPrefix: YOUR_STORAGE_BLOB_PREFIX
    outputStorageUri: storageUri

SAS生成タスクは次の通りです。SasTokenをstorageToken変数にかき出しています。

steps:
- task: pascalnaber.PascalNaber-Xpirit-CreateSasToken.Xpirit-Vsts-Release-SasToken.createsastoken@1
  displayName: 'Create SAS Token for Storage Account'
  inputs:
    ConnectedServiceName: YOUR_SUBSCRIPTION
    StorageAccountRM: YOUR_STORAGE_ACCOUNT
    SasTokenTimeOutInHours: 87600
    Permission: 'r'
    StorageContainerName: packages
    outputStorageUri: 'storageUri'
    outputStorageContainerSasToken: 'storageToken'

最後にRun from Packageを行います。前のタスクまでで生成した、time環境変数、アップロードしたstorageUri変数、SasトークンのstorageToken変数を利用します。 ポイントは、slotです。ここで作成しておいたステージングを指定することで、production環境ではなく、ステージングSlotへデプロイします。

steps:
- task: hboelman.AzureAppServiceSetAppSettings.Hboelman-Vsts-Release-AppSettings.AzureAppServiceSetAppSettings@2
  displayName: 'Run from package : Set App Settings'
  inputs:
    ConnectedServiceName: YOUR_SUBSCRIPTION
    WebAppName: 'prod-webapp'
    ResourceGroupName: 'YOUR_RESOURCE_GROUP_NAME'
    Slot: ステージング
    AppSettings: 'WEBSITE_RUN_FROM_PACKAGE=''$(storageUri)/prod/$(Release.DefinitionName)-$(time)-$(Release.ReleaseName)-$(Build.SourceVersion).zip$(storageToken)''

Swap

Deploy後のSwapタスクのpre-deployment conditionで、実行前の条件を付けることができます。 ここで、承認を要するようにすることで、チームメンバーのだれかの承認があったときだけ実行、ということが可能です。

Swap実行前にPre-deployment approval でチームの承認による任意展開をひっかける

タスクを見てみましょう。タスクは簡潔にSwapを行うだけです。

SwapはSlot Swap を実行するだけ

Swapは、Deployタスクでデプロイしたステージング SlotとProductionの入れ替えを行うだけです。

steps:
- task: AzureAppServiceManage@0
  displayName: 'Manage Azure App Service - Slot Swap'
  inputs:
    azureSubscription: '$(Parameters.ConnectedServiceName)'
    WebAppName: '$(Parameters.WebAppName)'
    ResourceGroupName: '$(Parameters.ResourceGroupName)'
    SourceSlot: '$(Parameters.SlotName)'
    SwapWithProduction: True

これでCIが実行されるとReleaseパイプラインで、Deployタスク、承認後にSwapタスクが実行されます。

CIからトリガーしてCD実行完了時の様子

Deployタスク

Deployの各タスクの状態

Swapタスク

Swapの各タスクの状態

Swapはおおよそ60sec - 90secかかりますが、これはAzure RM APIのレスポンスとApp Serviceの制約なので諦めます。

改善点

DeployとSwapを直接でつなぐことで、Deployタスクの完了時に連動するように依存性を組んでいます。しかしタスクを分けたために、DeployとSwapそれぞれでCIの成果物(Artifact) のダウンロード処理が2sec程度かかっています。無視してokなレベルですが、無駄は無駄。

Swapごときに60-90secかかるのなんというか、シカタナイとは言え気持ちは微妙です... Azureの制約にかかるので諦めです。(Slot WarmupとこのSwapの時間により無停止でアプリが展開できるのは皮肉というべきか)

Azure Functionsの場合も、ほぼ同様に行けますが、Durable Functionsでスロットの挙動がおかしい感じです。(Slot変数が展開しないような動き)

Ref

blogs.msdn.microsoft.com

https://docs.microsoft.com/ja-jp/azure/app-service/deploy-ステージング-slotsdocs.microsoft.com

www.azuredevopslabs.com

mikepfeiffer.io

*1:現在、同一Resource GroupでWindows/LinuxのApp Service Planが混在できないので注意が必要ですが!