Online Course

Mastering Azure Container Apps: A Step-by-Step Guide to Resource Creation and CI/CD Automation

azure azure container apps azure devops bicep ci/cd Feb 07, 2024

A 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:

  1. Save the yaml file in your repository, commit and push the code.
  2. Go to Azure DevOps > Pipelines > Create Pipeline.
  3. Choose where is your code (GitHub for instance) and select your repository.
  4. Configure your pipeline from an Existing Azure Pipelines YAML file and select the file.
  5. Save the pipeline and run it.

To create a new service connection in Azure DevOps:

  1. Go to Azure DevOps > Project settings > Service connections.
  2. Create a new service connection of type Azure Resource Manager
  3. Choose to authenticate with a service principal and follow the instructions.
  4. 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!

 

Work With Me

Ready to take your Azure solutions to the next level and streamline your DevOps processes? Let's work together! As an experienced Azure solutions architect and DevOps expert, I can help you achieve your goals. Click the button below to get in touch.

Get In Touch