---
title: Deploy Azure Function App without downtime
date: 2020-10-29 21:53:00 +0200 +0200
draft: false
author: John Roos
----

Deploying a function app without downtime should be standard practice for anyone working with Azure Functions, but it does not come out of the box. It requires an understanding of how function apps gets configured, how deployment slots can help and why warmup does not necessarily mean your solution is up and running. In this post I will go through how this can be achieved using CD-pipelines in Azure DevOps.

Defining the problem

First we need to set the stage. What is the problem we are trying to solve? Imagine you have a Function App running PowerShell. In this app you might have a profile.ps1 containing quite a lot of code which need to be processed during a cold start. Or you might need to import large modules which causes all functions to wait until the modules are loaded. After deploying a new version you will experience slow response times or even timeouts while the profile or modules are loading. You want to be able to seamlessly deploy new versions of the app using a continuous deployment mindset without users ever noticing.

What you need

To be able solve this problem using the examples in this post there are a few things you need.

  • Possibility to create a Function App with Standard, Premium, or Isolated App Service plan tier. Deployment slots are not for free.
  • Possibility to run Azure DevOps Pipelines for deployment.
  • Az PowerShell module
  • Basic knowledge about PowerShell, YAML, Azure Function Apps and Azure DevOps

Create the function app in Azure

Later in this post we are going to use Deployment Slots which requires Standard, Premium, or Isolated App Service plan tier. In this example I have selected EP1. Change the variables to your liking before running.

# Create Function App

# Variables
$Location           = 'North Europe'
$ResourceGroupName  = "myresourcegroup"
$StorageAccountName = "mystorageaccount"
$FunctionPlanName   = "myfunctionplan"
$FunctionAppName    = "myfunction"

# Create resource group
New-AzResourceGroup -Name $ResourceGroupName `
                    -Location $Location

# Create storage account
New-AzStorageAccount -Name $StorageAccountName `
                     -ResourceGroupName $ResourceGroupName `
                     -Location $Location `
                     -SkuName 'Standard_LRS'

# Create function app plan
New-AzFunctionAppPlan -Name $FunctionPlanName `
                      -ResourceGroupName $ResourceGroupName `
                      -Location $Location `
                      -Sku 'EP1' `
                      -WorkerType 'Windows'

