Azure Virtual Desktop Session Hosts Cost Savings

Azure Virtual Desktop Pricing: Optimize Costs

Since the launch of Azure Virtual Desktop a very important topic has been how to limit your costs. In this blog I’ll go over a couple of ways to limit your compute costs since these are the biggest costs.

Table of content

  • What to do with users that don’t logoff
  • Making sure your session hosts don’t keep running for nothing
  • Start VM on Connect

What to do with users that don’t logoff

We all know that users often don’t logoff their sessions but just press the “x” at the end of their working day. Because of this the second part of this blogpost won’t be able to work properly.

To make sure that those disconnected sessions are being logged off we can create a GPO that sets a time limit for disconnected sessions and apply it to the Session Host OU.

Computer Configuration > Administrative Templets >
Windows Components > Remote Desktop Services > Remote Desktop Session
Host > Session Time limits

Be sure to set the “Set time limit for disconnected sessions”. You only need to make sure that you don’t set the time to short otherwise you might get frustrated users.

You might also want to consider to enable the “Set time limit for active but idle Remote Desktop Services session” setting in case your users didn’t disconnect but left their session open and left.

Making sure your session hosts don’t keep running for nothing

So now we got the disconnected sessions taken care of we can focus on getting the unused session hosts turned off. We have a couple of options for this.

  • Scaling script (At least 1 session host keeps running)
  • Create a Azure Function to shutdown unused session hosts.

Scaling Script

The scaling script is a very good way to make sure that you don’t have session host running that you don’t need. Some benefits of the tool are:

  • Schedule VMs to start and stop based on Peak and Off-Peak business hours.
  • Scale out VMs based on number of sessions per CPU core.
  • Scale in VMs during Off-Peak hours, leaving the minimum number of session host VMs running.

The scaling script will see what hosts don’t have active sessions. So making sure disconnected sessions are being logged off is very important otherwise they will be ignored and stay active. Also Sessions hosts that are in drain mode will be ignored.

How to deploy this scaling script

Start with connecting to Azure (You need to be Contributor on the subscription where you deploy this)

Login-AzAccount

Next you need to download the script to create the Automation Account

New-Item -ItemType Directory -Path "C:\Temp" -Force
Set-Location -Path "C:\Temp"
$Uri = "https://raw.githubusercontent.com/Azure/RDS-Templates/master/wvd-templates/wvd-scaling-script/CreateOrUpdateAzAutoAccount.ps1"
# Download the script
Invoke-WebRequest -Uri $Uri -OutFile ".\CreateOrUpdateAzAutoAccount.ps1"

Now run the following Powershell script with the following parameters. Some parameters are optional.

$Params = @{
     "AADTenantId"           = "<Azure_Active_Directory_tenant_ID>"   # Optional. If not specified, it will use the current Azure context
     "SubscriptionId"        = "<Azure_subscription_ID>"              # Optional. If not specified, it will use the current Azure context
     "UseARMAPI"             = $true
     "ResourceGroupName"     = "<Resource_group_name>"                # Optional. Default: "WVDAutoScaleResourceGroup"
     "AutomationAccountName" = "<Automation_account_name>"            # Optional. Default: "WVDAutoScaleAutomationAccount"
     "Location"              = "<Azure_region_for_deployment>"
     "WorkspaceName"         = "<Log_analytics_workspace_name>"       # Optional. If specified, Log Analytics will be used to configure the custom log table that the runbook PowerShell script can send logs to
}

.\CreateOrUpdateAzAutoAccount.ps1 @Params

As soon as the script is finished you’ll see the Automation Account in the Azure portal

An image of the Azure overview page showing the newly created Azure Automation account and runbook.

Next thing to do is to create an run as account. Look up your automation account and click on Create

the next step is to create the logic app. Use the below code to download the script for this.

New-Item -ItemType Directory -Path "C:\Temp" -Force
Set-Location -Path "C:\Temp"
$Uri = "https://raw.githubusercontent.com/Azure/RDS-Templates/master/wvd-templates/wvd-scaling-script/CreateOrUpdateAzLogicApp.ps1"
# Download the script
Invoke-WebRequest -Uri $Uri -OutFile ".\CreateOrUpdateAzLogicApp.ps1"

Now we run the code to create the app and set the execution schedule. This has to be done per hostpool, but you can use the same automation account.

$AADTenantId = (Get-AzContext).Tenant.Id

$AzSubscription = Get-AzSubscription | Out-GridView -OutputMode:Single -Title "Select your Azure Subscription"
Select-AzSubscription -Subscription $AzSubscription.Id

$ResourceGroup = Get-AzResourceGroup | Out-GridView -OutputMode:Single -Title "Select the resource group for the new Azure Logic App"

$WVDHostPool = Get-AzResource -ResourceType "Microsoft.DesktopVirtualization/hostpools" | Out-GridView -OutputMode:Single -Title "Select the host pool you'd like to scale"

