Skip to content

Poor Man's DRS: Memory Balancing for vSphere Standard Clusters

Technical Article

A PowerCLI scheduled task that balances VM memory across hosts in a vSphere cluster that is not licensed for DRS. Calculates the cluster's average host memory utilisation, finds hosts more than a configurable threshold above the average, and vMotions one VM per hot host to the coldest host in the cluster.

Categories
MicrosoftVirtualisationVmware
Tags
Load BalancingVcenterVirtual MachineVmware EsxPowercliPowershellWindows Task SchedulerVsphere
Poor Man's DRS: Memory Balancing for vSphere Standard Clusters

The original 2013 post pointed at a script on Scott Warren's blog (now offline) that implemented a hand-rolled DRS for vSphere Standard clusters. The post below replaces the dead link with a modern, parameterised version that runs on PowerCLI 13 and PowerShell 7.

What the script does

  • Enumerates every host in a named cluster and calculates each host's memory utilisation as a percentage of total RAM.
  • Computes the cluster-wide average and flags hosts that are more than -ThresholdPercent above that average (default 10 percentage points).
  • For each hot host, picks the powered-on VM with the smallest allocated memory: the cheapest one to move.
  • vMotions that VM to the coldest host in the cluster (the one with the most free RAM), capped at -MaxMovesPerRun migrations per execution.
  • Emits a structured [PSCustomObject] report of every action taken or skipped, so it can be piped to Export-Csv from a scheduled task.

PowerShell

#requires -Version 7.0
#requires -Modules VMware.VimAutomation.Core
<#
.SYNOPSIS
    Poor-man's DRS for vSphere Standard / Essentials Plus clusters.

.DESCRIPTION
    Calculates per-host memory utilisation in a named cluster, finds hosts
    that exceed the cluster average by ThresholdPercent, and vMotions the
    lowest-memory powered-on VM from each hot host to the coldest host.

    Supports -WhatIf for dry runs. Caps work per execution with
    -MaxMovesPerRun.

.PARAMETER vCenter
    vCenter Server FQDN to connect to.

.PARAMETER Cluster
    Cluster name. Only this cluster's hosts and VMs are evaluated.

.PARAMETER ThresholdPercent
    Percentage points above the cluster average at which a host is
    considered "hot". Default 10.

.PARAMETER MaxMovesPerRun
    Maximum number of vMotions to issue per invocation. Default 3.

.PARAMETER LogPath
    Optional path. If supplied, the structured report is appended as CSV.

.EXAMPLE
    .\Invoke-PoorMansDrs.ps1 -vCenter vc01.contoso.local -Cluster 'Prod-Standard' -WhatIf

.EXAMPLE
    .\Invoke-PoorMansDrs.ps1 -vCenter vc01.contoso.local -Cluster 'Prod-Standard' -MaxMovesPerRun 2 -LogPath 'C:\Logs\poor-mans-drs.csv'
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
    [Parameter(Mandatory)] [string] $vCenter,
    [Parameter(Mandatory)] [string] $Cluster,
    [ValidateRange(1, 90)] [int] $ThresholdPercent = 10,
    [ValidateRange(1, 50)] [int] $MaxMovesPerRun   = 3,
    [string] $LogPath
)

$ErrorActionPreference = 'Stop'

Import-Module VMware.VimAutomation.Core
Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Confirm:$false -Scope Session | Out-Null

# Connect using the credential of the user running the script.
# For unattended scheduled tasks see "How to use it" for SSPI / stored creds.
$server = Connect-VIServer -Server $vCenter

