Shut Down Unused Session Hosts in a Windows Virtual Desktop Pooled or Personal Host Pool

WVD Function App

(UPDATED 6/17/2021 with code for multiple pooled and personal host pools) Azure Virtual Desktop (previously Windows Virtual Desktop (WVD)) has a new option in preview that starts session hosts in a personal or pooled host pool when a user connects. It won’t, however, shut down the session hosts when the user logs out. The script outlined in this video will evaluate all running and available session hosts in a personal or pooled host pool and shut down and deallocate session hosts without an active connection.  Deallocating Session hosts while not in use can save money on compute costs.

This script is intended to be used with the auto start on connect preview feature.  It uses an Azure Function with a system assigned managed identity to query AVD/WVD and shut down the Session Hosts.  It uses an Azure Function because support for a managed identity is GA, and the schedule can run more frequently than every hour.  The script could also run in an Azure Automation runbook.

Auto Start WVD Pooled and Personal Host Pool Session Hosts:
https://github.com/tsrob50/WVD-Public/blob/master/StopSH-MultiHostPools.ps1

One important step required for this script is a GPO, or some other method to set a time limit on disconnected sessions in the Host Pool.  The script will not shut down Session Hosts with any type of active session, including disconnected sessions. 

Also, I recommend not running the script more frequently than every 30 minutes.  There is a short window when a user connects, and the Session Host has started, but the login has not fully processed.  The script could interpret the Session Host as powered on with no active connects and shut it down. 

Disconnect GPO

Start by creating a GPO that sets a time limit for disconnected sessions and apply it to the Session Host OU.  The settings are located in:

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

At minimum, enable “Set time limit for disconnected sessions” to a time limit that works for your organization.  Set it too high, and charges will accumulate for resources not in use, set it too low and end users may get frustrated when they step away for a short time and have to wait for their Session Host to start.

GPO Settings

Also, consider enabling “Set time limit for active but idle Remote Desktop Services session”.  Configuring that setting will disconnect a user who may have stepped away and left the remote desktop client open. 

Set the Max Session Limit

Auto Start on Connect will only power on a Session Host in a Pooled Host Pool if no Session Host are powered on or, if the powered on Session Hosts have reached the Max Session Limit. A Pooled Host Pool in Breadth-First Load Balancing requires a max session limit when used with the start on connect feature.

Create Function App

Let’s start by creating the function app.  From the Azure Portal, go to Function Apps and add a new function app.

Create a new Function App

In the Create Function App page, create a new or use an existing resource group and give the Function app a name.  Set the runtime stack to PowerShell core and set your region.  Once finished, it should look similar to the image below.

Create Function App Settings

Hosting, Monitoring, and tags can be left as default.  Go to Review and Create to deploy the Function App.

Go to the resource when finished.

Go to Resource

Configure the Function App

Next, we will configure the function app to use the PowerShell Az. Module and configure a System Assigned Managed Identity.

Configure Az Module

From the Function App, go to App files under Functions.

App Files

From the dropdown, select the requirements.psd1 file and remove the “#” from the line: ‘Az’ = ‘5.*’.

Requirements.psd1

Click Save and go back to the Function App Overview.

From the overview page, restart the function app.  This will apply configuration changes we just made.

Restart Function App

Configure the Managed Identity

Next, we’ll configure the managed identity used by the Function App to interact with resources in the subscription.

Go to Identity under Settings in the Function App.

Settings Identity

From the System Assigned Identity page, change the status to On and save, that will create a new identity, and the Object ID will display similar to below, without the blur.

System Assigned Identity

Next, we give the identity rights to view properties of the WVD environment and manage virtual machines in the subscription.  Go to Add Role Assignments.

From the Add Role Assignments page, click Add Role Assignment (preview).

Add Role Assignments

On the next page, set the Scope to Subscription, Verify the correct subscription is added, and search for and select Desktop Virtualization Reader.

Add Roles

Click Save to add the Desktop Virtualization Reader role. 

