A futuristic data center, with interconnected servers dynamically balancing workloads through glowing energy flows and resource optimization visuals, all set in a sleek, modern environment.

In virtualized environments, evenly distributing virtual machines (VMs) across hosts in a cluster is crucial for optimal performance and resource utilization. VMware’s Distributed Resource Scheduler (DRS) is designed to automate this process. However, some users have encountered issues where DRS does not balance VMs as expected.

One user on Reddit highlighted this problem:

This is a known problem – if the DRS score of VMs is good, DRS will not migrate them to the empty host. I even opened a support ticket regarding this and was told that it is expected behaviour and that the toggle ‘Try to balance number of VMs per host’ does not actually do anything in the current DRS scheduler. It sucks…

Another user confirmed:

That is correct in newer versions of DRS. The only thing that matters is the VM’s happiness score and not the host. Host can be pegged but DRS wouldn’t do anything.

To address this issue, I developed a PowerShell script using VMware PowerCLI that manually balances VMs across hosts within a cluster. This script ensures that no host exceeds a specified maximum number of VMs, providing a more balanced and efficient environment.

The Solution

The solution consists of three main scripts:

  • Balance-VMs.ps1: Balances the VMs across hosts in the cluster based on the specified criteria.
  • Generate-EncryptionKey.ps1: Generates a secure encryption key for encrypting and decrypting vCenter credentials.
  • Create-VCenterCredentials.ps1: Encrypts and stores vCenter credentials using the generated encryption key.

Part 1: Generating the Encryption Key

To securely store your vCenter credentials, you first need to generate an encryption key.

Generate-EncryptionKey.ps1

<#
.SYNOPSIS
    Generates a secure encryption key for encrypting and decrypting vCenter credentials.

.DESCRIPTION
    This script creates a 256-bit (32-byte) encryption key using a cryptographically secure random number generator.
    The key is saved to a specified file path and should be securely stored with restricted access permissions.

.PARAMETER EncryptionKeyPath
    The file path where the encryption key will be stored. Default is "C:\Secure\Credentials\encryptionKey.key".

.EXAMPLE
    .\Generate-EncryptionKey.ps1
    Generates an encryption key and saves it to the default path.

.EXAMPLE
    .\Generate-EncryptionKey.ps1 -EncryptionKeyPath "D:\Keys\MyEncryptionKey.key"
    Generates an encryption key and saves it to the specified path.

.AUTHOR
    virtualox

.GITHUB_REPOSITORY
    https://github.com/virtualox/VM-Balancer

.LICENSE
    This script is licensed under the GPL-3.0 License. See the LICENSE file for more information.

.NOTES
    - Ensure the encryption key file is stored in a secure location with restricted access.
    - This key is required for both encrypting and decrypting the vCenter credentials.
    - Do not share the encryption key file publicly or store it in insecure locations.
#>

[CmdletBinding()]
param (
    [string]$EncryptionKeyPath = "C:\Secure\Credentials\encryptionKey.key"
)

# Function to check if the encryption key already exists
function Test-EncryptionKeyExists {
    param (
        [string]$Path
    )
    return (Test-Path -Path $Path)
}

