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 の両方で展開でき、運用上の負担も小さいのがいい感じです。 実際にどんな感じで組むのか紹介します。
こういうのよくありますが、実際に設定を含めてどう組めばいいのかまで含めた公開はあんまりされないのでしておきましょう。
目次
TL;DR
Slotを用いることで、Prod環境への適用前のStaging環境確認が簡単に行えます。 また、IISなどのAppSettingsの入れ替えに伴う再起動でWebAppsが応答できず500を返すのもSwap入れ替え中のWarm upで解消されます。 Terraform + AzureDevOps のCI/CD で自動化しつつ組んでみましょう。
ここで注意するべきは、Terraform の azurerm_app_service_active_slot を使ったSwapではないということです。このリソースは制約が大きいので、CDに利用することは避けたほうがいいでしょう。
概要
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によるユーザー影響は明確にリリース制御できる)
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名となるnameにstaging
、app_settings にASPNETCORE_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: Staging までのPackage Deploy を行います
- Swap: Staging Slot と Production を Swap します
DeployをSwapを分けることで、実際にProductionが入れ替わるタイミングだけGateをかけることができるようになります。
- Deployまでを自動CI/CDにして事前動作確認
- Swap は、Slackやほかの手段でチームの合意があった時だけデプロイ
Deploy
Deployタスクは、WebApps のSlot環境へのデプロイを行います。
デプロイは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 があります。
このリリースタスクは、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を行うだけです。
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 タスクが実行されます。
Deployタスク
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
*1:現在、同一Resource Group で Windows/Linux のApp Service Plan が混在できないので注意が必要ですが!