Follow the same steps to add the Virtual Machine Contributor role.

Virtual Machine Contributor

Once finished, go back to the Function App.

Create the Function

Next, we’ll create the Function.  A Function App can have many functions of the same type.  For example, we can have multiple PowerShell Functions in this Function App. 

From the Azure Function App, go to Functions under Functions.

Functions

Click Add to add a Function.

Add a Function

From the list of templates, select Timer Trigger.   This will trigger the Function based on a schedule.

Timer Trigger

Go to Template Details, give it a new name if needed, then change the schedule by replacing the 5 with a 30.  This will set the timer to run every 30 minutes on the hour.  You can change this to a different frequency, 45 to run every 45 minutes for example.

Function App Schedule

See the link below for more details on scheduling for a Function App.
https://docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer?tabs=csharp&WT.mc_id=AZ-MVP-5004159#ncrontab-expressions

Click Add to add the function app.

From within the Function, go to Code + Test under Developer.

Code + Test

If you need to change the schedule, go to the Function.json file in the Function drop-down.

Function json

Change the schedule as needed.

Go back to the Run.ps1 file and replace the existing code with the StopSH-MultiHostPools.ps1 code. The code can be found here:
https://github.com/tsrob50/WVD-Public/blob/master/StopSH-MultiHostPools.ps1

Go to the Variables section.  Update the Host Pool name and Resource Group for the Personal or Pooled Host Pool. 

If the function is running againt multple Pooled or Personal Host Pools, copy the block of code from the commends and paste it imediatly after the block updated in the previous step.

Host Pool Code Block

Add a new block of code for each host pool targeted by the function. When finished, the variable section will look similar to the code below.

Two Host Pools

When the Function runs, a while loop is used for each host pool. It starts by getting all the active sessions in the Host Pool.  It then creates a list of all active Session Host names, using a for each loop to remove duplicate values

Get Active Session Hosts

Next, it creates a list of all Session Hosts that are powered on and not in drain mode.  Setting drain mode will exclude the Session Host from the script. This way, if a Session Host has to be on for a few hours for maintenance, such as to pick up software updates, the script won’t shut it down.

It then compares the Session Hosts that are powered on with the Session Hosts that have active connections.  If the Session Host is on but has no active connections, it’s shut down.

Shut Down Session Hosts with no Sessions

After that, the script loops through the next instance of a Host Pool until the end.

Once updated with your Host Pool and Resource Group information, save the Function.

Run the Script

Test it before you trust it!

My lab had three Session Hosts. All were powered on and available with two users logged in.

Test Pool Before Script Run

Open logs in the Function so we can see the activity.

Open Logs

Check the time!  As configured in this example, the script will run every 30 minutes on the hour.  Close to run time, you can wait for it to run on its own. 

Otherwise, go to Run/Test and click Run to start it manually.

Test Run
Run the Function Manually

Close the Run window after it starts.

The Function will take a couple of extra minutes to finish on the first run as it has to download and add the PowerShell Az module.

Download PowerShell Az Module

Once it finishes running, the output will indicate any Session Hosts that are shut down in the function output.

Function Output

My example now shows one Session Host is off and unavailable.  With Start VM on Connect enabled, the Session Host will start the next time an assigned user logs in.

Session Host Unavailable

That, my friends, is how to shut down Session Hosts in a Personal Host Pool with no active sessions. 

