A sample Azure Container App implementation to demonstrate how to simulate something similar to an Azure App Service staging / production slots swaps.
This repo deploys a trivial .NET 6 Web API to an Azure Container Apps instance using GitHub Actions. The workflow will first deploy to a revision labeled as "staging" and then allow the user to promote the revision to "production." This methodology closely mimics the staging/production slot workflow typically used in Azure Web Apps.
This is somewhat based on Dennis Zielke's alternative (and excellent) blue/green implementation for Container Apps. Whereas Dennis uses environment variables to achieve blue/green, this repo uses the concept of Revision Labels to achieve something closer to Azure Web App slots. Revision labels gives deterministic URLs based on the label, whereas Dennis's implementation requires knowledge of the revision specific FQDN.
This example assumes familiarity with Bicep for Azure resource deployment, and a combination of Powershell and Azure CLI commands in scripts. These can be easily adapted to Terraform or bash or whatever.
The example will also deploy all resources to the Azure East US region. Make sure these resources are available in your region.
Lastly, this assumes that you have an existing container registry available. This example is using GitHub Packages. Please follow the steps to connect your account to your GitHub Packages, or modify the container registry steps to suit your registry of choice (Azure Container Registry, DockerHub, etc).
In order to setup the environment and execute our GitHub Actions workflow, we'll need to create the following Action Secrets:
Let's populate these with the following steps:
We first need to create a resource group:
New-AzResourceGroup -Name 'azure-containerapp-slots-example-usea-rg' -Location eastus
Save the resource group name to an Actions secret called RESOURCEGROUPNAME
The GitHub Actions workflow will need at least contributor access to the Azure resource group created above. This can be achieved by following the directions here: https://docs.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-cli%2Cwindows#use-the-azure-login-action-with-a-service-principal-secret
Save the output json of the az ad sp create-for-rbac
command to a secret called AZURE_CREDENTIALS
If you already have an existing Container App Environment created, populate the CONTAINERAPPENVNAME
secret with this name. Otherwise, deploy the ./deployment/arm/infrastructure.bicep
template to your resource group. This will create the Container App Environment, Log Analytics Workspace, Application Insights instance, and a storage account.
New-AzResourceGroupDeployment -ResourceGroupName 'azure-containerapp-slots-example-usea-rg' `
-TemplateFile ./deployment/scripts/infrastructure.bicep `
-applicationInsightsName 'azure-containerapp-slots-example-usea-ai' `
-containerAppEnvironmentName 'azure-containerapp-slots-example-usea-appenv' `
-logAnalyticsWorkspaceName 'azure-containerapp-slots-example-usea-logws' `
-storageAccountName 'caexampleapiuseasa'
Save the CONTAINERAPPENVNAME
and STORAGEACCOUNTNAME
as Actions secrets.
Populate these values with your GitHub Packages URI and a GitHub personal access token setup with read:packages
and write:packages
permissions. Why use a PAT instead of the built-in GITHUB_TOKEN secret that's automatically available? The reason is that the workflow for progressing the revision from staging to production may exceed the timeout for the ephemeral token. So while GITHUB_TOKEN
is suitable (and recommended) for the build step to push the container image to the GitHub Packages repository, the Azure side of things will need a longer lasting token to deal with the deployment stuff.
This will be the name of the container image that we will build and push to our registry. In this example, we set this to example-api
The CONTAINERAPPNAME
secret will be the name of the Azure Container App resource we will create with our Bicep template. In this example, it will be set to nshenoy-example-api-usea-app
.
Set this to some arbitrary string. The value will be used in the deployment step to prove that we can update an appSettings.json value with a secret.
At a high level, the pipeline yaml looks like this:
The build
job is quite straightforward. The templates and deployment scripts will be published as build artifacts to be used by the deployment stages. The container image is built and pushed to our GitHub Packages repository.
Next we have the staging
job. The first main step is to run the Get-ContainerAppProductionRevision.ps1
script to determine if a revision with a production
label exists.
- name: Get Revision with Production Label
id: getProductionRevision
uses: azure/powershell@v1
with:
inlineScript: |
$productionRevision = ( ./scripts/Get-ContainerAppProductionRevision.ps1 -resourceGroupName ${{ env.resourceGroupName }} -containerAppName ${{ env.containerAppName }} )
echo "containerAppProductionRevision=$productionRevision" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
The script uses az containerapp ingress show
to determine if there is a revision with a "production" label in place. The script either returns the revision name or a value of 'none' if the label doesn't exist, the output of which will become a new environment variable called containerAppProductionRevision
.
Next we run a "variable substition" step, which will replace #{}
based tokens in our containerApp.parameters.json
template file with environment variables.
The Bicep template is then deployed. And here we have to do some trickery. The first trick is the containerapp_revision_uniqueid
parameter:
...
param containerapp_revision_uniqueid string = newGuid()
...
env: [
...
{
name: 'containerapp_revision_uniqueid'
value: containerapp_revision_uniqueid
}
In order to force a revision-scope change, we set this containerapp_revision_uniqueid
params default value to a new GUID with each Bicep deployment.
The next bit of trickery is setting the ingress properties of the Container App:
ingress: containerAppProductionRevision != 'none' ? {
external: useExternalIngress
targetPort: containerPort
transport: 'auto'
traffic: [
{
latestRevision: true
label: 'staging'
weight: 0
}
{
revisionName: containerAppProductionRevision
label: 'production'
weight: 100
}
]
} : {
external: useExternalIngress
targetPort: containerPort
transport: 'auto'
}
Here we use a ternary operator to switch behavior off of the containerAppProductoinRevision
parameter. If the previous Get-ContainerAppProductionRevision.ps1
step returned a revision name with a production label, then we have to setup the ingress traffic rules such that production
remains with 100% of the traffic, but the latest revision we're deploying is set to 0%. In other words, don't mess with the current Production slot. Otherwise, if there was no previous production slot defined, then there's no traffic rules to define (yet). This is the crux of getting this slot-like behavor to work.
Next we run the Set-ContainerAppStagingLabel.ps1
script to apply the staging
label to the latest revision.
At this point, the latest container image is staged. We can then test to make sure it functions as needed. The revision FQDN can be retrieved from the Azure portal by going to your Container App -> Revision management and then clicking on your staging labeled revision.
The "Label URL" will always be the Container App name with ---staging
appended to the end.
Finally the production
job will run the Swap-ContainerAppRevisions.ps1
to swap revision labels and verify that the production
label has 100% of the traffic.