tech.guitarrapc.cóm

Technical updates

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

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

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

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

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

www.edmondek.com

目次

TL;DR

Slotを用いることで、Prod環境への適用前のStaging環境確認が簡単に行えます。 また、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機能があり、本番に適用する前にStaging環境で検証、Prod環境にSwapで反映できます。 Azure DevOps のPipelineでリリース時にDeployとSwapを分けることで、「本番に反映」のタイミングだけ承認を求めて、普段のStaging展開は自動CI/CD を行い開発者はステージング環境で常に確認できます。

想定されるワークフロー

  • Git Commit により、PRごとにCIが実行
  • CIの成功をtriggerに、CDのdeployタスクにより成果物を常にstaging slot にデプロイ
  • 開発者は AppService の staging slotを確認することでステージング環境を確認(ここまで毎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-staging-slots

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

  • Slot環境ではStaging へ接続
  • Slot環境では環境変数をStagingにして、アプリの動作を変える

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

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 は次の内容で作成してみます。

  • staging というSlot名にする
  • ASPNETCORE_ENVIRONMENT をProductionとStaging で入れ替える

この場合、Slot名となるnamestagingapp_settingsASPNETCORE_ENVIRONMENT=Staging を設定し、他はProductionとなるApp Service設定と同様に組んでおきます。

resource "azurerm_app_service_slot" "staging" {
  name                    = "staging"
  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                   = "Staging"
    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: Staging までのPackage Deploy を行います
  • Swap: Staging 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です。ここで作成しておいたstagingを指定することで、production環境ではなく、staging 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: staging
    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タスクでデプロイしたstaging 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 でちょっと挙動がおかしい感じです。(Slot変数が展開しないような動きが見えるような)

Ref

blogs.msdn.microsoft.com

docs.microsoft.com

www.azuredevopslabs.com

mikepfeiffer.io

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