Developments in Digital
Developments in Digital

Optimising Azure infrastructure deployment

When implementing infrastructure as code, there is a decision to be made about how updates to that infrastructure are released.

Typically, you would want an application's infrastructure to sit alongside the application source code within the same repository, as they have high cohesion.

However, the infrastructure of an application is likely to have a different lifecycle to the application source code. The infrastructure is likely to be updated and deployed less often than the application code once the initial setup is complete.

When you manage the deployment of both application infrastructure and application within the same release pipeline, there can be performance issues associated with redeploying the infrastructure on every application release.

Even if the infrastructure hasn't changed since the last release, the attempt to deploy an unmodified ARM template within Azure can still be time consuming.

There are a few options to optimise release time, some of them include:

  1. Split the repositories into application source code and application infrastructure, and have two build pipelines and two release pipelines.
  2. Keep one repository for both infrastructure and application source code, have one build pipeline that produces two build artefacts (infrastructure and app code) and have two release pipelines.
  3. Have one repository, two build pipelines, and one release pipeline. Each build pipeline triggers on either infrastructure or app code changes and produces its own build artefact. The release pipeline compares the previous build artefact to the current artefact, and only redeploys if the artefact has changed.
  4. Have one repository, one build pipeline, and one release pipeline. In the release pipeline, detect if the folders affecting infrastructure have changed since the last release and redeploy as necessary. Either always redeploy the application code, or detect app source folder changes and redeploy application code if necessary.

Note there are many other combinations of number of repositories, number of build pipelines, and number of release pipelines. The best advice is to analyse how easy your code is to maintain, what your build and release pipeline times are, and the best places to optimise. Try and keep the solution as simple as possible.

The option explored here is option 4, detecting application infrastructure changes in the release pipeline, and skipping if no changes are detected.

The reason is that having a single repository, single build pipeline, and single release pipeline is easy to manage and scales well as the number of applications increases.

In order to detect if infrastructure changes have been made, we can create a hash code of the contents of all of the files that apply to infrastructure changes. In the case of using the available Azure mechanisms for defining infrastructure, this will typically be any ARM templates and any PowerShell scripts that deploy the templates.

The following cmdlet can return a hash code based on the combined contents of all files in the specified collection of folders:

function Get-FoldersHash {
    param (
        [Parameter(Mandatory = $true)][string[]] $Folders
    )

    $bytes = new-object System.Collections.Generic.List[byte]

    foreach ($folder in $Folders) {
        $files = dir $folder -Recurse |? { -not $_.psiscontainer }

        foreach ($file in $files) {
            $bytes.AddRange([System.IO.File]::ReadAllBytes($file.FullName))
            $bytes.AddRange([System.Text.Encoding]::UTF8.GetBytes($file.Name))
        }
    }

    $hasher = [System.Security.Cryptography.MD5]::Create()
    return [string]::Join("", $($hasher.ComputeHash($bytes.ToArray()) | % {"{0:x2}" -f $_}))
}

Once you generate a hash value based on the infrastructure folder(s), you need to be able to compare the hash of the current release to that of the previous release. In other words, the release pipeline needs to be stateful.

$infrastructureFolders = @("$PSScriptRoot\scripts", "$PSScriptRoot\templates")
$infrastructureHash = Get-FoldersHash -Folders $infrastructureFolders

If ($previousHash -eq $infrastructureHash) {
    # skip infrastructure deployment
}

As we typically deploy an application infrastructure to an application resource group, we can store the previous hash value within a tag on the resource group.

Resource group tag values support up to 256 characters and can be retrieved with the following code:

$resourceGroup = Get-AzureRmResourceGroup -Name "..."
$resourceGroupTags = $resourceGroup.Tags

# output infrastructure hash tag value
Write-Verbose $resourceGroupTags['infrastructure']

The infrastructure tag value can be updated without affecting other tag values with the following:

$resourceGroupTags += @{
  infrastructure = "...";
}

Set-AzureRmResourceGroup `
  -Name $ResourceGroupName `
  -Tag $resourceGroupTags | Out-Null

These are all of the pieces needed to perform the following tasks during release:

graph TD
  get-rg-tags[Get resource group infrastructure tag value] --> get-hash[Get infrastructure folders hash]
  get-hash --> same{Hash = tag value?}
  same -- yes --> complete[Complete]
  same -- no --> deploy[Deploy infrastructure]
  deploy --> set[Set infrastructure tag value to hash]
  set --> complete

This approach can save release time on skipping unnecessary attempts to deploy infrastructure that hasn't changed. Checking the resource group tag value is faster than attempting an ARM template deployment with no changes.

One issue may be in any outputs that you require from the infrastructure ARM template deployment that are used later in the release script. For example, you may need the deployed app service name in order to deploy the application code.

A solution is to find an existing app service by name when skipping the infrastructure deployment:

function Get-AppService {
    param(
        [Parameter(Mandatory = $true)][string] $ResourceGroupName,
        [Parameter(Mandatory = $true)][string] $Name
    )

    $appServices = Get-AzureRmWebApp -ResourceGroupName $ResourceGroupName | Where-Object Name -like "$Name*"

    If ($appServices.Count -eq 0) {
        throw "App service '$($Name)' could not be found in resource group '$($ResourceGroupName)'."
    }

    If ($appServices.Count -eq 1) {
        return $appServices[0]
    }

    throw "More than one app service found beginning with '$($Name)' within resource group '$($ResourceGroupName)'."
}