Reducing Azure Local AVD Costs With Automation Accounts
- davidpereira20
- Apr 23
- 6 min read
Updated: May 1
Following up from my colleague's post on Azure ARC costs we decided to do something to reduce costs for a customer using Azure Local for their AVD environment.
Its worth saying I'm a big Nerdio advocate and would have used it in a blink of an eye to achieve this, I even wear my Nerdio socks to the gym :) But in this particular case the customer's budget doesn't allow it so we decided to do something ourselves.
Requirement
Ensure AVD hosts are automatically powered off inline with the business core hours and requirements to reduce the costs of Azure ARC as per https://www.auxiliumtechtalk.com/post/the-hidden-cost-of-azure-local
Objective
Set 4 of the 6 hosts in the pool to Drain around 5PM
Set a policy to logoff disconnected sessions after 3 hours
Shutdown hosts that are set to Drain at 9:30PM
Start-up Host at 7AM and remove Drain
Step 1
From within the Azure console navigate to "Automation Accounts" via the search bar and select Create and enter required details.

Step 2
From within the newly created automation account navigate to Hybrid Worker Group and select create hybrid worker group
A Hybrid Worker will be used to execute the run books and it will require line of sight to the devices. You can use any VM that is always on like a management server. If you were using AVD in Azure you wouldn't need this as you could run the run books natively.

Give the Hybrid Worker Group a name and click next to Add Machines. This will list all of your Arc enabled VMs just pick one or more for resiliency and it will install an extension.

We are also going to need a set of credentials with appropriate permissions to Power on and off the VMs.

You will also need to add the RBAC Roles assigned to the managed identity of the Automation Account so that you can connect to Azure and put the hosts on/off Drain.
The advice is to always use the principal of least privilege to whatever it is you're trying to achieve with your Script.
Make sure System assigned identity is On and assign the correct RBAC roles.

You should test the connection to azure and whatever commands you wish to run from the Hybrid Worker VM PowerShell Console.

For this particular example to put AVD hosts in maintenance you'll need
"Microsoft.DesktopVirtualization/hostpools/sessionhosts/read",
"Microsoft.DesktopVirtualization/hostpools/sessionhosts/write"
Now that we have all our pre-reqs met we can create the runbooks
Step 3
From within the automation account select runbooks and create, we will initially create a new runbook to drain the hosts.
Provide a name and description for your runbook as shown below and select the runtime environment, please ensure that your Hybrid Workers have the correct PowerShell version installed for the Runtime Environment you select.

Once created select the runbook and begin creating your PowerShell script