# Function to generate a secure encryption key
function Generate-EncryptionKey {
    param (
        [string]$Path
    )
    try {
        # Create a 32-byte (256-bit) key
        $key = New-Object byte[] 32

        # Use the appropriate RNG method based on .NET version
        if ([System.Security.Cryptography.RandomNumberGenerator].GetMethod('Fill', [Type[]]@([Byte[]]))) {
            # For .NET Core and .NET 5+
            [System.Security.Cryptography.RandomNumberGenerator]::Fill($key)
        }
        else {
            # For .NET Framework
            [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($key)
        }

        # Save the key to the specified path
        Set-Content -Path $Path -Value $key -Encoding Byte -Force

        Write-Output "Encryption key successfully generated and saved to '$Path'."
    }
    catch {
        Write-Error "Failed to generate encryption key: $_"
        exit 1
    }
}

# Function to check if running as administrator
function Test-IsAdministrator {
    $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = New-Object Security.Principal.WindowsPrincipal($currentUser)
    return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}

# Main Execution

# Check if running as administrator
if (-not (Test-IsAdministrator)) {
    Write-Warning "You need to run this script as an Administrator to set file permissions."
    exit 1
}

if (Test-EncryptionKeyExists -Path $EncryptionKeyPath) {
    Write-Warning "Encryption key already exists at '$EncryptionKeyPath'."

    do {
        $userInput = Read-Host "Do you want to overwrite the existing key? (Y/N)"
    } until ($userInput -match '^[YyNn]$')

    if ($userInput -ne 'Y' -and $userInput -ne 'y') {
        Write-Output "Operation cancelled by the user."
        exit
    }
}

# Ensure the directory exists
$directory = Split-Path -Path $EncryptionKeyPath -Parent
if (-not (Test-Path -Path $directory)) {
    try {
        New-Item -Path $directory -ItemType Directory -Force | Out-Null
        Write-Output "Created directory '$directory'."
    }
    catch {
        Write-Error "Failed to create directory '$directory': $_"
        exit 1
    }
}

# Generate the encryption key
Generate-EncryptionKey -Path $EncryptionKeyPath

# Secure the encryption key file by setting appropriate permissions
try {
    $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name

    # Secure the encryption key file
    $aclFile = Get-Acl -Path $EncryptionKeyPath

    # Remove all existing permissions except for the current user
    $accessRules = $aclFile.Access | Where-Object { $_.IdentityReference -ne $currentUser }
    foreach ($rule in $accessRules) {
        $aclFile.RemoveAccessRule($rule)
    }

    # Define the access rule: Only the current user has full control
    $accessRuleFile = New-Object System.Security.AccessControl.FileSystemAccessRule(
        $currentUser,
        [System.Security.AccessControl.FileSystemRights]::FullControl,
        [System.Security.AccessControl.InheritanceFlags]::None,
        [System.Security.AccessControl.PropagationFlags]::None,
        [System.Security.AccessControl.AccessControlType]::Allow
    )
    $aclFile.SetAccessRuleProtection($true, $false)
    $aclFile.SetAccessRule($accessRuleFile)
    Set-Acl -Path $EncryptionKeyPath -AclObject $aclFile

    Write-Output "Set restricted permissions on '$EncryptionKeyPath'."
}
catch {
    Write-Warning "Failed to set permissions on '$EncryptionKeyPath'. Please ensure it is secured properly."
}

Notes:

  • Ensure the encryption key file is stored in a secure location with restricted access permissions.
  • This key is required for both encrypting and decrypting the vCenter credentials.
  • Do not share the encryption key file publicly or store it in insecure locations.

Part 2: Encrypting and Storing vCenter Credentials

Next, you’ll encrypt and store your vCenter credentials using the encryption key generated in Part 1.

Create-VCenterCredentials.ps1

<#
.SYNOPSIS
    Encrypts and stores vCenter credentials using a shared encryption key.

.DESCRIPTION
    This script prompts the user to enter vCenter credentials, encrypts the password using a predefined encryption key,
    and saves the encrypted password along with the username to a specified file. This setup allows multiple administrators
    or automated tasks to access shared credentials securely.

.AUTHOR
    virtualox

.GITHUB_REPOSITORY
    https://github.com/virtualox/VM-Balancer

.LICENSE
    This script is licensed under the GPL-3.0 License. See the LICENSE file for more information.

.USAGE
    .\Create-VCenterCredentials.ps1

.NOTES
    - Ensure that the encryption key has been generated using Generate-EncryptionKey.ps1 before running this script.
    - The encryption key path and credential storage path must match those used in Balance-VMs.ps1.
    - Store the encrypted credentials file in a secure location with restricted access.
#>

# === Configuration Variables ===

# Path to the encryption key file
$encryptionKeyPath = "C:\Secure\Credentials\encryptionKey.key" # <-- Must match the key generated by Generate-EncryptionKey.ps1

# Path where the encrypted credentials will be stored
$credentialPath = "C:\Secure\Credentials\vcCredentials.xml"    # <-- Update this path as needed

# === End of Configuration Variables ===

# Function to check if the encryption key exists
function Test-EncryptionKeyExists {
    param (
        [string]$Path
    )
    return (Test-Path -Path $Path)
}

# Function to encrypt and store credentials
function Encrypt-And-Store-Credentials {
    param (
        [string]$KeyPath,
        [string]$CredPath
    )
    try {
        # Prompt user for vCenter credentials
        $credential = Get-Credential -Message "Enter your vCenter credentials"
        $username = $credential.Username
        $password = $credential.Password

        # Read the encryption key
        $key = Get-Content -Path $KeyPath -Encoding Byte

        # Encrypt the password
        $encryptedPassword = $password | ConvertFrom-SecureString -Key $key

        # Create a custom object to store username and encrypted password
        $credentialObject = [PSCustomObject]@{
            Username          = $username
            EncryptedPassword = $encryptedPassword
        }

        # Save the credential object to the specified path
        $credentialObject | ConvertTo-Json | Set-Content -Path $CredPath -Force

        Write-Output "vCenter credentials have been encrypted and stored successfully at '$CredPath'."
    }
    catch {
        Write-Error "Failed to encrypt and store credentials: $_"
        exit 1
    }
}

# Function to secure the credentials file by setting appropriate permissions
function Secure-CredentialsFile {
    param (
        [string]$Path
    )
    try {
        $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name

        # Secure the credentials file
        $aclFile = Get-Acl -Path $Path

        # Remove all existing permissions except for the current user
        $accessRules = $aclFile.Access | Where-Object { $_.IdentityReference -ne $currentUser }
        foreach ($rule in $accessRules) {
            $aclFile.RemoveAccessRule($rule)
        }

        # Define the access rule: Only the current user has full control
        $accessRuleFile = New-Object System.Security.AccessControl.FileSystemAccessRule(
            $currentUser,
            [System.Security.AccessControl.FileSystemRights]::FullControl,
            [System.Security.AccessControl.InheritanceFlags]::None,
            [System.Security.AccessControl.PropagationFlags]::None,
            [System.Security.AccessControl.AccessControlType]::Allow
        )
        $aclFile.SetAccessRuleProtection($true, $false)
        $aclFile.SetAccessRule($accessRuleFile)
        Set-Acl -Path $Path -AclObject $aclFile

        Write-Output "Set restricted permissions on '$Path'."
    }
    catch {
        Write-Warning "Failed to set permissions on '$Path'. Please ensure it is secured properly."
    }
}

# Main Execution

# Check if encryption key exists
if (-not (Test-EncryptionKeyExists -Path $encryptionKeyPath)) {
    Write-Error "Encryption key not found at '$encryptionKeyPath'. Please generate it using Generate-EncryptionKey.ps1 before storing credentials."
    exit 1
}

# Ensure the directory exists
$directory = Split-Path -Path $credentialPath -Parent
if (-not (Test-Path -Path $directory)) {
    try {
        New-Item -Path $directory -ItemType Directory -Force | Out-Null
        Write-Output "Created directory '$directory'."
    }
    catch {
        Write-Error "Failed to create directory '$directory': $_"
        exit 1
    }
}

# Encrypt and store the credentials
Encrypt-And-Store-Credentials -KeyPath $encryptionKeyPath -CredPath $credentialPath

# Secure the credentials file
Secure-CredentialsFile -Path $credentialPath

Notes:

  • Ensure that the encryption key has been generated before running this script.
  • The encryption key path and credential storage path must match those used in Balance-VMs.ps1.
  • Store the encrypted credentials file in a secure location with restricted access.

Part 3: Balancing VMs Across Hosts

Finally, the main script performs the VM balancing operation.

Balance-VMs.ps1

<#
.SYNOPSIS
    Balances VMs across hosts in a vCenter cluster, ensuring no host exceeds the specified VM limit.

.DESCRIPTION
    This script connects to a vCenter server using encrypted credentials, identifies overloaded hosts within a specified cluster,
    and migrates VMs to underloaded hosts to maintain a balanced distribution. It excludes specified VMs from migration
    based on their names or assigned tags. Additionally, it includes an enhanced dry-run mode to simulate migrations safely.

.AUTHOR
    virtualox

.GITHUB_REPOSITORY
    https://github.com/virtualox/VM-Balancer

.LICENSE
    This script is licensed under the GPL-3.0 License. See the LICENSE file for more information.

.USAGE
    .\Balance-VMs.ps1 [-DryRun]

.PARAMETER DryRun
    Executes the script in simulation mode without performing actual migrations. Useful for testing and verifying actions.

.NOTES
    - Ensure that the encryption key and encrypted credentials have been set up using Generate-EncryptionKey.ps1 and Create-VCenterCredentials.ps1.
    - Adjust the configuration variables and exclusion criteria within the script as needed.
    - The script requires appropriate permissions to migrate VMs within the specified vCenter cluster.
#>

param (
    [switch]$DryRun
)

# === Configuration Variables ===

# Path to the encryption key file
$encryptionKeyPath = "C:\Secure\Credentials\encryptionKey.key"  # <-- Must match the key generated by Generate-EncryptionKey.ps1

# Path to the encrypted credentials file
$credentialPath = "C:\Secure\Credentials\vcCredentials.xml"     # <-- Must match the credential path used in Create-VCenterCredentials.ps1

# vCenter Server details
$vcServer = "your_vcenter_server"       # <-- Replace with your vCenter server name or IP
$clusterName = "YourClusterName"        # <-- Replace with your cluster name

# VM balancing settings
$maxVMsPerHost = 60                     # Maximum number of VMs per host

# Exclusion Settings

## Name-Based Exclusion
# Specify exact VM names or use wildcards for patterns
$excludeVMNames = @(
    "cp-replica-*",    # Exclude VMs with names starting with 'cp-replica-'
    "horizon-*",       # Exclude VMs managed by Horizon (assuming they start with 'horizon-')
    "*-replica*",      # Exclude any VM containing '-replica' in its name
    "Important-VM1",   # Exclude specific VM by exact name
    "Critical-VM2"     # Add more as needed
)

## Tag-Based Exclusion
# Specify the names of tags assigned to VMs that should be excluded
$excludeVMTags = @(
    "DoNotMigrate",
    "Infrastructure",
    "VDI"
)

# === End of Configuration Variables ===

# Import the PowerCLI module
Import-Module VMware.PowerCLI -ErrorAction Stop

# Suppress certificate warnings (optional, remove if not needed)
Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Confirm:$false

# Function to check if the encryption key exists
function Test-EncryptionKeyExists {
    param (
        [string]$Path
    )
    return (Test-Path -Path $Path)
}

# Function to check if the credentials file exists
function Test-CredentialsFileExists {
    param (
        [string]$Path
    )
    return (Test-Path -Path $Path)
}

# Function to retrieve credentials
function Get-Credentials {
    param (
        [string]$KeyPath,
        [string]$CredPath
    )
    try {
        # Read the encryption key
        $key = Get-Content -Path $KeyPath -Encoding Byte

        # Read the encrypted credentials JSON
        $credentialJson = Get-Content -Path $CredPath -Raw | ConvertFrom-Json

        $username = $credentialJson.Username
        $encryptedPassword = $credentialJson.EncryptedPassword

        # Decrypt the password
        $securePassword = $encryptedPassword | ConvertTo-SecureString -Key $key

        # Create PSCredential object
        $cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $username, $securePassword

        return $cred
    }
    catch {
        Write-Error "Failed to retrieve and decrypt credentials: $_"
        exit 1
    }
}

# Function to check if the host is eligible (connected and not in maintenance mode)
function Is-HostEligible {
    param (
        $VMHost
    )
    return ($VMHost.ConnectionState -eq 'Connected' -and -not $VMHost.ExtensionData.Runtime.InMaintenanceMode)
}


# Function to apply exclusion filters to VMs
function Apply-ExclusionFilters {
    param (
        [VMware.VimAutomation.ViCore.Impl.V1.Inventory.VirtualMachineImpl]$VM
    )
    # Name-Based Exclusion
    foreach ($pattern in $excludeVMNames) {
        if ($VM.Name -like $pattern) {
            return $false
        }
    }

    # Tag-Based Exclusion
    foreach ($tag in $excludeVMTags) {
        if ($VM.Tags -contains $tag) {
            return $false
        }
    }

    return $true
}

# Function to secure the script execution
function Verify-Security {
    # Ensure encryption key exists
    if (-not (Test-EncryptionKeyExists -Path $encryptionKeyPath)) {
        Write-Error "Encryption key not found at '$encryptionKeyPath'. Please generate it using Generate-EncryptionKey.ps1 and store credentials using Create-VCenterCredentials.ps1."
        exit 1
    }

    # Ensure credentials file exists
    if (-not (Test-CredentialsFileExists -Path $credentialPath)) {
        Write-Error "Encrypted credentials file not found at '$credentialPath'. Please store credentials using Create-VCenterCredentials.ps1."
        exit 1
    }
}

# Function to get host object by name
function Get-HostByName {
    param (
        [string]$Name
    )
    return $hosts | Where-Object { $_.Name -eq $Name }
}

# Main Execution

# Verify security prerequisites
Verify-Security

# Retrieve credentials
$cred = Get-Credentials -KeyPath $encryptionKeyPath -CredPath $credentialPath

# Connect to vCenter
try {
    Connect-VIServer -Server $vcServer -Credential $cred -ErrorAction Stop
    Write-Output "Successfully connected to vCenter Server '$vcServer'."
}
catch {
    Write-Error "Failed to connect to vCenter Server '$vcServer'. $_"
    exit 1
}

# Get the cluster object
$cluster = Get-Cluster -Name $clusterName -ErrorAction SilentlyContinue

if (-not $cluster) {
    Write-Error "Cluster '$clusterName' not found."
    Disconnect-VIServer -Server $vcServer -Confirm:$false
    exit 1
}

# Get all eligible hosts in the cluster
$hosts = Get-VMHost -Location $cluster | Where-Object { Is-HostEligible -VMHost $_ }

if ($hosts.Count -eq 0) {
    Write-Error "No eligible hosts found in cluster '$clusterName'. Ensure that hosts are not in Maintenance or Disconnected states."
    Disconnect-VIServer -Server $vcServer -Confirm:$false
    exit 1
}

# Create a hashtable to store host VM counts
$hostVMCounts = @{}

foreach ($vmHost in $hosts) {
    # Retrieve all VMs on the host
    $vmsOnHost = Get-VM -Location $vmHost -ErrorAction SilentlyContinue

    # Apply Exclusion Filters
    $vmsToConsider = $vmsOnHost | Where-Object { Apply-ExclusionFilters -VM $_ }

    # Count the number of VMs to consider for migration
    $vmCount = $vmsToConsider.Count
    $hostVMCounts[$vmHost.Name] = $vmCount
}

# Identify overloaded and underloaded hosts
$overloadedHosts = $hostVMCounts.GetEnumerator() | Where-Object { $_.Value -gt $maxVMsPerHost } | Sort-Object -Property Value -Descending
$underloadedHosts = $hostVMCounts.GetEnumerator() | Where-Object { $_.Value -lt $maxVMsPerHost } | Sort-Object -Property Value

if ($overloadedHosts.Count -eq 0) {
    Write-Output "All hosts have $maxVMsPerHost VMs or fewer. No balancing needed."
    Disconnect-VIServer -Server $vcServer -Confirm:$false
    exit 0
}

if ($underloadedHosts.Count -eq 0) {
    Write-Output "No hosts available with less than $maxVMsPerHost VMs to migrate VMs to."
    Disconnect-VIServer -Server $vcServer -Confirm:$false
    exit 0
}

# Start balancing
foreach ($overloaded in $overloadedHosts) {
    $overHostName = $overloaded.Key
    $overHostVMCount = $overloaded.Value
    $overHost = Get-HostByName -Name $overHostName

    $vmsToMoveCount = $overHostVMCount - $maxVMsPerHost

    if ($vmsToMoveCount -le 0) {
        continue
    }

    Write-Output "Host '$overHostName' is overloaded with $overHostVMCount VMs. Need to move $vmsToMoveCount VMs."

    # Get VMs on the overloaded host, excluding replicas and tagged VMs, sorted by power state (optional: adjust sorting as needed)
    $vmsOnOverHost = Get-VM -Location $overHost | Where-Object { Apply-ExclusionFilters -VM $_ } | Sort-Object -Property PowerState

    foreach ($vm in $vmsOnOverHost) {
        # Check again if there are still VMs to move
        if ($vmsToMoveCount -le 0) {
            break
        }

        # Find a target host that is underloaded and can host the VM
        $targetHostEntry = $underloadedHosts | Where-Object { $_.Value -lt $maxVMsPerHost } | Sort-Object -Property Value | Select-Object -First 1

        if ($targetHostEntry) {
            $targetHostName = $targetHostEntry.Key
            $targetHostObj = Get-HostByName -Name $targetHostName

            # Additional check to ensure the target host is still eligible
            if ($targetHostObj.ConnectionState -ne 'Connected' -or $targetHostObj.ExtensionData.Runtime.InMaintenanceMode) {
                Write-Warning "Target host '$targetHostName' is no longer eligible. Skipping."
                # Remove the host from underloaded list
                $underloadedHosts = $underloadedHosts | Where-Object { $_.Key -ne $targetHostName }
                continue
            }

            if ($DryRun) {
                Write-Output "[Dry-Run] Would migrate VM '$($vm.Name)' from '$overHostName'."

                # Simulate the migration by updating the VM counts
                $hostVMCounts[$overHostName] -= 1
                $hostVMCounts[$targetHostName] += 1

                # Update the underloaded hosts list if the target host reaches the limit
                if ($hostVMCounts[$targetHostName] -ge $maxVMsPerHost) {
                    $underloadedHosts = $underloadedHosts | Where-Object { $_.Key -ne $targetHostName }
                }

                $vmsToMoveCount -= 1
            }
            else {
                Write-Output "Migrating VM '$($vm.Name)' from '$overHostName' to '$targetHostName'. (Hardware Version: $($vm.HardwareVersion))"

                try {
                    # Perform the migration
                    Move-VM -VM $vm -Destination $targetHostObj -ErrorAction Stop

                    # Update the VM counts
                    $hostVMCounts[$overHostName] -= 1
                    $hostVMCounts[$targetHostName] += 1

                    # Update the underloaded hosts list if the target host reaches the limit
                    if ($hostVMCounts[$targetHostName] -ge $maxVMsPerHost) {
                        $underloadedHosts = $underloadedHosts | Where-Object { $_.Key -ne $targetHostName }
                    }

                    $vmsToMoveCount -= 1
                }
                catch {
                    Write-Warning "Failed to migrate VM '$($vm.Name)': $_"
                }
            }
        }
        else {
            Write-Warning "No available target hosts with less than $maxVMsPerHost VMs to migrate VM '$vm.Name'."
            break
        }
    }
}

Write-Output "VM balancing complete."

# Disconnect from vCenter
Disconnect-VIServer -Server $vcServer -Confirm:$false

Key Features:

  • Connection and Authentication: Uses encrypted credentials to connect to the specified vCenter server.
  • Host and VM Retrieval: Gathers information about hosts and VMs in the specified cluster.
  • Exclusion Criteria: Excludes VMs from migration based on name patterns and assigned tags.
  • Balancing Logic: Identifies overloaded hosts (those exceeding maxVMsPerHost) and migrates VMs to underloaded hosts.
  • Dry-Run Mode: The -DryRun parameter allows you to simulate the balancing process without making actual changes.

Usage Example:

To perform a dry run:

.\Balance-VMs.ps1 -DryRun

To execute the balancing operation:

.\Balance-VMs.ps1

Important Notes:

  • Ensure that the encryption key and encrypted credentials are properly set up before running the script.
  • Adjust the configuration variables and exclusion lists to fit your environment.
  • The script requires appropriate permissions to perform VM migrations in vCenter.

Conclusion

Balancing VMs across hosts in a VMware cluster can improve performance and resource utilization. While DRS is designed to automate this process, it may not always behave as expected due to its focus on VM happiness scores. This custom script provides a way to manually enforce a more balanced distribution of VMs.

By tailoring the exclusion criteria and configuration settings, you can adapt the script to meet the specific needs of your environment. The inclusion of a dry-run mode also allows for safe testing before making actual changes.

Additional Resources

Leave a Reply

Your email address will not be published. Required fields are marked *