One value proposition of any cloud service is consumption-based pricing, only paying for services when used. Consumption-based pricing is an advantage of Windows Virtual Desktop (WVD), Microsoft Azure-hosted remote desktop service. Or at least it would be if there was an easy way to start and stop session hosts based on demand.

Windows Virtual Desktop has a script referenced in the online documentation. It looks similar to a script used to start and stop on-premises RDS Session Hosts. I reviewed this script and, although it may fit some environments, there were elements that I didn’t like about it.

The first problem I ran into was the script’s complexity. It has many options that make the script difficult to understand. The biggest issue for me, however, is that the script had to run as a scheduled task on a server. This meant keeping an IaaS server running for the script to work. That seemed like an unnecessary resource for a cloud service.

With that in mind, I set out to write a script to save on WVD charges by shutting down Session Hosts when they are not in use and start Session Hosts as demands increased.

I began by developing the script for Azure Functions. I quickly discovered that the WVD PowerShell module will not work with PowerShell 6 and had to move development to Azure Automation. I still use a function to trigger the Azure Automation Runbook. More on that to come.

The number of options in this script is reduced to simplify it. The Microsoft Script provides options to use a user account or a service principle, changes from depth-first to breadth-first during peak hours and several other options. I did away with much of that while still providing the stated functionality.

Code for this project can be found on my GitHub site here.

Guidelines for Using the Script

Test it before you trust it, this script is offered as-is with no warranty, expressed or implied.

The script works with depth-first load balancing, during and outside peak hours. The goal is to save on cloud costs. The best way to do that is with depth-first load balancing. If you are not familiar with the load balancing options, see my video on the subject here.

Depth-first requires a maximum number of sessions per session host. It is important to size the session hosts correctly for the workload and number of sessions on the Session Hosts to provide a good user experience.

This script only uses a Service Principle. There is no option for a user account. I have another video here that goes over setting up a Service Principal for WVD. This is the Service Principal that the script uses to interact with WVD and start and stop session hosts.

The script will only work for one host pool. If you have multiple host pools, configure a host pool for each.

This script will not provision new session hosts. It will only start and stop existing servers based on the number of sessions to the pool. Remember, there is little cost associated with having servers at the ready and deallocated.

The script won’t factor in servers set not to allow new connections. Servers with the host pool option -allownewsessions set to False will not be started or stopped. Any active connections on these servers will not be factored into the logic to start or stop servers.

The script will only start or stop one server at each run.

Logic

The logic behind the script is fairly easy. The basic functionality is to determine the number of session hosts that should be running and compare that to the number that is running. If there should be more running than there is, a session host will start. If there are more session hosts running than need to be, it will attempt to shut one down.

There are a couple of numbers that go into determining the number of servers that should be running. First is the number of active sessions. Find this by adding the number of active sessions from each session hosts.

The next number is the threshold value. The threshold value acts as a buffer to provide session capacity between script runs. If the number of available sessions falls below the threshold value, a new server will start.

The last number is the maximum number of connections per session hosts. The maximum connections are defined when configuring depth-first load balancing.

The number of session hosts that should be running is found by adding the number of active sessions and the threshold value, then dividing that sum by the maximum number of connections per session host.

(Active Sessions + Threshold) / Max Connections

It’s not possible (yet) to start a fraction of a server, so any fraction is rounded up using the ceiling function.

For example, if there are 24 active sessions in a host pool, and the threshold is 8, with 16 max sessions per session host, the total number of running servers is two.

(24+8)/16=2

If one more user logs in, the number of required hosts is increased by one, and another session host is started:

(25+8)/16=3 (rounded up to next whole number)

After a while, several users log off. This brings the currently active users to 19 and the number of required servers back to two.

(19+8)/16=2

The decrease in users will trigger a server shutdown. The script looks for a server with no active sessions. This is important because users don’t log out in the same order they log in. There is the potential that all running servers have active connections. If the script finds a server without active connections, it will shut that server down.

Peak Hours

A greater number of available session hosts are sometimes required to meet higher demand during peak use time. The script defines a peak time range and weekday. The threshold can be adjusted to accommodate a higher demand during that time. Increasing the threshold value will create more available sessions between script run.

Extending our previous example, if the requirement is to have one server worth of sessions available during peak hours, set the threshold to 17 (max sessions + 1). That will keep an extra server running to accommodate logins during active periods.

Deployment

The items listed in this section is an overview of the prerequisites and process of deploying the script. See my video for a walkthrough of the full setup.

Group Policy

There is no way to log out disconnected or idle sessions in WVD. By default, disconnected and idle sessions will exist indefinitely, preventing servers from shutting down. Set limits on idle and disconnected sessions with a Group Policy Object (GPO). Create a GPO and modify the settings under Computer Configuration > Policies > Administrative Templates > Windows Components > Remote Desktop Services > Remote Desktop Session Hosts > Session Time Limits. Enable and set the limit for disconnected sessions and limit for active but idle RDS sessions. Exact limits vary by environment.

Apply this GPO to the session host OU in Active directory once created.

Azure Automation

This script requires an Azure Automation account. These are easy to set up, and cost is minimal to run. Find more information on setting up an Azure Automation account at my playlist here.

The script uses the Az PowerShell Module and the WVD PowerShell Module. Newly deployed Azure Automation accounts have the AzureRM module installed by default. All three of the modules below are required for the script and need to be added to the Azure Automation account.

Az.Accounts

Az.Compute

Microsoft.RDInfra.RDPowershell

Service Principle

The service principle is used to retrieve session and host information from WVD. It is also used to log into Azure and run the stop or start command. There are two steps to make this work.

First, add a credentials object to Azure Automation using the Application ID and Password of the Service Principle used to deploy WVD. The password was captured during set up. If not, create a new one from the App Registrations properties for the object in Azure AD.

Second, the Service Principle must have Contributor rights to the Session Host Resource Group. The same service principle is used to log into Azure and interact with the session hosts. It needs Contributor RBAC rights to the resource group to start and stop with the VM’s.

Azure Function

Using Azure Functions is my least favorite part of this solution. The script should run every 5 minutes. The minimum reoccurrence for schedule in Azure Automation is 1 hour. That is not short enough to be effective. Azure Functions can run every 5 minutes and trigger the webhook. This webhook starts the Azure Automation Runbook.

Start by publishing the Azure Automation runbook and going to Add Webhook. Create and save the Webhook. You will not be able to retrieve the once the creation window closes.

Create a new consumption-based PowerShell Azure Function App. Add a new time-based trigger int the Azure Function app. Use the following schedule to run the function app every 5 minutes:

"0 */5 * * * *"

Go to the Run.PS1 script and removing everything but the parameter section of the script. Add the line below to trigger the webhook.

Invoke-WebRequest -Uri <Webhook URI> -Method Post

Costs to Run

The cost of this solution is dependent on factors including time between each run, region, and runtime of the script. The costs listed below are a demonstration only and not intended for actual pricing.

Azure Automation running for one minute, every 5 minutes each month.

12 times per hour * 24 hours * 30 days = 8640 minutes a month.

Azure Runtime price = $0.002/minute

$17.28 per month.

An Azure Consumption-Based Function provides 1 million free executions per month. The Azure Function should not exceed the free allotment.

Summary

This script accomplished my goal of starting and stopping servers based on user load. Please note that testing took place in a small environment of 3 servers. Although the size of the environment should not matter, there may be some unexpected results in large environment.

Logging and alerting could be enhanced in this script. For example, I posted information here on sending alerts to a Teams channel. There is another post here on using Send Grid to send alerts from PowerShell. Either one could be used to send notifications when the script makes a change.