AzureのApp ServiceにはSlotがあります。
Slotはただ利用してもそれなりにうれしいのですが、Terraformでの構成とAzure DevOpsのリリースパイプラインでの展開を行えるようにすることで、「CI/CDによるApp ServiceのSlotによる展開前のステージング環境での確認」と「リリースゲートを使った任意でのSwapによるステージング->Production展開」「Azure上のSlot環境の明確な管理」ができるようになります。
AzureをTerraformで展開しておくことで、AppService + Slot構成がDev/Productionの両方で展開でき、運用上の負担も小さいのがいい感じです。 実際にどんな感じで組むのか紹介します。
こういうのよくありますが、実際に設定を含めてどう組めばいいのかまで含めた公開はあんまりされないのでしておきましょう。
TL;DR
Slotを用いることで、本番環境への適用前のステージング環境確認が簡単に行えます。 また、IISなどのAppSettingsの入れ替えに伴う再起動でWebAppsが応答できず500を返すのもSwap入れ替え中のWarm upで解消されます。 Terraform + AzureDevOpsのCI/CDで自動化しつつ組んでみましょう。
ここで注意するべきは、Terraformのazurerm_app_service_active_slotを使ったSwapではないということです。このリソースは制約が大きいので、CDに利用することは避けたほうがいいでしょう。
概要
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によるユーザー影響は明確にリリース制御可能)
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_settings にASPNETCORE_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: ステージングまでのPackage Deploy
- Swap: ステージング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です。ここで作成しておいたステージング
を指定することで、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を行うだけです。
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タスクが実行されます。
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変数が展開しないような動き)
Ref
https://docs.microsoft.com/ja-jp/azure/app-service/deploy-ステージング-slotsdocs.microsoft.com
*1:現在、同一Resource GroupでWindows/LinuxのApp Service Planが混在できないので注意が必要ですが!