Mastering Azure Container Apps: A Step-by-Step Guide to Resource Creation and CI/CD Automation
Feb 07, 2024A few months back, I explored how to deploy applications to Azure Container Apps.
I wanted to create a permanent redirection from my domain remi-solutions.com to remiceraline.com.
My goal was to make sure that all URLs once accessible on remi-solutions.com gracefully redirect to remiceraline.com. For instance, www.remi-solutions.com/blog would return a permanent redirect to www.remiceraline.com/blog.
To achieve this, I decided to create a simple asp.net web application deployed to an Azure Container Apps and automate the deployment with an Azure DevOps pipeline.
In this article, I'll walk you through the step-by-step process of how I accomplished this feat.
By the end of our exploration, you'll gain insights on creating an Azure environment tailored for your container apps, automating infrastructure creation using Bicep, and deploying your application seamlessly with an Azure DevOps CI/CD pipeline.
The code of the solution is available on GitHub.
The Web Application
To achieve the redirection, I created an asp.net web application with Visual Studio.
The first thing I needed was an endpoint for the Azure Container Apps probes. Probes are useful to determine if the application is ready (Readiness Probe) or running correctly (Liveness Probe).
I use /healthz for that. This is the only URL that won’t be redirected to remiceraline.com.
Then, I created a regex to retrieve the current baseUrl. I wanted the regex to match anything that starts with http:// or https:// and ends with a / but without including the slash.
For instance, in https://www.remi-solutions.com/blog, the result would be https://www.remi-solutions.com.
With this, I created a middleware to do the actual redirection. I had to change the response status code and the location header.
Here is the code of the program.cs file.
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Net.Http.Headers;
using System.Net;
using System.Text.RegularExpressions;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/healthz", () => "Healthie!");
var urlRegex = new Regex("^https?:\\/\\/.+?[^\\/]*");
const string newBaseUrl = "https://www.remiceraline.com";
app.Use(async (context, next) =>
{
var oldUrl = context.Request.GetEncodedUrl();
if (oldUrl.EndsWith("/healthz"))
{
await next.Invoke();
return;
}
var redirectUrl = urlRegex.Replace(oldUrl, newBaseUrl);
var response = context.Response;
response.StatusCode = (int)HttpStatusCode.MovedPermanently;
response.Headers[HeaderNames.Location] = redirectUrl;
await response.WriteAsync("Redirect to www.remiceraline.com.");
});
app.Run();
The Infrastructure
To create the environment in Azure, I use a Bicep script.
By following Microsoft quickstart template, I came up with a script almost identical. I just changed some skus to use free services.
First, we have the container registry module saved in a containerRegistry.bicep file.
@description('Specifies the location for all resources.')
param location string
param containerRegistryName string
resource containerRegistry 'Microsoft.ContainerRegistry/registries@2022-02-01-preview' = {
name: containerRegistryName
location: location
sku: {
name: 'Basic'
}
properties: {
//You will need to enable an admin user account in your Azure Container Registry even when you use an Azure managed identity <https://docs.microsoft.com/azure/container-apps/containers>
adminUserEnabled: true
}
}
output id string = containerRegistry.id
output name string = containerRegistry.name
output loginServer string = containerRegistry.properties.loginServer
Then, in the main.bicep file we include the containerRegistry module, a log analytic workspace, a managed identity, the container app environment and the container app.
Since we cannot create a container app without a container image, we use a module from Microsoft import-acr
, to push a default image to our container registry. This is just temporary as we will push our custom image to the registry with an Azure DevOps pipeline.
@description('Common name used by all resources.')
param commonName string = 'containerapp20231211'
@description('Specifies the name of the container app.')
param containerAppName string = 'app-${commonName}'
@description('Specifies the name of the container app environment.')
param containerAppEnvName string = 'env-${commonName}'
@description('Specifies the name of the log analytics workspace.')
param containerAppLogAnalyticsName string = 'containerapp-log-${commonName}'
@description('Specifies the name of the container app environment.')
param containerRegistryName string = 'cr${commonName}'
@description('Specifies the location for all resources.')
param location string = resourceGroup().location
@description('Minimum number of replicas that will be deployed')
@minValue(0)
@maxValue(25)
param minReplica int = 1
@description('Maximum number of replicas that will be deployed')
@minValue(0)
@maxValue(25)
param maxReplica int = 3
// use a default container image as we cannot create a container app without a container image.
@description('Specifies the docker container image to deploy.')
param containerImage string = 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest'
var acrPullRole = resourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')
resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-06-01' = {
name: containerAppLogAnalyticsName
location: location
properties: {
sku: {
name: 'PerGB2018'
}
}
}
module acr 'containerRegistry.bicep' = {
name: 'acr'
params: {
location: location
containerRegistryName: containerRegistryName
}
}
@description('This module seeds the ACR with the public version of the app')
module acrImportImage 'br/public:deployment-scripts/import-acr:3.0.1' = {
name: 'importContainerImage'
params: {
acrName: acr.outputs.name
location: location
images: array(containerImage)
}
}
resource containerAppEnv 'Microsoft.App/managedEnvironments@2022-06-01-preview' = {
name: containerAppEnvName
location: location
sku: {
name: 'Consumption'
}
properties: {
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: {
customerId: logAnalytics.properties.customerId
sharedKey: logAnalytics.listKeys().primarySharedKey
}
}
}
}
resource uai 'Microsoft.ManagedIdentity/userAssignedIdentities@2022-01-31-preview' = {
name: 'id-${containerAppName}'
location: location
}
@description('This allows the managed identity of the container app to access the registry, note scope is applied to the wider ResourceGroup not the ACR')
resource uaiRbac 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(resourceGroup().id, uai.id, acrPullRole)
properties: {
roleDefinitionId: acrPullRole
principalId: uai.properties.principalId
principalType: 'ServicePrincipal'
}
}
resource containerApp 'Microsoft.App/containerApps@2022-06-01-preview' = {
name: containerAppName
location: location
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${uai.id}': {}
}
}
properties: {
managedEnvironmentId: containerAppEnv.id
configuration: {
ingress: {
external: true
targetPort: 80
allowInsecure: false
traffic: [
{
latestRevision: true
weight: 100
}
]
}
registries: [
{
identity: uai.id
server: acr.outputs.loginServer
}
]
}
template: {
revisionSuffix: 'firstrevision'
containers: [
{
name: containerAppName
image: acrImportImage.outputs.importedImages[0].acrHostedImage
resources: {
cpu: json('.25')
memory: '.5Gi'
}
}
]
scale: {
minReplicas: minReplica
maxReplicas: maxReplica
rules: [
{
name: 'http-requests'
http: {
metadata: {
concurrentRequests: '10'
}
}
}
]
}
}
}
}
output containerAppFQDN string = containerApp.properties.configuration.ingress.fqdn
Finally, I include a powershell script script.ps1
to create a resouce group and deploy the Bicep template.
$resourceGroup = "rg-containerapp"
$location = "Canada Central"
az group create --name $resourceGroup --location $location
# wait for connection to be ready
Start-Sleep -Seconds 5
az deployment group create --resource-group $resourceGroup `
--mode Complete `
--name remisolutions `
--template-file .\main.bicep
The Infrastructure Pipeline
We can create a pipeline to automate the deployment of the infrastructure.
This pipeline will use a service connection to connect to Azure and execute the Bicep code previously created.
We use the AzureCLI task to run our script.ps1.
You will find the yaml file below. You can create a new pipeline in Azure DevOps based on this file.
To create a new pipeline in Azure DevOps:
- Save the yaml file in your repository, commit and push the code.
- Go to Azure DevOps > Pipelines > Create Pipeline.
- Choose where is your code (GitHub for instance) and select your repository.
- Configure your pipeline from an Existing Azure Pipelines YAML file and select the file.
- Save the pipeline and run it.
To create a new service connection in Azure DevOps:
- Go to Azure DevOps > Project settings > Service connections.
- Create a new service connection of type Azure Resource Manager
- Choose to authenticate with a service principal and follow the instructions.
- Name the service connection AzureSubscription.
# infrastructure pipeline
trigger:
branches:
include:
- main
paths:
include:
- 2024-02-container-apps/Infrastructure
stages:
- stage: Deploy
jobs:
- job: Deploy
pool:
vmImage: ubuntu-latest
steps:
- task: AzureCLI@2
inputs:
azureSubscription: AzureSubscription
scriptType: "pscore"
scriptLocation: "scriptPath"
scriptPath: $(Build.Repository.LocalPath)/2024-02-container-apps/Infrastructure/script.ps1
workingDirectory: $(Build.Repository.LocalPath)/2024-02-container-apps/Infrastructure
The Application Pipeline
This pipeline will be a bit more complex than the infrastructure one. To make it easier to grasp, I've split it into three sections: the variables, the build stage, and the deploy stage. All of these sections are contained within a single file.
Once the file is created, create the pipeline in Azure DevOps as we did for the Infrastructure pipeline.
The variables
First, we configure the trigger to run the pipeline after every push on the main branch.
As I have multiple projects in the same repository, I also included a path filter. You probably won't need it.
Next, we configure a couple of variables that will be used in the pipeline.
# azure-pipelines.yml
trigger:
branches:
include:
- main
paths:
include:
- 2024-02-container-apps/Application
variables:
- name: containerAppName
value: app-containerapp20231211
- name: containerRegistryUrl
value: crcontainerapp20231211.azurecr.io
- name: resourceGroupName
value: rg-containerapp
- name: applicationFolder
value: $(Build.Repository.LocalPath)/2024-02-container-apps/Application/RemiSolutions
- name: projectFolder
value: $(applicationFolder)/RemiSolutions
- name: containerRepository
value: containerapp
- name: buildConfiguration
value: "Release"
- name: outputFolder
value: $(projectFolder)/output/
stages:
[...]
The Build Stage
After the variables, we have the Build stage.
In this stage, we build and publish the dotnet project, login to ACR, build the Docker image and push the Docker image to the container registry.
Notice that in the Docker task, I passed the value AzureContainerRegistry for the containerRegistry parameter. This is actually the name of a service connection that you have to create. The connection type is Docker Registry instead of Azure Resource Manager like the previous one. Then, choose the Azure Container Registry for the registry type as displayed on the picture below.
Lastly in the build stage, we update the deployment.yaml with the replacetokens task and publish it as an artifact.
The deployment.yaml file is a configuration file used to setup the container app. In this file, we specify the image to use and the different probes. Notice how we use variables in the file for the image and container name properties. They will be replaced with their values during the replacetokens task execution.
The file is under the Application folder next to the azure-pipelines.yml file.
# azure-pipelines.yml
[...]
- stage: Build
jobs:
- job: Build
pool:
vmImage: ubuntu-latest
steps:
- task: DotNetCoreCLI@2
displayName: Build & Publish
inputs:
command: "publish"
publishWebProjects: false
modifyOutputPath: false
workingDirectory: $(projectFolder)
arguments: --output $(outputFolder) -c $(buildConfiguration) --self-contained true
zipAfterPublish: false
- task: Docker@2
displayName: Login to ACR
inputs:
command: login
containerRegistry: AzureContainerRegistry
- task: Docker@2
displayName: Build Docker image
inputs:
containerRegistry: AzureContainerRegistry
repository: $(containerRepository)
command: "build"
Dockerfile: $(projectFolder)/Dockerfile
buildContext: $(projectFolder)
tags: |
$(Build.BuildId)
latest
- task: Docker@2
displayName: Push Docker image
condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
inputs:
containerRegistry: AzureContainerRegistry
repository: $(containerRepository)
command: "push"
tags: |
$(Build.BuildId)
latest
- task: CopyFiles@2
displayName: Copy deployment yaml file
inputs:
sourceFolder: $(applicationFolder)
contents: "deployment.yaml"
targetFolder: $(Build.ArtifactStagingDirectory)
overwrite: true
- task: qetza.replacetokens.replacetokens-task.replacetokens@3
displayName: Replace tokens
inputs:
rootDirectory: $(Build.ArtifactStagingDirectory)
targetFiles: "deployment.yaml"
encoding: auto
writeBOM: true
escapeType: no escaping
actionOnMissing: log warning
tokenPrefix: __
tokenSuffix: __
- publish: $(Build.ArtifactStagingDirectory)
[...]
# deployment.yaml file
properties:
configuration:
ingress:
external: true
targetPort: 80
template:
containers:
- image: __containerRegistryUrl__/__containerRepository__:__Build.BuildId__
name: __containerAppName__
probes:
- type: Liveness
httpGet:
path: "/healthz"
port: 80
initialDelaySeconds: 3
periodSeconds: 3
- type: Readiness
httpGet:
path: "/healthz"
port: 80
initialDelaySeconds: 3
periodSeconds: 3
The Deploy Stage
The second stage is the Deploy stage.
In this stage, we use a deployment job and the environment container-app. Make sure to create an empty environment named container-app for the stage to run successfully. You can create different Approvals for the environment to make sure that a human can validate every deployments.
Then, we download the artifacts and use the AzureContainerApps task to deploy our application with the deployment.yaml file.
# azure-pipelines.yml
[...]
- stage: Deploy
jobs:
- deployment: Deploy
environment:
name: container-app
pool:
vmImage: "ubuntu-latest"
strategy:
runOnce:
deploy:
steps:
- task: DownloadPipelineArtifact@2
inputs:
artifact: "artifacts"
- task: AzureContainerApps@1
inputs:
azureSubscription: AzureSubscription
containerAppName: $(containerAppName)
resourceGroup: $(resourceGroupName)
yamlConfigPath: $(Pipeline.Workspace)/deployment.yaml
Save the pipeline including all the stages, commit and push the code. Then, create the pipeline based on the saved yaml file, run it and your application will be deployed to Azure Container Apps.
Conclusion
And there you have it! A complete solution to propel your applications into Azure Container Apps seamlessly. This guide, encompassing resource creation and a meticulous CI/CD pipeline in Azure DevOps, sets the stage for an optimized, automated deployment process.
What's your take on deploying applications with Azure Container Apps? Have questions or insights to share? Drop me a comment, and let's discuss your experiences together!