try {
    $clusterObj = Get-Cluster -Name $Cluster -Server $server
    if (-not $clusterObj) { throw "Cluster '$Cluster' not found on $vCenter." }

    # 1. Collect per-host memory utilisation.
    $hosts = Get-VMHost -Location $clusterObj -Server $server |
        Where-Object { $_.ConnectionState -eq 'Connected' -and $_.PowerState -eq 'PoweredOn' } |
        ForEach-Object {
            $totalGB = [math]::Round($_.MemoryTotalGB, 2)
            $usedGB  = [math]::Round($_.MemoryUsageGB, 2)
            [pscustomobject]@{
                Host         = $_
                Name         = $_.Name
                TotalGB      = $totalGB
                UsedGB       = $usedGB
                FreeGB       = [math]::Round($totalGB - $usedGB, 2)
                PercentUsed  = if ($totalGB -gt 0) { [math]::Round(($usedGB / $totalGB) * 100, 2) } else { 0 }
            }
        }

    if ($hosts.Count -lt 2) {
        Write-Warning "Cluster '$Cluster' has fewer than two connected hosts. Nothing to balance."
        return
    }

    # 2. Cluster average and report.
    $avg = [math]::Round(($hosts | Measure-Object PercentUsed -Average).Average, 2)
    Write-Host ("Cluster '{0}' average memory utilisation: {1}%" -f $Cluster, $avg) -ForegroundColor Cyan
    $hosts | Sort-Object PercentUsed -Descending | Format-Table Name, TotalGB, UsedGB, PercentUsed -AutoSize | Out-String | Write-Host

    # 3. Identify hot hosts and the coldest target.
    $hotHosts    = $hosts | Where-Object { $_.PercentUsed -gt ($avg + $ThresholdPercent) } |
                       Sort-Object PercentUsed -Descending
    $coldestHost = $hosts | Sort-Object PercentUsed | Select-Object -First 1

    if (-not $hotHosts) {
        Write-Host "No hosts exceed average + $ThresholdPercent%. Nothing to do." -ForegroundColor Green
        return
    }

    # 4. Plan and execute moves.
    $report = New-Object System.Collections.Generic.List[object]
    $moves  = 0

    foreach ($hot in $hotHosts) {
        if ($moves -ge $MaxMovesPerRun) {
            Write-Host "Reached MaxMovesPerRun ($MaxMovesPerRun). Stopping." -ForegroundColor Yellow
            break
        }

        # Skip if the coldest host is the hot host itself or is already saturated.
        if ($coldestHost.Name -eq $hot.Name) { continue }
        if ($coldestHost.PercentUsed -ge ($avg + $ThresholdPercent)) {
            Write-Warning "No suitable cold host: cluster is uniformly hot."
            break
        }

        # Pick the powered-on VM on the hot host with the smallest MemoryGB.
        $candidate = Get-VM -Location $hot.Host -Server $server |
            Where-Object { $_.PowerState -eq 'PoweredOn' } |
            Sort-Object MemoryGB |
            Select-Object -First 1

        if (-not $candidate) {
            Write-Warning "No powered-on VMs to move from $($hot.Name)."
            continue
        }

        $entry = [pscustomobject]@{
            Timestamp     = (Get-Date).ToString('s')
            Cluster       = $Cluster
            VM            = $candidate.Name
            VMMemoryGB    = [math]::Round($candidate.MemoryGB, 2)
            SourceHost    = $hot.Name
            SourcePctUsed = $hot.PercentUsed
            TargetHost    = $coldestHost.Name
            TargetPctUsed = $coldestHost.PercentUsed
            ClusterAvgPct = $avg
            Action        = ''
            Error         = ''
        }

        if ($PSCmdlet.ShouldProcess("$($candidate.Name) ($($hot.Name) -> $($coldestHost.Name))", 'Move-VM')) {
            try {
                Move-VM -VM $candidate -Destination $coldestHost.Host -VMotionPriority High -Confirm:$false | Out-Null
                $entry.Action = 'Moved'
                $moves++
                Write-Host ("Moved {0} from {1} to {2}" -f $candidate.Name, $hot.Name, $coldestHost.Name) -ForegroundColor Green
            }
            catch {
                $entry.Action = 'Failed'
                $entry.Error  = $_.Exception.Message
                Write-Warning ("Move failed: {0}" -f $_.Exception.Message)
            }
        }
        else {
            $entry.Action = 'WhatIf'
        }

        $report.Add($entry)
    }

    # 5. Output structured report.
    $report | Format-Table Timestamp, VM, VMMemoryGB, SourceHost, TargetHost, Action -AutoSize | Out-String | Write-Host
    if ($LogPath) {
        $report | Export-Csv -Path $LogPath -NoTypeInformation -Append
        Write-Host "Report appended to $LogPath" -ForegroundColor Cyan
    }
    $report
}
finally {
    Disconnect-VIServer -Server $server -Confirm:$false -ErrorAction SilentlyContinue
}

How to use it

Dry run first:

.\Invoke-PoorMansDrs.ps1 `
    -vCenter vc01.contoso.local `
    -Cluster 'Prod-Standard' `
    -ThresholdPercent 10 `
    -MaxMovesPerRun 3 `
    -WhatIf

Once you are happy with the planned moves, remove -WhatIf. To schedule it nightly on a Windows jump box with PowerShell 7 installed:

# Run from an elevated PowerShell 7 prompt on the jump box.
$action  = New-ScheduledTaskAction `
    -Execute 'C:\Program Files\PowerShell\7\pwsh.exe' `
    -Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\Scripts\Invoke-PoorMansDrs.ps1" -vCenter vc01.contoso.local -Cluster Prod-Standard -MaxMovesPerRun 3 -LogPath C:\Logs\poor-mans-drs.csv'

$trigger = New-ScheduledTaskTrigger -Daily -At 02:30
$principal = New-ScheduledTaskPrincipal `
    -UserId 'CONTOSO\svc-vmwaresched' `
    -LogonType Password `
    -RunLevel Highest