You can save the changes and test it.
NOTE: tests will do whatever your script tells it to do so if you're telling it to put hosts in maintenance against a production host pool that's exactly what will happen, so make sure you test against a test Host Pool or out of hours.
You can read the comments to check what each part of the script does, but in summary it lists all the hosts in a Host Pool and puts all of them in maintenance apart from 2, you can change the number of hosts to leave available by just changing that 2 to 3 or something else that suits you.
Remember to change the following variables in the script.
You can also pass the Resource group and Host Pool as parameters when you schedule them or during a test. I'll show that later.
$tenantId = "<tenantId>"
$subscriptionId = "<>subscriptionId"
[string]$ResourceGroupName = "<ResourceGroupName>",
[string]$HostPoolName = "<HostPoolName>"
Script
param (
[string]$ResourceGroupName = "<ResourceGroupName>",
[string]$HostPoolName = "<HostPoolName>"
)
# Ensure required module is installed
if (-not (Get-Module -ListAvailable -Name Az.DesktopVirtualization)) {
Install-Module -Name Az.DesktopVirtualization -Force
}
Import-Module Az.DesktopVirtualization
# Connect to Azure using system-assigned managed identity
$tenantId = "<tenantId>"
$subscriptionId = "<>subscriptionId"
$AzureContext = Connect-AzAccount -Identity -SubscriptionId $subscriptionId -TenantId $tenantId
# Retrieve session hosts
$sessionHosts = Get-AzWvdSessionHost -ResourceGroupName $ResourceGroupName -HostPoolName $HostPoolName | Sort-Object Name
if (-not $sessionHosts) {
Write-Output "No session hosts found in the specified host pool."
exit 1
}
# Extract only the short hostnames
$sessionHosts = $sessionHosts | ForEach-Object {
$_ | Add-Member -NotePropertyName "ShortName" -NotePropertyValue ($_.Name -split "/")[-1] -Force
$_
}
# Display session hosts
Write-Output "Session Hosts in Host Pool:"
$sessionHosts | ForEach-Object { Write-Output $_.ShortName }
# Filter out hosts already in drain mode
$activeHosts = $sessionHosts | Where-Object { $_.AllowNewSession -eq $true }
$drainModeHosts = $sessionHosts | Where-Object { $_.AllowNewSession -eq $false }
# Display hosts that are already in drain mode
if ($drainModeHosts) {
Write-Output "The following hosts are already in drain mode:"
$drainModeHosts | ForEach-Object { Write-Output $_.ShortName }
} else {
Write-Output "No session hosts are currently in drain mode."
}
# Ensure at least 2 hosts remain active
$totalHosts = $sessionHosts.Count
$hostsToDrainCount = $totalHosts - 2
if ($hostsToDrainCount -le 0) {
Write-Output "Only $totalHosts hosts available. No additional hosts will be put into drain mode."
exit
}
# Select hosts to drain
$hostsToDrain = $activeHosts[0..($hostsToDrainCount - 1)]
if (-not $hostsToDrain) {
Write-Output "No active session hosts available to put into drain mode."
exit
}
# Put selected hosts into drain mode
foreach ($sessionHost in $hostsToDrain) {
Write-Output "Putting host $($sessionHost.ShortName) in maintenance mode..."
Update-AzWvdSessionHost -ResourceGroupName $ResourceGroupName -HostPoolName $HostPoolName -Name $sessionHost.ShortName -AllowNewSession:$false
}
Write-Output "Drain mode update complete. At least 2 hosts remain available."
Second Runbook will shut down the Hosts in Drain
Note: in the "" $cred = Get-AutomationPSCredential -Name "HyperVAdminCredential" "" the "HyperVAdminCredential" needs to match what you called it under Automation account > Shared Resources > Credentials
param (
[string]$ResourceGroupName = "<ResourceGroupName>",
[string]$HostPoolName = "<HostPoolName>"
)
# Connect to Azure
Connect-AzAccount -Identity
# Get domain credentials from the Automation Account
$cred = Get-AutomationPSCredential -Name "HyperVAdminCredential"
# Get all session hosts
$sessionHosts = Get-AzWvdSessionHost -ResourceGroupName $resourceGroupName -HostPoolName $hostPoolName
# Filter for drain mode
$drainModeHosts = $sessionHosts | Where-Object { $_.AllowNewSession -eq $false }
# Extract hostnames
$hostnames = $drainModeHosts | ForEach-Object { $_.Name.Split("/")[-1] }
# Shutdown each VM remotely
foreach ($hostname in $hostnames) {
Write-Output "Sending remote shutdown to $hostname"
Invoke-Command -ComputerName $hostname -Credential $cred -ScriptBlock {
shutdown.exe /s /t 0 /f
} -ErrorAction Stop
}
Third and last Runbook will Start-up the hosts and switch off Drain
Note: Again remember to change the variables accordingly or with a few tweaks you can pass them on as parameters. Under # Extract VM names I'm removing the domain suffix as it doesn't match the name of the VM you don't need to do this if your VMs names match what's on ARC.
param (
[string]$ResourceGroupName = "<ResourceGroupName>",
[string]$HostPoolName = "<HostPoolName>"
)
# Connect to Azure
Connect-AzAccount -Identity
# Variables
$clusterName = "<HCIclusterName>" # Name of your Azure Stack HCI cluster
# Get domain credentials from Automation
$cred = Get-AutomationPSCredential -Name "HyperVAdminCredential"
# Get all session hosts from the host pool
$sessionHosts = Get-AzWvdSessionHost -ResourceGroupName $resourceGroupName -HostPoolName $hostPoolName
# Extract VM names (remove the domain part if present)
$vmNames = $sessionHosts | ForEach-Object {
$name = $_.Name.Split("/")[-1]
$name.Split(".")[0] # Remove the domain suffix (e.g., .domain.local)
}
# Define cluster nodes manually
$clusterNodes = @("<clusterNode1>", "<clusterNode2>")
Write-Output "Using hardcoded cluster nodes: $($clusterNodes -join ', ')"
# Loop through each node to try starting the VM on every node
foreach ($node in $clusterNodes) {
Write-Output "Attempting to start VMs on node: $node"
foreach ($vmName in $vmNames) {
Write-Output "Starting VM '$vmName' on host '$node'"
try {
Invoke-Command -ComputerName $node -Credential $cred -ScriptBlock {
param($vmName)
Start-VM -Name $vmName
} -ArgumentList $vmName -ErrorAction Stop
Write-Output "Successfully started VM '$vmName' on node '$node'"
}
catch {
Write-Error "Failed to start VM '$vmName' on node '$node'. Error: $_"
}
}
}
# Disable drain mode for session hosts
foreach ($sessionHost in $sessionHosts) {
$sessionHostName = $sessionHost.Name.Split("/")[-1] # Extract just the VM name
Write-Output "Disabling drain mode on: $sessionHostName"
$params = @{
ResourceGroupName = $resourceGroupName
HostPoolName = $hostPoolName
Name = $sessionHostName
AllowNewSession = $true
}
Update-AzWvdSessionHost @params
}
Scheduling
Now that we have all 3 Runbooks and we've tested them and they work as expected we can schedule them.
You can either go to each runbook and create a schedule or you can create the 3 schedules in the Automation account and link them to the runbook.
Adjust the schedules to suit your operation

Under each runbook go to schedules and click Add a Schedule.
Click Link to select the correct schedule for that Runbook and Parameters to set the variables if you haven't defined them in the script.

Make sure you select Hybrid Worker otherwise the job will fail.

Jobs
Under Jobs you can check if the jobs are running OK or if they failed.

Challenges and Notes
Getting all the pre-reqs in place is the most important thing, make sure the managed identity is set correctly and you have the necessary RBAC roles for what you're trying to do.
PowerShell version on the Hybrid Worker is important.
Don't start troubleshooting the Runbook if the Script isn't working when you run it from the Hybrid Worker, it won't work remotely if it's not working locally and it's harder to troubleshoot.
The way I've done it is far from perfect and there's hundred ways to do the same, I'm going to improve some parts around passing the variables as parameters and maybe discovering the Cluster nodes instead of passing them in a variable, but this is working today and that's good for the customer.
Tests are live test not what-ifs, if you're testing a script to shutdown VMs against a production Host Pool during working hours, well....
Comments