Over the weekend, I was on a quest to find the cheapest available Azure Virtual Machine to house my Azure Kubernetes Service cluster. I previously wrote about optimizing storage cost with Azure Kubernetes Service (AKS), where I talk about the importance of selecting VMs that support ephemeral OS disks. But what VM type and region should I choose? Searching through reservation offers in the Azure Portal turned out to be tedious. This time, I’ll demonstrate how to find the best offers using PowerShell and export the results to a CSV file.

As always, you can find the source code on my GitHub.

So Many Choices

My AKS cluster hosts a handful of services and doesn’t need a lot of resources. Two vCPUs, eight gigs of RAM, and Premium Storage are sufficient. The analytics data suggests that most of my readers are from the US and Europe, so any region between the eastern US and western Europe will do:

canadaeast
canadacentral
eastus
eastus2
northeurope
westeurope
ukwest
uksouth
switzerlandnorth
germanywestcentral
francecentral
norwayeast
swedencentral

We can browse offers in the Azure Portal under ReservationsAddVirtual machine. However, we can only request one quote for a reservation at a time.

Clicking through the VMs for each region would take a long time. 😴 It certainly would have been faster than writing this blog article. But hacking together a PowerShell script was way more fun! I used a bunch of heuristics that “just work”, so it’s not perfect and causes some 400 Bad Request responses. But it gets the job done.

Requirements

We’ll need the following things to get started:

Parameters and Helper Functions

Let’s create a file named Get-VmReservationQuotes.ps1 and add the following parameters to it:

param (
  [Parameter(Mandatory, HelpMessage="Azure subscription ID")]
  [string]
  $SubscriptionId,

  [Parameter(HelpMessage="Reservation term duration in years (1 or 3)")]
  [int]
  $TermYears = 3,

  [Parameter(HelpMessage="Azure regions")]
  [string[]]
  $Locations = @(
    "canadaeast",
    "canadacentral",
    "eastus",
    "eastus2",
    "northeurope",
    "westeurope",
    "ukwest",
    "uksouth",
    "switzerlandnorth",
    "germanywestcentral",
    "francecentral",
    "norwayeast",
    "swedencentral"
  ),

  [Parameter(HelpMessage="CSV output filename")]
  [string]
  $OutFile = "VmReservationQuotes.csv"
)

This allows us to call the script like this:

.\Get-VmReservationQuotes.ps1 -SubscriptionId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

We also add some helper functions that we’ll re-use throughout the script:

function Get-VmLocation ($vmSize) {
  return $vmSize.Locations[0].ToLower()
}

function Test-VmCapability ($vmSize, $capabilityName, $capabilityValue) {
  foreach($capability in $vmSize.Capabilities)
  {
    if ($capability.Name -eq $capabilityName -and $capability.Value -eq $capabilityValue)
    {
      return $true
    }
  }
  return $false
}

Get-VmLocation extracts the location from a VM object returned by the Get-AzComputeResourceSku cmdlet. With Test-VmCapability, we can check whether the VM supports a specific feature like ephemeral OS disks:

Test-VmCapability $vmSize "EphemeralOSDiskSupported" "True"

Query and Filter Available VM Types

Using Get-AzComputeResourceSku and our helper functions, we query Azure for all the available VM types and apply our filter heuristics:

$vmSizes = @()

foreach ($vmSize in Get-AzComputeResourceSku) {
  # Skip `availibilitySets`, `disks`, etc.
  if (-not ($vmSize.ResourceType -eq 'virtualMachines')) {
    continue
  }

  # Skip unavailable offers
  if ($vmSize.Restrictions.Count -gt 0) {
    continue
  }

  # Filter locations
  if (-not $Locations.Contains((Get-VmLocation $vmSize))) {
    continue
  }

  # Select D-Series VMs
  if (-not $vmSize.Name.StartsWith("Standard_D")) {
    continue
  }

  # Exclude confidential VMs
  if ($vmSize.Name.StartsWith("Standard_DC")) {
    continue
  }

  # Exclude memory-optimized VMs
  if ($vmSize.Name.StartsWith("Standard_D11") -or $vmSize.Name.StartsWith("Standard_DS11")) {
    continue
  }

  if (-not (Test-VmCapability $vmSize "EphemeralOSDiskSupported" "True")) {
    continue
  }

  if (-not (Test-VmCapability $vmSize "PremiumIO" "True")) {
    continue
  }

  if (-not (Test-VmCapability $vmSize "vCPUs" "2")) {
    continue
  }

  $vmSizes += $vmSize
}

Pretty straightforward, right?

Request Quotes

Now we put the filtered $vmSizes through another loop and query Azure for quotes using Get-AzReservationQuote. After, we calculate the monthly price and export the results as CSV.

$i = 0
foreach ($vmSize in $vmSizes) {
  $location = Get-VmLocation $vmSize
  $displayName = "$($vmSize.Name)-$location"

  # Progress bar
  $i++
  $percent = [Math]::Floor(($i / $vmSizes.Count) * 100)
  Write-Progress -Activity "Requesting VM quotes" -Status "$percent% $displayName" -PercentComplete $percent

  $quote = Get-AzReservationQuote `
    -ReservedResourceType "VirtualMachines" `
    -Sku $vmSize.Name `
    -Location $location `
    -Term "P${TermYears}Y" `
    -BillingScopeId $SubscriptionId `
    -Quantity 1 `
    -AppliedScopeType Shared `
    -DisplayName "$displayName"

  # BillingCurrencyTotal is a JSON string, e.g.:
  # {
  #   "currencyCode": "CHF",
  #   "amount": 1130
  # }

  # Extract amount value `1130` from JSON
  $billingCurrencyTotal = $quote.BillingCurrencyTotal | ConvertFrom-Json
  $termPrice = $billingCurrencyTotal.amount

  @{
    Name = $vmSize.Name;
    Location = $location;
    PricePerMonth = $termPrice / ($TermYears * 12)
  } | Export-Csv -Path "${OutFile}" -NoTypeInformation -Delimiter ";" -Append
}

Besides the progress bar, the trickiest part is extracting the price from the BillingCurrencyTotal multi-string field via regular expression.

And The Winner Is 🥁

VMStandard_D2as_v4
Locationeastus
Monthly CostCHF 25.94
USD 27.10

Pretty cool, eh? Do you have any thoughts or questions? Let me know in the comments or on Twitter. Feedback is always welcome!