# Create function app
New-AzFunctionApp -Name $FunctionAppName `
                  -ResourceGroupName $ResourceGroupName `
                  -StorageAccountName $StorageAccountName `
                  -Runtime 'PowerShell' `
                  -RuntimeVersion '7.0' `
                  -PlanName $FunctionPlanName `
                  -FunctionsVersion '3' `
                  -OSType 'Windows'

Since the function app is running on EP1, don’t forget to remove it when you are done. It costs even when you are not using it and even when its turned off.

Function app code

For this scenario I have created a very simple Function App. It contains one HTTP triggered function and a modified profile.

Status endpoint

When this function (or any function) is triggered the profile will run before the endpoint starts to execute. The status endpoint returns a predefined string. I will update this string during every example to show the difference when a new version is deployed.

# status\run.ps1
using namespace System.Net
param($Request, $TriggerMetadata)

$body = @{
    Message = "Hi, I'm running version 1"
}
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
    StatusCode = [HttpStatusCode]::OK
    Body = $body
})

Profile

I removed everything in the default profile.ps1 and replaced it with Start-Sleep to simulate anything that might take a long time to load.

# profile.ps1
Start-Sleep -Seconds 30

That’s the whole app. One HTTP triggered function and a slow profile.

Deployment from VSCode

To get things started, lets do the first deploy from VSCode. Deploy using the Azure Functions extension. If you haven’t used this extension, I highly recommend it. It has a lot of really nice features for working with Azure Functions. I will not describe how to create and deploy an Azure Function App in this post. If you haven’t deployed a function app before, you can follow this guide.

Verification script

To be able to test that the endpoint we just deployed works during deployments, I wrote this short script which will simply call the status endpoint in a loop until canceled. During every deployment we will have this script running to monitor for timeouts. In the end we want this script to return a good response every few seconds during a deployment, without warnings, until the new version of the app is up and running. Run the script now to make sure your newly deployed app is working. Change the $uri variable to the Uri for your function.

# Verification.ps1
$uri = 'https://myfunction.azurewebsites.net/api/status' # Uri to status function

while ($true) {
    try {
        $response = Invoke-RestMethod -Uri $uri -TimeoutSec 10
        Write-Output $response.message
    }
    catch {
        Write-Warning 'No response'
    }
    Start-Sleep -Seconds 1
}

If everything has worked as expected, you should first see a few timeouts and then it should start responding. This is because we put Start-Sleep in the profile.

...
WARNING: No response
WARNING: No response
WARNING: No response
Hi, I'm running version 1!
Hi, I'm running version 1!
Hi, I'm running version 1!
...

Before every deployment in this post, I will change the version number on the status function to make sure this script shows the change during deployment.

Standard CD deployment

Now we have the first version deployed. What happens if we try to deploy a new version using a Continuous Deployment (CD) pipeline? We can keep the verification script running during the deploy to see what happens. In this scenario we will do a regular Function App deployment from Azure DevOps.

Deploy with Azure DevOps pipeline

Below you can see the YAML which can be used in a Azure DevOps pipeline. We don’t really care about the Build stage for this scenario, but its included for completeness. Change the variables in the top to suit your environment.

trigger:
- master

variables:
  # Azure Resource Manager connection
  azureSubscription: 'Name of your Azure RM connection'

  # Function app name
  functionAppName: 'myfunction'

  # Agent VM image name
  vmImageName: 'vs2017-win2016'

  # Working Directory
  workingDirectory: '$(System.DefaultWorkingDirectory)/'
  
  # Resource Group
  resourceGroup: 'myresourcegroup'

stages:
- stage: Build
  displayName: Build stage

  jobs:
  - job: Build
    displayName: Build
    pool:
      vmImage: $(vmImageName)

    steps:
    - task: ArchiveFiles@2
      displayName: 'Archive files'
      inputs:
        rootFolderOrFile: $(workingDirectory)
        includeRootFolder: false
        archiveType: zip
        archiveFile: $(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip
        replaceExistingArchive: true

    - publish: $(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip
      artifact: drop
      displayName: 'Publish artifact'

- stage: Deploy
  displayName: Deploy stage
  dependsOn: Build
  condition: succeeded()

  jobs:
  - deployment: Deploy
    displayName: Deploy
    environment: $(functionAppName)
    pool:
      vmImage: $(vmImageName)

    strategy:
      runOnce:
        deploy:

          steps:
          - task: AzureFunctionApp@1
            displayName: 'Azure functions app deploy'
            inputs:
              azureSubscription: '$(azureSubscription)'
              appType: functionApp
              appName: $(functionAppName)
              package: '$(Pipeline.Workspace)/drop/$(Build.BuildId).zip'
              resourceGroupName: $(resourceGroup)
              deploymentMethod: 'auto'

Verification

To test how this will affect uptime, first start the verification script above. Change the version in the status function to version 2. Push the changes to trigger a deploy. During deployment the verification script should return something like this:

...
Hi, I'm running version 1!
Hi, I'm running version 1!
Hi, I'm running version 1!
WARNING: No response
WARNING: No response
WARNING: No response
WARNING: No response
Hi, I'm running version 2!
Hi, I'm running version 2!
Hi, I'm running version 2!
...

This is not what we want. During deployment the app restarted. After the restart it imported modules if there were any in the requirements file (not used in this example). When the status function was triggered by the verification script, profile.ps1 executed. When profile.ps1 was done executing, the status function could start to run its code.

This resulted in a few timeouts and the uptime was affected. We should be able to solve this using Deployment Slots.

Deployment slots

Imagine your app is running on a web server, like IIS. Your app is just a website on that server. If you add another slot, that would be the same as adding another website. The new website has its own URL and settings. If you at any time would like to switch between these websites you can simply change the binding to make the new site use the old sites URL. This is fairly close to how deployment slots work.

Note that deployment slots share processors, so if you put heavy load on one slot it will affect the others.

Slot swap

You can use deployment slots to deploy new versions of your app, test that the new version is working as expected, and then swap them to avoid downtime. If you then notice that something is not working as expected you can swap back to the previous slot which has the old version.

The different stages of a slot swap

There are a few things that happens behind the scenes during a slot swap. Here is a simplified version of the process:

  1. Apply app settings from target slot to source slot. (triggers a restart)
  2. Wait for source slot to restart.
  3. Trigger local cache initialization, if needed.
  4. Trigger application initiation, if needed
  5. Wait for source slot to warm up
  6. Swap slots by switching the routing rules (can trigger a restart)

More detailed information about these stages can be found here.

Add a new slot to the function app

To add a new slot to a function app, run the following command.

# Add staging slot
New-AzWebAppSlot -ResourceGroupName $ResourceGroupName `
                 -Name $FunctionAppName `
                 -Slot 'staging'

I used the name “staging”, but you can use any name you want.

Deploy to slot with Azure DevOps pipeline

Now its time to see if a Slot swap could help us get closer to the “no downtime” goal. First we need to set the name of the slot we want to deploy to. In this scenario I have named it “staging” and I added it as a variable. The second part we need to do is to tell the deployment to deploy to a slot (deployToSlotOrASE is set to true) and then give it the name of the slot, using the variable.

When the deploy stage is configured, we need to add the slot swap configuration. I like to add that as a separate stage to make it easier to read both in the YAML and later in Azure DevOps. The “Swap slots” need to know which slot it should swap with production. We use the variable here again.

trigger:
- master

variables:
  # Azure Resource Manager connection
  azureSubscription: 'Name of your Azure RM connection'

  # Function app name
  functionAppName: 'myfunction'

  # Agent VM image name
  vmImageName: 'vs2017-win2016'

  # Working Directory
  workingDirectory: '$(System.DefaultWorkingDirectory)/'
  
  # Resource Group
  resourceGroup: 'myresourcegroup'
  
  # Source Slot
  sourceSlot: 'staging'

stages:
# Build stage omitted

- stage: Deploy
  displayName: Deploy stage
  dependsOn: Build
  condition: succeeded()

  jobs:
  - deployment: Deploy
    displayName: Deploy
    environment: $(functionAppName)
    pool:
      vmImage: $(vmImageName)

    strategy:
      runOnce:
        deploy:

          steps:
          - task: AzureFunctionApp@1
            displayName: 'Azure functions app deploy'
            inputs:
              azureSubscription: '$(azureSubscription)'
              appType: functionApp
              appName: $(functionAppName)
              package: '$(Pipeline.Workspace)/drop/$(Build.BuildId).zip'
              deployToSlotOrASE: true
              resourceGroupName: $(resourceGroup)
              slotName: $(sourceSlot)
              deploymentMethod: 'auto'

- stage: SlotSwap
  displayName: Slot Swap stage
  dependsOn: Deploy
  condition: succeeded()

  jobs:
  - deployment: SlotSwap
    displayName: Slot Swap
    environment: $(functionAppName)
    
    strategy:
      runOnce:
        deploy:

          steps:
          - task: AzureAppServiceManage@0
            displayName: 'Swap Slots'
            inputs:
              azureSubscription: '$(azureSubscription)'
              Action: 'Swap Slots'
              WebAppName: '$(functionAppName)'
              ResourceGroupName: $(resourceGroup)
              SourceSlot: $(sourceSlot)

Now we have three stages in the pipeline. One for building the artifact, one for deploying the artifact and one for swapping slots. That should do it, right?

Slot swap result

Change the version in the status function to version 3, run the verification script, push the changes to master. After a while you should get something like this:

...
Hi, I'm running version 2!
Hi, I'm running version 2!
WARNING: No response
WARNING: No response
Hi, I'm running version 3!
Hi, I'm running version 3!
...

We still get downtime. Didn’t Microsoft say that swapping slots would be the solution to our problem? Well, its one part of the solution. When you deploy a new version of the app on the staging slot, the app on the staging slot will restart. After the restart it will wait for the app to warm up. However, profile.ps1 is not part of the warmup process so when we call the status endpoint, it has to run profile.ps1 before executing its own code and return a response.

Deployment slots with post-warmup script before slot swap

So if the profile.ps1 is not part of the warmup we need to have a way of executing it before we do the swap. We can do this by writing a small script which will be included in the YAML and executed before the slot swap. The script will attempt to call the HTTP triggered function until it responds. When it finally responds we know that the profile has executed. In the YAML we add a PowerShell task with an inline script, before the swap. The URL for the source slot endpoint has been added as a variable as well.

trigger:
- master

variables:
  # Azure Resource Manager connection
  azureSubscription: 'Name of your Azure RM connection'

  # Function app name
  functionAppName: 'myfunction'

  # Agent VM image name
  vmImageName: 'vs2017-win2016'

  # Working Directory
  workingDirectory: '$(System.DefaultWorkingDirectory)/'
  
  # Resource Group
  resourceGroup: 'myresourcegroup'
  
  # Source Slot
  sourceSlot: 'staging'

  # Source Slot endpoint
  statusEndpointUri: 'https://myfunction-staging.azurewebsites.net/api/status'

stages:
# Build stage omitted

- stage: Deploy
  displayName: Deploy stage
  dependsOn: Build
  condition: succeeded()

  jobs:
  - deployment: Deploy
    displayName: Deploy
    environment: $(functionAppName)
    pool:
      vmImage: $(vmImageName)

    strategy:
      runOnce:
        deploy:

          steps:
          - task: AzureFunctionApp@1
            displayName: 'Azure functions app deploy'
            inputs:
              azureSubscription: '$(azureSubscription)'
              appType: functionApp
              appName: $(functionAppName)
              package: '$(Pipeline.Workspace)/drop/$(Build.BuildId).zip'
              deployToSlotOrASE: true
              resourceGroupName: $(resourceGroup)
              slotName: $(sourceSlot)
              deploymentMethod: 'auto'

- stage: SlotSwap
  displayName: Slot Swap stage
  dependsOn: Deploy
  condition: succeeded()

  jobs:
  - deployment: SlotSwap
    displayName: Slot Swap
    environment: $(functionAppName)
    
    strategy:
      runOnce:
        deploy:

          steps:
          - task: PowerShell@2
            displayName: 'Wait for endpoint to respond'
            inputs:
              targetType: 'inline'
              script: | 
                Do {
                  try {
                    $response = Invoke-RestMethod -Uri '$(statusEndpointUri)' -TimeoutSec 5
                  } catch {}
                  Start-Sleep -Seconds 2
                } 
                while (-not $response)
              pwsh: true
          - task: AzureAppServiceManage@0
            displayName: 'Swap Slots'
            inputs:
              azureSubscription: '$(azureSubscription)'
              Action: 'Swap Slots'
              WebAppName: '$(functionAppName)'
              ResourceGroupName: $(resourceGroup)
              SourceSlot: $(sourceSlot)

The inline script could also output status messages to the console to make it easier to follow in the log, but I haven’t included that here.

Verification

When the above YAML code has been saved, update the version in the status endpoint to version 4, push it to master, and start the verification script.

...
Hi, I'm running version 3!
Hi, I'm running version 3!
Hi, I'm running version 3!
WARNING: No response
WARNING: No response
WARNING: No response
WARNING: No response
Hi, I'm running version 4!
Hi, I'm running version 4!
...

This still doesn’t look good. Why is that? If you scroll back up to where I wrote about the different stages of a slot swap you can see that the first thing a slot swap does is to apply settings from the production slot to the staging slot. This triggers a restart. So we need to have a way to run the inline script after the settings have been applied and the app has restarted.

Deployment slots with preview and post-warmup script

You can have some control over how a slot swap is done. There is a feature called “Swap with preview” or “Multi-phase swap” which starts the swapping process, but does not complete it. Swap with preview will begin a swap operation, but will stop after the first stage (copying settings etc.). You can then do whatever you need to the app before completing the swap. Here is a good opportunity for us to add our post-warmup script.

During the Slot Swap stage, we first run a ‘Start Swap With Preview’ action, then we execute the script which will trigger the status function (and profile.ps1). Finally we complete the swap operation by doing a ‘Swap Slots’ action.

trigger:
- master

variables:
  # Azure Resource Manager connection
  azureSubscription: 'Name of your Azure RM connection'

  # Function app name
  functionAppName: 'myfunction'

  # Agent VM image name
  vmImageName: 'vs2017-win2016'

  # Working Directory
  workingDirectory: '$(System.DefaultWorkingDirectory)/'
  
  # Resource Group
  resourceGroup: 'myresourcegroup'
  
  # Source Slot
  sourceSlot: 'staging'

  # Source Slot endpoint
  statusEndpointUri: 'https://myfunction-staging.azurewebsites.net/api/status'

stages:
# Build stage omitted

- stage: Deploy
  displayName: Deploy stage
  dependsOn: Build
  condition: succeeded()

  jobs:
  - deployment: Deploy
    displayName: Deploy
    environment: $(functionAppName)
    pool:
      vmImage: $(vmImageName)

    strategy:
      runOnce:
        deploy:

          steps:
          - task: AzureFunctionApp@1
            displayName: 'Azure functions app deploy'
            inputs:
              azureSubscription: '$(azureSubscription)'
              appType: functionApp
              appName: $(functionAppName)
              package: '$(Pipeline.Workspace)/drop/$(Build.BuildId).zip'
              deployToSlotOrASE: true
              resourceGroupName: $(resourceGroup)
              slotName: $(sourceSlot)
              deploymentMethod: 'auto'

- stage: SlotSwap
  displayName: Slot Swap stage
  dependsOn: Deploy
  condition: succeeded()

  jobs:
  - deployment: SlotSwap
    displayName: Slot Swap
    environment: $(functionAppName)
    
    strategy:
      runOnce:
        deploy:

          steps:
          - task: AzureAppServiceManage@0
            displayName: 'Start Swap With Preview'
            inputs:
              azureSubscription: '$(azureSubscription)'
              Action: 'Start Swap With Preview'
              WebAppName: '$(functionAppName)'
              ResourceGroupName: $(resourceGroup)
              SourceSlot: $(sourceSlot)
          - task: PowerShell@2
            displayName: 'Wait for endpoint to respond'
            inputs:
              targetType: 'inline'
              script: | 
                Do {
                  try {
                    $response = Invoke-RestMethod -Uri '$(statusEndpointUri)' -TimeoutSec 5
                  } catch {}
                  Start-Sleep -Seconds 2
                } 
                while (-not $response)
              pwsh: true
          - task: AzureAppServiceManage@0
            displayName: 'Complete Slot Swap'
            inputs:
              azureSubscription: '$(azureSubscription)'
              Action: 'Swap Slots'
              WebAppName: $(functionAppName)
              ResourceGroupName: $(resourceGroup)
              SourceSlot: $(sourceSlot)

Verify result

Just as before we change the version in the status function to version 5, push to master and start the verification script.

...
Hi, I'm running version 4!
Hi, I'm running version 4!
Hi, I'm running version 5!
Hi, I'm running version 4!
Hi, I'm running version 5!
Hi, I'm running version 5!
WARNING: No response
WARNING: No response
WARNING: No response
Hi, I'm running version 5!
Hi, I'm running version 5!
...

You would think that this should work, but there is a small detail that is easily missed. This detail has nothing to do with the PowerShell script or the YAML code. It has to do with the last step in the slot swap process. On the last step of a swap, the swap is completed by changing the routing rules. Could that trigger another restart?

Deployment slots with preview, post-warmup script and app config

Often, the app will restart one last time after the swap has completed (and sporadically after that) because the hostname binding goes out of sync. This is how Microsoft explains it:

This is because after a swap, the hostname binding configuration goes out of sync, which by itself doesn’t cause restarts. However, certain underlying storage events (such as storage volume failovers) may detect these discrepancies and force all worker processes to restart.

This can be solved by adding the setting WEBSITE_ADD_SITENAME_BINDINGS_IN_APPHOST_CONFIG on both slots. Read more about this setting here. There is really no downside to adding this setting for a Function App running PowerShell, as you can read here.

Add app setting

You can use the PowerShell cmdlet Set-AzWebAppSlot to set app configurations. But be careful. This cmdlet requires you to give it ALL the settings the app should have. If you only give it the settings you want to add, all previous settings will be removed. Because of that I wrote a script which first picks up all the existing settings, then adds the new setting to the list before giving it to Set-AzWebAppSlot.

# Add app setting
foreach ($Slot in 'production', 'staging') {
    $AppSettings = @{}

    $WebAppSlot = Get-AzWebAppSlot -ResourceGroupName $ResourceGroupName `
                                   -Name $FunctionAppName -Slot $Slot
    
    foreach ($Setting in $WebAppSlot.SiteConfig.AppSettings) {
        $AppSettings.Add($Setting.Name, $Setting.Value)
    }

    $AppSettings.Add('WEBSITE_ADD_SITENAME_BINDINGS_IN_APPHOST_CONFIG', 1)
    
    Set-AzWebAppSlot -ResourceGroupName $ResourceGroupName `
                     -Name $FunctionAppName `
                     -AppSettings $AppSettings `
                     -Slot $Slot
}

Changing settings will trigger a restart of the app. Use the verification script to get it up and running again.

Deploy and verify again

Change the version in the status endpoint one more time (version 6). Start the verification script and push the change to master. If this doesn’t work, I will go mad.

...
Hi, I'm running version 5!
Hi, I'm running version 5!
Hi, I'm running version 5!
Hi, I'm running version 5!
Hi, I'm running version 6!
Hi, I'm running version 6!
Hi, I'm running version 6!
Hi, I'm running version 6!
...

No downtime! We have finally reach the goal. After all this work we can finally deploy new versions seamlessly.

Summary

To be able to deploy a PowerShell Function App without any downtime you need to keep a few things in mind.

  1. Use Slot swap with preview
  2. Add a post-warmup script to trigger the profile
  3. Add the setting WEBSITE_ADD_SITENAME_BINDINGS_IN_APPHOST_CONFIG=1 to both slots

Here is the complete script to create the app, add a slot and configure the app setting.

# Create Function App

# Variables
$Location           = 'North Europe'
$ResourceGroupName  = "myresourcegroup"
$StorageAccountName = "mystorageaccount"
$FunctionPlanName   = "myfunctionplan"
$FunctionAppName    = "myfunction"

# Create resource group
New-AzResourceGroup -Name $ResourceGroupName `
                    -Location $Location