$LogAnalyticsWorkspaceId = Read-Host -Prompt "If you want to use Log Analytics, enter the Log Analytics Workspace ID returned by when you created the Azure Automation account, otherwise leave it blank"
$LogAnalyticsPrimaryKey = Read-Host -Prompt "If you want to use Log Analytics, enter the Log Analytics Primary Key returned by when you created the Azure Automation account, otherwise leave it blank"
$RecurrenceInterval = Read-Host -Prompt "Enter how often you'd like the job to run in minutes, e.g. '15'"
$BeginPeakTime = Read-Host -Prompt "Enter the start time for peak hours in local time, e.g. 9:00"
$EndPeakTime = Read-Host -Prompt "Enter the end time for peak hours in local time, e.g. 18:00"
$TimeDifference = Read-Host -Prompt "Enter the time difference between local time and UTC in hours, e.g. +5:30"
$SessionThresholdPerCPU = Read-Host -Prompt "Enter the maximum number of sessions per CPU that will be used as a threshold to determine when new session host VMs need to be started during peak hours"
$MinimumNumberOfRDSH = Read-Host -Prompt "Enter the minimum number of session host VMs to keep running during off-peak hours"
$MaintenanceTagName = Read-Host -Prompt "Enter the name of the Tag associated with VMs you don't want to be managed by this scaling tool"
$LimitSecondsToForceLogOffUser = Read-Host -Prompt "Enter the number of seconds to wait before automatically signing out users. If set to 0, any session host VM that has user sessions, will be left untouched"
$LogOffMessageTitle = Read-Host -Prompt "Enter the title of the message sent to the user before they are forced to sign out"
$LogOffMessageBody = Read-Host -Prompt "Enter the body of the message sent to the user before they are forced to sign out"

$AutoAccount = Get-AzAutomationAccount | Out-GridView -OutputMode:Single -Title "Select the Azure Automation account"
$AutoAccountConnection = Get-AzAutomationConnection -ResourceGroupName $AutoAccount.ResourceGroupName -AutomationAccountName $AutoAccount.AutomationAccountName | Out-GridView -OutputMode:Single -Title "Select the Azure RunAs connection asset"

$WebhookURIAutoVar = Get-AzAutomationVariable -Name 'WebhookURIARMBased' -ResourceGroupName $AutoAccount.ResourceGroupName -AutomationAccountName $AutoAccount.AutomationAccountName

$Params = @{
     "AADTenantId"                   = $AADTenantId                             # Optional. If not specified, it will use the current Azure context
     "SubscriptionID"                = $AzSubscription.Id                       # Optional. If not specified, it will use the current Azure context
     "ResourceGroupName"             = $ResourceGroup.ResourceGroupName         # Optional. Default: "WVDAutoScaleResourceGroup"
     "Location"                      = $ResourceGroup.Location                  # Optional. Default: "West US2"
     "UseARMAPI"                     = $true
     "HostPoolName"                  = $WVDHostPool.Name
     "HostPoolResourceGroupName"     = $WVDHostPool.ResourceGroupName           # Optional. Default: same as ResourceGroupName param value
     "LogAnalyticsWorkspaceId"       = $LogAnalyticsWorkspaceId                 # Optional. If not specified, script will not log to the Log Analytics
     "LogAnalyticsPrimaryKey"        = $LogAnalyticsPrimaryKey                  # Optional. If not specified, script will not log to the Log Analytics
     "ConnectionAssetName"           = $AutoAccountConnection.Name              # Optional. Default: "AzureRunAsConnection"
     "RecurrenceInterval"            = $RecurrenceInterval                      # Optional. Default: 15
     "BeginPeakTime"                 = $BeginPeakTime                           # Optional. Default: "09:00"
     "EndPeakTime"                   = $EndPeakTime                             # Optional. Default: "17:00"
     "TimeDifference"                = $TimeDifference                          # Optional. Default: "-7:00"
     "SessionThresholdPerCPU"        = $SessionThresholdPerCPU                  # Optional. Default: 1
     "MinimumNumberOfRDSH"           = $MinimumNumberOfRDSH                     # Optional. Default: 1
     "MaintenanceTagName"            = $MaintenanceTagName                      # Optional.
     "LimitSecondsToForceLogOffUser" = $LimitSecondsToForceLogOffUser           # Optional. Default: 1
     "LogOffMessageTitle"            = $LogOffMessageTitle                      # Optional. Default: "Machine is about to shutdown."
     "LogOffMessageBody"             = $LogOffMessageBody                       # Optional. Default: "Your session will be logged off. Please save and close everything."
     "WebhookURI"                    = $WebhookURIAutoVar.Value
}

.\CreateOrUpdateAzLogicApp.ps1 @Params

When the script is finished you’ll be able to see it in the portal

An image of the overview page for an example Azure Logic App.

Create a Azure Function to shutdown unused session hosts

The other option is to create an Azure Function to stop unused session hosts. Kudos for “Ciraltos” for creating this.

Go to the Azure portal and look for Function App and click on Create

The next steps are to make sure the functiopn app uses the powershell Az. Module and create a System Assigned Managed Identity.

In the left blade go to App Files and from the dropdown select “requirements.psd1” and remove the “#” from line 7.

to make sure the configuration changes are applied to the Function App you need to restart it.

