Terraform, Azure DevOps, App Services, and Slots
Background
This post is going to cover a lot achieving the swapping of slots via Azure DevOps tasks and YAML templates! Essentially wanted to go over how to deploy application code to an App Service slot, created by Terraform, then leverage Azure DevOps (ADO) tasks to perform the swap between the slot and production. And let’s do this all leveraging ADO YAML…..with templates to boot!
Slots
What is a slot? Hear is the Microsoft documentation on slots. Essentially a slot is a parallel version, in this case App Service. This second version shared such components as the App Service Plan just with the slot name appended to the URL. However, and this is important that everyone seems to forget, the slot has a different identity.
This means that if leveraging Role Based Access Control will need to be set up twice, once for production and once for the slot. I always recommend what is the justification for using slots. Are you looking at rolling out canary testing (sending x % of your traffic to a different site to test new features)? Perform testing in production (really don’t advise this, though there are valid reasons)? Or are you just wanting to lower downtime (avoid the blip of the site being unavailable during code deployment? In our case it will be the later as it’s the simplest.
Terraform
The Terraform is going to be the easiest part of this equation. Here is the Hashicorp documentation on slots for Azure. Since we leveraging for zero downtime, we don’t need to get fancy with app settings, network configuration, nor identities.
You may have noticed that there is an option to have Terraform perform the promotion of a slot to production. We will not cover this as this complicates state management as we’d have to deploy Terraform to create the slot, deploy the App Service, deploy the second Terraform code to perform the swap. This really isn’t conducive to a CI/CD pipeline.
To create the slot just declare it like:
resource "azurerm_app_service_slot" "slot" {
name = "staging"
app_service_name = azurerm_app_service.app_service.name
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
app_service_plan_id = azurerm_app_service_plan.app_service_plan.id
https_only = true
}
In the above code we are assuming the App Service, App Service Plan, and Resource Group all contained within the same Terraform project.
Code Deployment
For deploying the code we will be leveraging version 4 of the AzureRMWebAppDeployment task. In order to deploy to a slot there are a few settings which may different from how the AzureRMWebAppDeployment task works for regular App Service deployments.
We want to set DeployToSlotOrASEFlag
to true. This indicates we are doing a slot deployment. Then, since this is a slot, we need to provide the task with a value for SlotName
. Lastly for all slot deployments a ResourceGroupName
needs to be provided. The end result in a highly templated task format will look like:
parameters:
azureSubscription: ''
webAppName: ''
takeAppOfflineFlag: true
packagePath: ''
appSettings: ''
slotName: 'staging'
resourceGroupName: ''
steps:
- task: AzureRmWebAppDeployment@4
inputs:
azureSubscription: ${{ parameters.azureSubscription }}
WebAppName: ${{parameters.webAppName }}
deployToSlotOrASE: true
SlotName: ${{ parameters.slotName }}
Package: ${{ parameters.packagePath }}
TakeAppOfflineFlag: ${{ parameters.takeAppOfflineFlag }}
appSettings: ${{ parameters.appSettings }}
ResourceGroupName: ${{ parameters.resourceGroupName}}
I’ve done some liberty on parameter
handling and defaulting where appropriately.
Slot Promotion
Currently we have the infrastructure handled for slots and the code deploying to a slot; however, we are still missing the actual swapping of the slots. We will be handling this via the AzureAppServiceManage task version 0.
For this task since we are swapping with production we will not need to override the default swapWithProduction
flag nor provide a value for the targetSlot
. However; we will still need to provide a sourceSlot
value and resourceGroupName
. Again a heavily templatized version of this task may look like:
parameters:
azureSubscription: ''
webAppName: ''
resourceGroupName: ''
slotname: 'staging'
steps:
- task: AzureAppServiceManage@0
displayName: 'Swap ${{parameters.webAppName }}'
inputs:
ConnectedServiceName: ${{ parameters.azureSubscription }}
WebAppName: ${{parameters.webAppName }}
ResourceGroupName: ${{parameters.resourceGroupName }}
SourceSlot: ${{parameters.slotname}}
One More Thing….
We don’t have to; however, the thought occurred to stop the swap after it has been completed. This makes logical sense since we are leveraging it just for zero downtime so why not ensure nothing is running rogue and just turn it off after completion.
To achieve this we will use the same AzureAppServiceManage task version 0, just with a different action. This may look like:
parameters:
azureSubscription: ''
webAppName: ''
resourceGroupName: ''
specifySlotOrASE: true
slotname: 'staging'
action: 'Start Azure App Service'
steps:
- task: AzureAppServiceManage@0
displayName: '${{ parameters.action }} ${{parameters.webAppName }}'
inputs:
ConnectedServiceName: ${{ parameters.azureSubscription }}
Action: ${{parameters.action }}
WebAppName: ${{parameters.webAppName }}
SpecifySlotOrASE: ${{parameters.specifySlotOrASE }}
ResourceGroupName: ${{parameters.resourceGroupName }}
Slot: ${{parameters.slotname}}
Note the default on this on was ‘Start Azure App Service’. This is defaulted as least impact if something goes wrong or a the paremter isn’t properly passed. The net result will be an App Service running vs inadvertently stopping something in a production environment.
Stitching It Together
This is a lot, so how does it come together? I am going to gloss over the Terraform components. For that review my post of Azure DevOps Terraform Task. Also it may not hurt to review my thoughts on MultiStage YAML Pipelines with Templates
So the Tasks….
appservice-manage-task.yml
parameters:
azureSubscription: ''
webAppName: ''
resourceGroupName: ''
specifySlotOrASE: true
slotname: 'staging'
action: 'Start Azure App Service'
steps:
- task: AzureAppServiceManage@0
displayName: '${{ parameters.action }} ${{parameters.webAppName }}'
inputs:
ConnectedServiceName: ${{ parameters.azureSubscription }}
Action: ${{parameters.action }}
WebAppName: ${{parameters.webAppName }}
SpecifySlotOrASE: ${{parameters.specifySlotOrASE }}
ResourceGroupName: ${{parameters.resourceGroupName }}
Slot: ${{parameters.slotname}}
appservice-manage-swap-slots-task.yml
parameters:
azureSubscription: ''
webAppName: ''
resourceGroupName: ''
slotname: 'staging'
steps:
- task: AzureAppServiceManage@0
displayName: 'Swap ${{parameters.webAppName }}'
inputs:
ConnectedServiceName: ${{ parameters.azureSubscription }}
WebAppName: ${{parameters.webAppName }}
ResourceGroupName: ${{parameters.resourceGroupName }}
SourceSlot: ${{parameters.slotname}}
webapp-deploy-slot-task-.yml
parameters:
azureSubscription: ''
webAppName: ''
takeAppOfflineFlag: true
packagePath: ''
appSettings: ''
slotName: 'staging'
resourceGroupName: ''
steps:
- task: AzureRmWebAppDeployment@4
inputs:
azureSubscription: ${{ parameters.azureSubscription }}
WebAppName: ${{parameters.webAppName }}
deployToSlotOrASE: true
SlotName: ${{ parameters.slotName }}
Package: ${{ parameters.packagePath }}
TakeAppOfflineFlag: ${{ parameters.takeAppOfflineFlag }}
appSettings: ${{ parameters.appSettings }}
ResourceGroupName: ${{ parameters.resourceGroupName}}
appservice-deploy-swap-job.yml
parameters:
- name: environmentName
type: string
default: 'dev'
- name: webAppName
type: string
- name: resourceGroupName
type: string
default: ''
- name: slotName
type: string
default: 'staging'
- name: dependsOn
type: object
default: []
- name: packagePath
type: string
default: ''
jobs:
- deployment: swap_${{ parameters.environmentName }}
dependsOn: ${{ parameters.dependsOn }}
displayName: 'Swap Slots for ${{parameters.webAppName }}'
variables:
- template: ../variables/azure.${{ parameters.environmentName }}.yml
environment: ${{ parameters.environmentName }}
strategy:
runOnce:
deploy:
steps:
- template: ../tasks/webapp-deploy-slot-task.yml
parameters:
azureSubscription: ${{ variables.AzureSubscriptionServiceConnectionName}}
webAppName: ${{parameters.webAppName }}
packagePath: ${{ parameters.packagePath }}
resourceGroupName: ${{parameters.resourceGroupName}}
- template: ../tasks/appservice-manage-task.yml@YAMLTemplates
parameters:
azureSubscription: ${{ variables.AzureSubscriptionServiceConnectionName}}
action: Start Azure App Service
webAppName: ${{parameters.webAppName }}
resourceGroupName: ${{parameters.resourceGroupName }}
slot: ${{parameters.slotName }}
- template: ../tasks/appservice-manage-swap-slots-task.yml@YAMLTemplates
parameters:
azureSubscription: ${{ variables.AzureSubscriptionServiceConnectionName}}
webAppName: ${{parameters.webAppName }}
resourceGroupName: ${{parameters.resourceGroupName }}
slot: ${{parameters.slotName }}
- template: ../tasks/appservice-manage-task.yml@YAMLTemplates
parameters:
azureSubscription: ${{ variables.AzureSubscriptionServiceConnectionName}}
action: Stop Azure App Service
webAppName: ${{parameters.webAppName }}
resourceGroupName: ${{parameters.resourceGroupName }}
slot: ${{parameters.slotName }}
tf_appservice_deploy_swap_slot_stage.yml
parameters:
- name: serviceName
type: string
- name: environmentName
type: string
- name: terraformVersion
type: string
- name: webAppName
type: string
- name: resourceGroupName
type: string
- name: projectName
type: string
stages:
- stage: ${{ parameters.serviceName }}_${{ parameters.environmentName}}
jobs:
- template: ../jobs/tf-deploy-job.yml@YAMLTemplates
parameters:
environmentName: ${{ parameters.environmentName}}
serviceName: ${{ parameters.serviceName}}
terraformVersion: ${{ parameters.terraformVersion}}
- template: ../jobs/appservice-deploy-swap-job.yml@YAMLTemplates
parameters:
environmentName: ${{ parameters.environmentName}}
webAppName: ${{ parameters.webAppName}}
resourceGroupName: ${{parameters.resourceGroupName}}
dependsOn: ['terraformApply${{ parameters.environmentName }}']
packagePath: '$(Pipeline.Workspace)/drop/${{ parameters.projectName }}.zip'
Conclusion
That’s it, just like a firehose! Realize I didn’t include every single step in this (mainly the Terraform, and the pipeline.yml as well as the variable files ). Reason behind this is time as well as these are just the Lego blocks used to build new pipelines, thus optimizing reusability.
Hopefully this is enough to get you started on leveraging bits and pieces in your existing environments!
“occurred to stop the swap after it ”
typo think should be “occurred to stop the slot after it ”
woulda been good to explore slot settings