# Create storage account
New-AzStorageAccount -Name $StorageAccountName `
                     -ResourceGroupName $ResourceGroupName `
                     -Location $Location `
                     -SkuName 'Standard_LRS'

# Create function app plan
New-AzFunctionAppPlan -Name $FunctionPlanName `
                      -ResourceGroupName $ResourceGroupName `
                      -Location $Location `
                      -Sku 'EP1' `
                      -WorkerType 'Windows'

# Create function app
New-AzFunctionApp -Name $FunctionAppName `
                  -ResourceGroupName $ResourceGroupName `
                  -StorageAccountName $StorageAccountName `
                  -Runtime 'PowerShell' `
                  -RuntimeVersion '7.0' `
                  -PlanName $FunctionPlanName `
                  -FunctionsVersion '3' `
                  -OSType 'Windows'

# Add staging slot
New-AzWebAppSlot -ResourceGroupName $ResourceGroupName `
                 -Name $FunctionAppName `
                 -Slot 'staging'


# Add app setting
foreach ($Slot in 'production', 'staging') {
    $AppSettings = @{}

    $WebAppSlot = Get-AzWebAppSlot -ResourceGroupName $ResourceGroupName `
                                   -Name $FunctionAppName -Slot $Slot
    
    foreach ($Setting in $WebAppSlot.SiteConfig.AppSettings) {
        $AppSettings.Add($Setting.Name, $Setting.Value)
    }

    $AppSettings.Add('WEBSITE_ADD_SITENAME_BINDINGS_IN_APPHOST_CONFIG', 1)
    
    Set-AzWebAppSlot -ResourceGroupName $ResourceGroupName `
                     -Name $FunctionAppName `
                     -AppSettings $AppSettings `
                     -Slot $Slot
}