27 thoughts on “Shut Down Unused Session Hosts in a Windows Virtual Desktop Pooled or Personal Host Pool

  1. Great stuff! Now that autostart on connect is available for pooled hostpools as well, could we adapt this for that use case?

  2. Hi Travis! Wonderful script! I am using in a Pooled, but I had a little problem with the name of the VM. In the Pooled Host Pool, the VM Name is not the ComputerName because it uses a prefix name of the host pool. When the script tries to shutdown the VM, It is using the ComputerName. (—> (($sessionHost).name). I dont know if this mess is my fault, because I didnt use the computer name in my AD with the prefix name of VDI. So, Windows VDI shows my AD ComputerName in the Host Pool, but, outside of the VM, that is what care for the script to shut it down… it does not works.
    Is there a way to bring convert my Computer Name to the VM Name ? Please, if you can…. let me know (I would like to contribute ($) if you could help me! Thanks!

  3. Hi Travis, is there a possibility that this Function App will work if the managed ID only has permissions on the Resource Group instead of the whole Subscription? In the organization I currently work for I am unable to set permissions on a subscription level, because of security precautions. I am able to set permissions on the Resource Group where the AVD host pool is though.

    1. I haven’t tested that but it should work. Desktop virtualization reader will need to be assigned to the RG of the host pool and virtual machine contributor needs needs to be assigned to the RG the VM’s are in. They may both be in the same RG, but could be different.

      1. Thank you for your reply. I can confirm that this script also works with permissions on RG level. It seems that when you have multiple subscriptions in your tenant, it will randomly pick the first available subscription. This was not the subscription where my session hosts were running, so that’s why it failed.
        I was able to solve this by adding the -SubscriptionId in the Get-AzWvdUserSession and Get-AzWvdSessionHost lines.
        It now works like a charm!

  4. I like the auto shutdown of session hosts provided by this solution. I am also looking for a way of having session hosts start when total session count reaches a certain threshold across all active session hosts. This would alleviate the users having to wait for session hosts to start or at least lower the time. It seems this process could be used to start them as well with some code modification. The script could count the total max session limit across all session hosts. If total session count was less then 5 from the max, start another session host. Does this sound possible or am I going down a rabbit hole? I was looking into scale sets too, but it seems to be based around percent of processor which doesn’t meet my requirements.

      1. Thanks! It’s like you created this script based on my requirements 🙂 I throw it in the dev subscription and kick the tires.

  5. Thanks! It’s like you created this script based on my requirements 🙂 I’ll throw it in the dev subscription and kick the tires.

    I would like to have a configurable variable to keep x session hosts running. I don’t see that baked into the script but it would be nice. Lets say peak time is 6AM-6PM. You have some rogue user that logs in at 5:00AM and then has to wait for the server to spin up. If at least one host was running it would be the optimal end user experience.

  6. Hello Travis,

    As far as i read the topic i only needed to change 2 parrameters right?
    I am tryinjg to apply this script on a test environment but getting below error, what am i doing wrong am i missing something?
    2021-07-06T11:53:22.713 [Error] Timeout value of 00:05:00 exceeded by function ‘Functions.TimerTrigger1’ (Id: ‘8dadd015-612a-46b9-ba50-2bcdc77c210e’). Initiating cancellation.
    2021-07-06T11:53:22.779 [Error] Executed ‘Functions.TimerTrigger1’ (Failed, Id=8dadd015-612a-46b9-ba50-2bcdc77c210e, Duration=300112ms)Timeout value of 00:05:00 was exceeded by function: Functions.TimerTrigger1

    Hope to hear from you.

    1. The error indicates the script is running beyond the 5 minute limit. It should not take that long for the script to finish, unless you have a very large environment. For a single host pool, only the host pool name and resource group needs to be updated. If there are multiple host pools, additional $allhostpools blocks need to be added.
      Have you tried to restart the function app?

      1. I tried to restart it and after that it was in the same state.
        The current environment is only 2 AVD for testing purposes.
        Can i see the output of the script where it is hanging on or debug it on my local powershell somehow?
        I had the AZ requirement set to 5 as your print screen states as the standard was 6 but this also did not change anything.
        I am kind of new to this so perhaps i miss something somewhere.

  7. Hi Travis,

    I kept testing and found out this has nothing to do with your script (Offcourse as others said it was wokring)
    I was having a other Script which worked on a other Tenant but is not working in this tenant.
    So i think you will not be able to help me in this but thank you for the script!

  8. Hi Travis!
    I am using your script to a single hostpool pooled (Depth), and sometimes, it is shuting down the vms with Active Sessions. Could you help me?

    “timestamp [UTC]”,message,logLevel
    “7/15/2021, 7:30:00.040 PM”,”Executing ‘Functions.CheckWVDVazio’ (Reason=’Timer fired at 2021-07-15T19:30:00.0127369+00:00′, Id=c051a287-e33e-42ec-ac1e-b8e6f90e90b1)”,Information
    “7/15/2021, 7:30:15.028 PM”,”OUTPUT:”,Information
    “7/15/2021, 7:30:15.358 PM”,”OUTPUT: Account SubscriptionName TenantId Environment”,Information
    “7/15/2021, 7:30:15.359 PM”,”OUTPUT: ——- —————- ——– ———–“,Information
    “7/15/2021, 7:30:15.361 PM”,”OUTPUT:”,Information
    “7/15/2021, 7:30:15.361 PM”,”OUTPUT: MSI@50342 Microsoft Azure (Nononono): #1480349 xxxxxx-yyyy-0000-0000-0000000 AzureCloud”,Information
    “7/15/2021, 7:30:18.527 PM”,”INFORMATION: Loaded Module ‘Az.Accounts'”,Information
    “7/15/2021, 7:30:25.468 PM”,”INFORMATION: Loaded Module ‘Az.DesktopVirtualization'”,Information
    “7/15/2021, 7:30:26.566 PM”,,Error
    “7/15/2021, 7:30:26.566 PM”,”ERROR: Collection was modified; enumeration operation may not execute.

    Exception :
    Type : System.InvalidOperationException
    TargetSite :
    Name : ThrowInvalidOperationException_InvalidOperation_EnumFailedVersion
    DeclaringType : System.ThrowHelper, System.Private.CoreLib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e
    MemberType : Method
    Module : System.Private.CoreLib.dll
    StackTrace :
    at System.Collections.Generic.Dictionary`2.Enumerator.MoveNext()
    at System.Net.Http.Headers.HttpHeaders.GetEnumeratorCore()+MoveNext()
    at Microsoft.WindowsAzure.Commands.Utilities.Common.GeneralUtilities.ConvertHttpHeadersToWebHeaderCollection(HttpHeaders headers)
    at Microsoft.WindowsAzure.Commands.Utilities.Common.GeneralUtilities.GetHttpRequestLog(String method, String requestUri, HttpHeaders headers, String body, IList`1 matchers)
    at Microsoft.WindowsAzure.Commands.Utilities.Common.GeneralUtilities.GetLog(HttpRequestMessage request, IList`1 matchers)
    at Microsoft.WindowsAzure.Commands.Utilities.Common.GeneralUtilities.GetLog(HttpRequestMessage request)
    at Microsoft.Azure.Commands.Common.AzModule.c__DisplayClass21_0.b__0()
    at Microsoft.Azure.PowerShell.Cmdlets.DesktopVirtualization.Cmdlets.GetAzWvdUserSession_List.c__DisplayClass66_0.b__1()
    at Microsoft.Azure.PowerShell.Cmdlets.DesktopVirtualization.Cmdlets.GetAzWvdUserSession_List.Microsoft.Azure.PowerShell.Cmdlets.DesktopVirtualization.Runtime.IEventListener.Signal(String id, CancellationToken token, Func`1 messageData)
    at Microsoft.Azure.Commands.Common.AzModule.OnBeforeCall(String id, CancellationToken cancellationToken, Func`1 getEventData, Func`4 signal, String processRecordId)
    at Microsoft.Azure.Commands.Common.AzModule.EventListener(String id, CancellationToken cancellationToken, Func`1 getEventData, Func`4 signal, InvocationInfo invocationInfo, String parameterSetName, String correlationId, String processRecordId, Exception exception)
    at Microsoft.Azure.PowerShell.Cmdlets.DesktopVirtualization.Module.Signal(String id, CancellationToken token, Func`1 getEventData, Func`4 signal, InvocationInfo invocationInfo, String parameterSetName, String correlationId, String processRecordId, Exception exception)
    at Microsoft.Azure.PowerShell.Cmdlets.DesktopVirtualization.Cmdlets.GetAzWvdUserSession_List.Microsoft.Azure.PowerShell.Cmdlets.DesktopVirtualization.Runtime.IEventListener.Signal(String id, CancellationToken token, Func`1 messageData)
    at Microsoft.Azure.PowerShell.Cmdlets.DesktopVirtualization.DesktopVirtualizationClient.UserSessionsListByHostPool_Call(HttpRequestMessage request, Func`3 onOk, Func`3 onDefault, IEventListener eventListener, ISendAsync sender)
    at Microsoft.Azure.PowerShell.Cmdlets.DesktopVirtualization.DesktopVirtualizationClient.UserSessionsListByHostPool_Call(HttpRequestMessage request, Func`3 onOk, Func`3 onDefault, IEventListener eventListener, ISendAsync sender)
    at Microsoft.Azure.PowerShell.Cmdlets.DesktopVirtualization.DesktopVirtualizationClient.UserSessionsListByHostPool(String subscriptionId, String resourceGroupName, String hostPoolName, String Filter, Func`3 onOk, Func`3 onDefault, IEventListener eventListener, ISendAsync sender)
    at Microsoft.Azure.PowerShell.Cmdlets.DesktopVirtualization.Cmdlets.GetAzWvdUserSession_List.ProcessRecordAsync()
    at Microsoft.Azure.PowerShell.Cmdlets.DesktopVirtualization.Cmdlets.GetAzWvdUserSession_List.ProcessRecordAsync()
    Message : Collection was modified; enumeration operation may not execute.
    Source : System.Private.CoreLib
    HResult : -2146233079
    CategoryInfo : NotSpecified: (:) [Get-AzWvdUserSession_List], InvalidOperationException
    FullyQualifiedErrorId : Microsoft.Azure.PowerShell.Cmdlets.DesktopVirtualization.Cmdlets.GetAzWvdUserSession_List
    InvocationInfo :
    MyCommand : Get-AzWvdUserSession_List
    ScriptLineNumber : 47
    OffsetInLine : 1
    HistoryId : 1
    ScriptName : C:\home\site\wwwroot\CheckWVDVazio\run.ps1
    Line : $activeShs = (Get-AzWvdUserSession -HostPoolName $personalHp -ResourceGroupName $personalHpRg).name

    PositionMessage : At C:\home\site\wwwroot\CheckWVDVazio\run.ps1:47 char:1
    + $activeShs = (Get-AzWvdUserSession -HostPoolName $personalHp -Resourc …
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    PSScriptRoot : C:\home\site\wwwroot\CheckWVDVazio
    PSCommandPath : C:\home\site\wwwroot\CheckWVDVazio\run.ps1
    InvocationName : Get-AzWvdUserSession
    CommandOrigin : Internal
    ScriptStackTrace : at Get-AzWvdUserSession, C:\home\data\ManagedDependencies\210709125841223.r\Az.DesktopVirtualization\3.0.0\exports\ProxyCmdletDefinitions.ps1: line 2220
    at , C:\home\site\wwwroot\CheckWVDVazio\run.ps1: line 47
    PipelineIterationInfo :”,Error
    “7/15/2021, 7:30:27.240 PM”,”INFORMATION: Server iwvmbr-0 is not active, shut down”,Information
    “7/15/2021, 7:30:30.849 PM”,”OUTPUT:”,Information
    “7/15/2021, 7:30:31.514 PM”,”INFORMATION: Server iwvmbr-1 is not active, shut down”,Information
    “7/15/2021, 7:30:31.877 PM”,”OUTPUT: RequestId IsSuccessStatusCode StatusCode ReasonPhrase”,Information
    “7/15/2021, 7:30:31.878 PM”,”OUTPUT: 6b54ab6e-5440-44b5-b4bf-388b36d8bd0c True Accepted Accepted”,Information
    “7/15/2021, 7:30:31.878 PM”,”OUTPUT: ——— ——————- ———- ————“,Information
    “7/15/2021, 7:30:31.878 PM”,”OUTPUT: 8b4af1a6-11c0-4feb-81c9-010b174387d1 True Accepted Accepted”,Information
    “7/15/2021, 7:30:32.069 PM”,”INFORMATION: Server iwvmbr-2 is not active, shut down”,Information
    “7/15/2021, 7:30:32.302 PM”,”OUTPUT: a0baac22-164b-4105-a776-c59182fff1b5 True Accepted Accepted”,Information
    “7/15/2021, 7:30:32.681 PM”,”OUTPUT:”,Information
    “7/15/2021, 7:30:32.713 PM”,”Executed ‘Functions.CheckWVDVazio’ (Succeeded, Id=c051a287-e33e-42ec-ac1e-b8e6f90e90b1, Duration=32693ms)”,Information

    1. Hello Denilson,
      Sorry you’re having problems. I reviewed the error and see what’s happening but not sure why. The get-azwvdusersession command is failing for some reason. That sets user sessions to nothing and the logic later in the script acts as if no one is logged in and shuts down the SH’s.
      I’m reviewing and trying to replicate the issue but not having any luck. You could try to restart the function app or start over with a new function app. I’ll let you know if I find a solution.
      Thanks,
      Travis

      1. Hi Travis! Dont need to apologizes! Your stuffs are amazing!

        I am updating with your new code. If it is possible, a suggestion on error. Is it possible to send an notification? Imagine If the function fail… and my hostpool keep running… $$$ kkkkk
        In time… I am trying to change the TImer Trigger. Could you tell me if this syntax is right:
        “0 */30 0-8,19-23 * * *”
        I will schedule to run this function only 0-8 AM and 7-11Pm. Is it right? 🙂
        Thanks
        Denilson

  9. hello Travis, love your work
    I am quite keen to get this going but I get the following , not sure why? Any help is appreciated

    Connected!
    2021-08-26T03:48:29 Welcome, you are now connected to log-streaming service. The default timeout is 2 hours. Change the timeout with the App Setting SCM_LOGSTREAM_TIMEOUT (in seconds).
    2021-08-26T03:49:29 No new trace in the past 1 min(s).
    2021-08-26T03:49:34.500 [Error] Executed ‘Functions.TimerTrigger1’ (Failed, Id=eb733a8e-b723-4bb0-b8e4-457b5eaf5888, Duration=66490ms)Result: FailureException: Failed to install function app dependencies. Error: ‘Failed to install function app dependencies. Error: ‘The running command stopped because the preference variable “ErrorActionPreference” or common parameter is set to Stop: Unable to save the module ‘Az’.”Stack: at Microsoft.Azure.Functions.PowerShellWorker.DependencyManagement.DependencyManager.WaitOnDependencyInstallationTask() in /home/vsts/work/1/s/src/DependencyManagement/DependencyManager.cs:line 246at Microsoft.Azure.Functions.PowerShellWorker.DependencyManagement.DependencyManager.WaitForDependenciesAvailability(Func`1 getLogger) in /home/vsts/work/1/s/src/DependencyManagement/DependencyManager.cs:line 164at Microsoft.Azure.Functions.PowerShellWorker.RequestProcessor.ProcessInvocationRequest(StreamingMessage request) in /home/vsts/work/1/s/src/RequestProcessor.cs:line 245
    2021-08-26T03:51:29 No new trace in the past 1 min(s).

    1. Hello Jace,

      I’ve seen this in some other functions I’ve created recently. It’s having problems downloading the az modules prior to running the function. The issue usually resolves itself after a couple of hours. Try restarting the function app. If it’s still a problem, change the az module in the requirements.psd1 file in App Files from 6.* to 5.* and restart the function app for the change to take effect.
      -Travis

Leave a Reply

Your email address will not be published.

This site uses Akismet to reduce spam. Learn how your comment data is processed.