Register-ScheduledTask `
    -TaskName 'Poor Mans DRS - Prod-Standard' `
    -Action $action -Trigger $trigger -Principal $principal `
    -Description 'Memory rebalancing for vSphere Standard cluster.'

For unattended runs you have three sensible options for credentials, in order of preference:

  1. Run the task as a service account that already has the vSphere role required to vMotion (and let SSPI handle auth via Connect-VIServer -Server $vCenter without a -Credential).
  2. Store an encrypted credential with New-VICredentialStoreItem and load it inside the script before Connect-VIServer.
  3. Use a workload identity / vCenter service-principal token if you have vCenter 8.0 U2 or later with the identity broker enabled.

Do not put a plaintext password into the scheduled task arguments.

Notes and modern alternatives

  • Use DRS if you have it. vSphere Enterprise Plus has DRS built in and does this far better than a script can: it accounts for CPU as well as memory, considers active vs consumed memory, respects affinity rules, and runs continuously. This script exists because Standard and Essentials Plus do not include DRS.
  • Memory utilisation only. The script does not consider CPU, IOPS, or network. If you are CPU-bound, this will not help you; add a similar pass on CpuUsageMhz / CpuTotalMhz if you need it.
  • Active vs consumed memory. MemoryUsageGB reports consumed memory, which on ESXi includes overhead and may overstate pressure on hosts using transparent page sharing. The hypervisor's own "active memory" counter via Get-Stat is a better signal for serious tuning. The script uses MemoryUsageGB for simplicity and parity with the original.
  • Smallest-VM heuristic. Moving the smallest VM means cheapest vMotions and least risk, but it may also mean repeated runs are needed to bring a very hot host down. Increase -MaxMovesPerRun or schedule more often if you need faster convergence.
  • PowerCLI 13 is cross-platform. Because PowerCLI 13 runs on PowerShell 7, you can also schedule this on a Linux box with systemd timers if you do not have a Windows jump box.

Security notes

  • Scope vCenter permissions. The account used to run the script needs Resource.Migrate powered-on virtual machine and Resource.Assign virtual machine to resource pool on the cluster. Do not give it admin.
  • Cap the blast radius. -MaxMovesPerRun 3 prevents a misconfigured threshold from chain-moving every VM in the cluster on the first night.
  • Always run with -WhatIf first on a new cluster. The script prints the full plan before doing anything.
  • Centralise logging. Use -LogPath so every move is recorded. Pipe the CSV into your SIEM if vMotion activity is a tracked event.
  • Roll back. vMotion is non-disruptive and reversible; you can always migrate a VM back. There is no destructive operation in the script (Move-VM only relocates).

Original 2013 script

Kept for historical reference, but do not run it: the Add-PSSnapin VMware.VimAutomation.Core line will not load on modern PowerCLI / PowerShell 7, and the syntax errors visible in the legacy version (concatenated lines, the balanced2.csv typo) were artefacts of how the post was originally copied from Scott Warren's blog.

Set-StrictMode -version 2

function GetPercentages() {
    $objHosts = get-vmhost
    foreach ($objHost in $objHosts) {
        $objHost | add-member NoteProperty PercentMemory ($objHost.MemoryUsageMB / $objHost.MemoryTotalMB * 100)
        $objHost
    }
}

function GetVMToMove($strHost, $arrAllVMs) {
    $objVMs = get-vm | where { ($_.Host.Name -eq $strHost) -and ($_.PowerState -eq "PoweredOn") }
    if (test-path "balanced.csv") {
        $arrStatic = import-csv "balanced.csv"
        foreach ($objStatic in $arrStatic) {
            $objVM = $objVM | where { $_.Name -ne ($objStatic.Name) }
        }
    }
    $objVMs = $objVMs | sort "Memory*"
    $objVMs[0]
}

$arrAllVMs = get-vm | where { $_.PowerState -eq "PoweredOn" }
$bMoved = $true
while ($bMoved) {
    $objHosts = GetPercentages | sort PercentMemory -descending | select Name, PercentMemory
    $intAverage = 0; $intCount = 0
    foreach ($objHost in $objHosts) { $intAverage += $objHost.PercentMemory; $intCount++ }
    $intAverage /= $intCount
    echo ("Average is " + $intAverage)
    $objHosts | select Name, PercentMemory
    $strDest = $objHosts[$objHosts.Count - 1].Name
    $bMoved = $false
    foreach ($objHost in $objHosts) {
        if ($objHost.PercentMemory -gt ($intAverage + 10)) {
            $objVM = GetVMToMove $objHost.Name
            echo ('Moving ' + $objVM.Name + " from " + $objHost.Name)
            $objVM | move-vm -destination (get-vmhost $strDest)
            $bMoved = $true
        }
    }
}