Balance your VMs across ESXi hosts in a vSphere cluster with local storage

The Citrix guys in my company has a bad habbit of using local storage, probably because they have had some bad performance experiences in the past. Local storage is not good for the vSphere admin, as vMotion is set out of play, and I need to move things around manually when doing maintenance. Furthermore the Citrix provisioning tool is not very good at launching/deploying VM’s on hosts that has a lot of free resources, so pretty often we end up with clusters where 50% of the hosts is utilizing 90% of the resources (mainly memory) and 50 % is doing nothing 🙁

To mitigate this I have written a script to “balance” the VM’s equally across the clusters. The script takes a parameter with the cluster name, and you are able to exclude specific hosts by editing the file.

# Script to balance VMs across ESXi hosts in cluster using local storage.
# Created by kasper@nordal-lund.dk
# Execute with cluster name as parameter
param(
[string]$cluster
)

#Make sure the vmware modules are loaded
Get-Module -name vmware* -ListAvailable | Import-Module

#Connect to the viserver
connect-viserver hostname.vcenter -alllinked -Credential (Get-Credential)

#$cluster = "xxx" # Manually define the cluster value. For testing purposes.

# Exclude hosts from the operation, use * as wildcard and seperate with |
$excluded = "" 

#Check if the cluster parameter is set.
if ($cluster -eq "") {
    write-host "You forgot to specify a cluster, please try again..."
    exit
}

# Fire up the main loop
while ($true) {

# Pull out the target hosts
$hosttargetsRaw =  get-cluster $cluster | get-vmhost | where {($_.connectionstate -eq "Connected")} | select name,@{N="VMCount";E={(get-vm -location $_.name).count}} | Sort-Object VMCount -Descending

# Trim the list for exclusions 
$hosttargetstrimmed = $hosttargetsRaw -notmatch $excluded

# Calculate the difference between the most and least populated hosts
$diff = ($hosttargetstrimmed | select -First 1).VMCount - ($hosttargetstrimmed | select -Last 1).VMCount

if ($diff -gt 4) {

# Define the source and destination hosts
$sourcehosts = $hosttargetstrimmed | select -First 2
$desthosts = $hosttargetstrimmed | select -last 2

# Check if we have only one hosts doing nothing
$bottomdiff = ($desthosts | select -first 1).VMcount - ($desthosts | select -Last 1).VMCount

$i = 0

foreach ($sourcehost in $sourcehosts.name){
	if ($bottomdiff -gt 4) {
        $curdesthost = $desthosts[1].name
    }else {
        $curdesthost = $desthosts[$i].name
	    $i++
    }
# Get the destination datastore		
$destds = (get-datastore -vmhost $curdesthost local*).name

# get the VM's we want to move
$targets = get-vmhost $sourcehost | get-vm | select -first 2

# Move the VM's 2 from each hosts = 4 VM's pr. loop
foreach ($target in $targets) {
    echo "move-vm -vm $target -Destination $curdesthost -Datastore $destds -RunAsync"
   }
}
# Wait for the VM's to be moved before looping again.  
sleep -Seconds 90
}else {
    write-host "Balancing of cluster $cluster is finished, difference between most and least populated host is $diff VMs..."
exit
}
}

I hope you are able to use this, at least as inspiration for getting on with your own.
And remember, this script can of course be combined with my powershell menu script found here: https://www.nordal-lund.dk/?p=574

Enjoy…

Powershell menu script

Do you ever need to give some input to a script? Maybe its a long filename or some other long and complex string, or maybe you’re just lazy like me? I wrote a script for handling the input, it’s a bit like the curses based menus you see in some network equipment, and in Linux systems. The eaxmple below is for getting the firmware baseline for a HP OneView system, but the menu is usable almost everywhere you need to specify something.

Lets stop talking, and take a look at the actual script:

param(
[string]$selection
)

if ((get-module hpone*).count -lt 1){
Get-Module -name hpone* -ListAvailable | Import-Module -WarningAction SilentlyContinue
}

if ($Global:connectedSessions){
    echo "Already connected..."
    } else {
    Connect-HPOVMgmt -Appliance ADDRESS -Credential (Get-Credential)
}

$global:MenuOptions = get-hpovbaseline

if ($Selection -eq ""){

    Clear-Host
    Write-Host "================Select Firmware Version================"
    
    $MenuNumber = 1
    foreach ($Option in $MenuOptions.version){

    Write-Host "$MenuNumber : $Option"
    $MenuNumber++
    }
    Write-Host -ForegroundColor red "Quit: Press 'ctrl-c' to quit."

    [int]$Selection = Read-Host "Please select firmware by number"
    
}

$baseline = $MenuOptions[$Selection -1]

The above code has some checks in the beginning to see if we have the right modules loaded, and if we are connected to the OneView server. This is not needed for the menu, but maybe it can be useful for you anyway.
In this example I’m listing the firmware included in the baselines from an HP OneView system, and the $baseline variable will have the selection I made.
This is usable for all kinds of interactions with other systems, e.g. its also very usable for vmware vCenter.
The selection parameter is there if you run this task often and already know what to select, then you can add it as an attribute to the script.

Please enjoy using the script.