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.
27 thoughts on “Automatically Start and Stop WVD VM’s with Azure Automation”
Am I missing something? A service principal as created with New-AzureAdApplication as per the MS WVD doc and your blog post cannot be added as a Contributor for the RSG in Azure. When adding in the Azure portal the account is not found using either the displayname or Appid. Any ideas?
Sounds like the app wasn’t created. Verify that application was created in Azure AD under app registrations. If you have multiple tenants, maybe it was created in a different tenant?
Great job on the script!
I made some improvements to your script. That allow you to stop multiple VM’s or start multiple VM’s and loops until it gets to the desired threshold state. Additionally, changed all of the write-verbose to write-output so that information could be captured in the Azure Runbooks logs for ease of use and to push to Azure Analytics.
That’s awesome, thanks for doing that!
https://github.com/KandiceLynne/AzureRunbooks/blob/master/AutoScaleWVD.ps1
My pleasure Travis! FYI the script with that has been tested and is currently used in a production environment.
Thanks Travis for putting this out there! It was really what I was looking for. Thanks to Kandice Hendricks as well. I have combined the new way Microsoft did this witih a Logic App with Kandice’s version of the script and it works great! You setup all the variables in the Logic App and the runbook uses them. it’s pretty slick and you don’t need all the extra stuff a Azure Function App needs if you don’t have any existing function apps. I also setup it up to use a RunAsAccount instead of a “custom” service principal. Much easier than creating your own service principal, since it does all the Azure pieces for you. You just need to add the RunAsAccount as a “RDS Contributor” in you WVD Tenant. Here’s a link to my gitbhub:
https://github.com/eulogious/AzureRunbooks
Thanks again Travis and Kandice!
That’s great to here. Thanks for sharing.
Donald
This is great
Does this need the Logic app created in advance or it creates everything like the MS “tool” does?
Donald,
Thanks so much for this enhancement! I will have to try it out…..nothing like good technical teamwork to improve functionality.
Regards,
Kandice Hendricks
Hi Travis. I’m not yet on WVD, but planning on making a move to it soon. Right now I have an RDS farm in Azure that I am manually starting / stopping daily in order to reduce costs. Would the original script you referenced assist me with automating this? i’m looking to stop all new connections on certain hosts at 5pm, allow the connections to drain, then once a server has zero connections, shut it down. Then at 6am the next morning I’ll have the server turned on and allow connections again. Thanks for any assistance.
Here is a script for scaling Session Hosts in RDS running in Azure. I used it a long time ago and I remember it working well once I got it configured. I may have ran it from a Connection Broker at the time.
https://gallery.technet.microsoft.com/scriptcenter/Automatic-Scaling-of-9b4f5e76
For some reason its not kicking off the correct amount during peak time.
Wouldn’t that CRON expression mean it runs every 5th hour, not minute?
The default when creating a new function is every 5 minutes.
Hi …thanks for the amazing script…since we have start vm on connect feature now on pooled wvd so I looking for script to only shutdown or stop VMS whcih doesn’t have user session post business hour …appreciate it if you can write blog for the same.
Hello Mahammad,
I posted information on shutting down unused VM’s in a pooled host pool a couple weeks ago. I have not had a chance to test it against pooled hosts pools but I did see some comments that it works with a pooled host pool as well. I have plans to update the script with peak hours as well. I’m a bit backlogged but hoping to get that done in a few weeks.
https://www.ciraltos.com/shut-down-unused-session-hosts-in-a-windows-virtual-desktop-personal-host-pool/
Thanks
Travis
Travis, i am running into weird issue
$tenantName = ‘LE&V Company, Inc.’
error: get-rdssessionhost : Tenant name ‘LE&V Company, Inc’ has invalid characters at position 2
I tired all possible escape characters, but it is still the same. Any suggestions pls?
I suspect it’s having problems with the &. For Azure tenants, special characters are not allowed. If possible, switch to the newer version of WVD that doesn’t use a tenant, or, create a new tenant without the special characters.
-Travis
Hi Travis,
I am trying to set this up on my environment with Azure Virtual Desktops but I am getting this error. Any thoughts or ideas?
2023-11-22T20:40:30Z [Error] ERROR: Error getting a list of user sessions: The term ‘Get-AzWvdUserSession’ is not recognized as a name of a cmdlet, function, script file, or executable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.
Exception :
Type : Microsoft.PowerShell.Commands.WriteErrorException
Message : Error getting a list of user sessions: The term ‘Get-AzWvdUserSession’ is not recognized as a name of a cmdlet, function, script file, or executable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.
HResult : -2146233087
CategoryInfo : NotSpecified: (:) [Write-Error], WriteErrorException
FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,_Gyver_TimerTriger_
InvocationInfo :
MyCommand : _Gyver_TimerTriger_
HistoryId : 1
InvocationName : _Gyver_TimerTriger_
CommandOrigin : Internal
ScriptStackTrace : at , C:\home\site\wwwroot\Gyver_TimerTriger\run.ps1: line 67
PipelineIterationInfo :