Here is the complete YAML, including the build step.

trigger:
- master

variables:
  # Azure Resource Manager connection
  azureSubscription: 'Name of your Azure RM connection'

  # Function app name
  functionAppName: 'myfunction'

  # Agent VM image name
  vmImageName: 'vs2017-win2016'

  # Working Directory
  workingDirectory: '$(System.DefaultWorkingDirectory)/'
  
  # Resource Group
  resourceGroup: 'myresourcegroup'
  
  # Source Slot
  sourceSlot: 'staging'

  # Source Slot endpoint
  statusEndpointUri: 'https://myfunction-staging.azurewebsites.net/api/status'

stages:
- stage: Build
  displayName: Build stage

  jobs:
  - job: Build
    displayName: Build
    pool:
      vmImage: $(vmImageName)

    steps:
    - task: ArchiveFiles@2
      displayName: 'Archive files'
      inputs:
        rootFolderOrFile: $(workingDirectory)
        includeRootFolder: false
        archiveType: zip
        archiveFile: $(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip
        replaceExistingArchive: true

    - publish: $(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip
      artifact: drop
      displayName: 'Publish artifact'

- stage: Deploy
  displayName: Deploy stage
  dependsOn: Build
  condition: succeeded()

  jobs:
  - deployment: Deploy
    displayName: Deploy
    environment: $(functionAppName)
    pool:
      vmImage: $(vmImageName)

    strategy:
      runOnce:
        deploy:

          steps:
          - task: AzureFunctionApp@1
            displayName: 'Azure functions app deploy'
            inputs:
              azureSubscription: '$(azureSubscription)'
              appType: functionApp
              appName: $(functionAppName)
              package: '$(Pipeline.Workspace)/drop/$(Build.BuildId).zip'
              deployToSlotOrASE: true
              resourceGroupName: $(resourceGroup)
              slotName: $(sourceSlot)
              deploymentMethod: 'auto'

- stage: SlotSwap
  displayName: Slot Swap stage
  dependsOn: Deploy
  condition: succeeded()

  jobs:
  - deployment: SlotSwap
    displayName: Slot Swap
    environment: $(functionAppName)
    
    strategy:
      runOnce:
        deploy:

          steps:
          - task: AzureAppServiceManage@0
            displayName: 'Start Swap With Preview'
            inputs:
              azureSubscription: '$(azureSubscription)'
              Action: 'Start Swap With Preview'
              WebAppName: '$(functionAppName)'
              ResourceGroupName: $(resourceGroup)
              SourceSlot: $(sourceSlot)
          - task: PowerShell@2
            displayName: 'Wait for endpoint to respond'
            inputs:
              targetType: 'inline'
              script: | 
                Do {
                  try {
                    $response = Invoke-RestMethod -Uri '$(statusEndpointUri)' -TimeoutSec 5
                  } catch {}
                  Start-Sleep -Seconds 2
                } 
                while (-not $response)
              pwsh: true
          - task: AzureAppServiceManage@0
            displayName: 'Complete Slot Swap'
            inputs:
              azureSubscription: '$(azureSubscription)'
              Action: 'Swap Slots'
              WebAppName: $(functionAppName)
              ResourceGroupName: $(resourceGroup)
              SourceSlot: $(sourceSlot)

Deployment slots are great to use when doing CI/CD pipelines as long as you understand how it works. It took me a while to figure all this out so I hope you find this post helpful.