Next we will configure the Managed Identity. To do this go to Idendity in the left blade and click on Identity. Now put the slider to “On” and click on Save.


Now you need to add the Role Assignment. The roles you need to assign are:

  • Desktop Virtualization Reader
  • Virtual Machine Contributor

Now it’s finally time to create the Azure Function. On the left click on functions and click create

Choose the Timer Trigger Template

In this example I’ll set the timer to 30 minutes.

Now go to the Code+Test blade and choose “Function.json” from the dropdown

Go back to the run.ps1 section and replace the code with the code

#Variables
## Update "HostPool" value with your host pool, and "HostPoolRG" with the value of the host pool resource group.
## See the next step if working with multiple host pools.
$allHostPools = @()
$allHostPools += (@{
        HostPool   = "<HostPoolName>";
        HostPoolRG = "<HostPoolResourceGroup>"
    })

You can use the code to manage multiple hostpools also by adding these to the script.

The script will list all the session host that have active connections.

$count = 0
while ($count -lt $allHostPools.Count) {
    $pool = $allHostPools[$count].HostPool
    $poolRg = $allHostPools[$count].HostPoolRG
    Write-Output "This is the key (pool) $pool"
    write-output "this is the value (rg) $poolRg"
    # Get the active Session hosts
    try {
        $activeShs = (Get-AzWvdUserSession -ErrorAction Stop -HostPoolName $pool -ResourceGroupName $poolRg).name
    }
    catch {
        $ErrorMessage = $_.Exception.message
        Write-Error ("Error getting a list of user sessions: " + $ErrorMessage)
        Break
    }
    
    $allActive = @()
    foreach ($activeSh in $activeShs) {
        $activeSh = ($activeSh -split { $_ -eq '.' -or $_ -eq '/' })[1]
        if ($activeSh -notin $allActive) {
            $allActive += $activeSh
        }
    }
    # Get the Session Hosts
    # Exclude servers in drain mode and do not allow new connections
    try {
        $runningSessionHosts = (Get-AzWvdSessionHost -ErrorAction Stop -HostPoolName $Pool -ResourceGroupName $PoolRg | Where-Object { $_.AllowNewSession -eq $true } )
    }
    catch {
        $ErrorMessage = $_.Exception.message
        Write-Error ("Error getting a list of running session hosts: " + $ErrorMessage)
        Break
    }
    $availableSessionHosts = ($runningSessionHosts | Where-Object { $_.Status -eq "Available" })
    #Evaluate the list of running session hosts against 
    foreach ($sessionHost in $availableSessionHosts) {
        $sessionHostName = (($sessionHost).name -split { $_ -eq '.' -or $_ -eq '/' })[1]
        if ($sessionHostName -notin $allActive) {
            Write-Host "Server $sessionHostName is not active, shut down"
            try {
                # Stop the VM
                Write-Output "Stopping Session Host $sessionHostName"
                Get-azvm -ErrorAction Stop -Name $sessionHostName | Stop-AzVM -ErrorAction Stop -Force -NoWait
            }
            catch {
                $ErrorMessage = $_.Exception.message
                Write-Error ("Error stopping the VM: " + $ErrorMessage)
                Break
            }
        }
        else {
            write-host "Server $sessionHostName has an active session, won't shut down"
        }
    }

Now save the script and try it out. All your session host that don’t have active sessions will be turned off saving you money.

Start VM on Connect

We have talked about shutting down unused session hosts but how can the user access them while they are turned off. Start VM on Connect to the rescue.

This feature will make sure that if a session host is turned off (Deallocated)a user will be able to start it.

Requirements

Start VM on Connect needs a custom role. Go to the scubscription and click on IAM and add a custom role.

A screenshot of a drop-down menu from the Add button in Access control (IAM). "Add a custom role" is highlighted in red.

The permissions that are needed are:

  • Microsoft.Compute/virtualMachines/start/action
  • Microsoft.Compute/virtualMachines/read

Now you need to assign the role to Azure Virtual Desktop. Go to IAM and look up the custom role you created and assign it.

A screenshot of the Access control (IAM) tab. In the search bar, both Azure Virtual Desktop and Azure Virtual Desktop (classic) are highlighted in red.

You can also create the custom role with a Json template.

Configure Start VM on Connect

This can be done in the Azure Portal. Go to your hostpool properties and put the slider to Yes.

A screenshot of the Properties window. The Start VM on connect option is highlighted in red.

You can also do this via Powershell

Update-AzWvdHostPool -ResourceGroupName <resourcegroupname> -Name <hostpoolname> -StartVMOnConnect:$true

To disable the feature via Powershell use the following code.

Update-AzWvdHostPool -ResourceGroupName <resourcegroupname> -Name <hostpoolname> -StartVMOnConnect:$false

There you go, the most used methods to save costs for your Azure Virtual Desktop deployment. These are settings that you have to configure your self. If you don’t want to do this I suggest you take a look at some third party tools for AVD like Nerdio. They do this all for you with their Azure Virtual Desktop manager.

Thank you for reading this blogpost and see you next time.

Leave a Reply

Your email address will not be published. Required fields are marked *