Compact Hyper-V VHDX files using PowerShell

I used VMware Workstation for a while which has an option to automatically compact the virtual hard disk after shutting down the VM, an excellent way of freeing up space on my laptop hard drive. But I switched to using only Hyper-V now, compacting virtual hard disks is not something you can enable in Hyper-V to run automatically. In this blog post, I will show you how to do this using a script that you can use to do that in an easy way 🙂

Limitations

Let me first start with things that this script can’t do, the optimize-vhd cmdlet has some limitations, and what the limitations are:

  • Disks with checkpoints (Snapshots), the VM will have a .avhdx disk attached which should not be compacted
  • VMs which use differencing disks which have child virtual disks associated with them
  • Virtual hard disks that are associated with a virtual machine that has replication enabled and is currently involved in initial replication, resynchronization, test failover, or failover.
  • I only tested this on my own machine, not a stand-alone Hyper-V server or a Hyper-V server running in a Fail-Over cluster
  • The script only compacts .vhdx files, not .vhd files
  • It only compacts dynamic .vhdx files, fixed disk files are not supported by the optimize-vhd cmdlet
  • The VM needs to be turned off, disks can’t be compacted which belong to a running VM
  • The script will only run locally on the computer running Hyper-V, it can’t connect to a remote Hyper-V instance

Running the script

After running, the Compact-VHDX function is available in your PowerShell session. You can run it without the -VMName parameter and it will try to compact all .vhdx files associated with the registered VMs on your computer. If you run it with the -VMName parameter and specify a certain VM, it will only compact the .vhdx(s) on that machine.

Example of running on all VMs

In this example, I ran the script on my laptop and it compacted .vhdx files on three VMs, you can see the space that it recovered in the column on the right: (1.656Gb)

Example of running on one VM

In this example, I ran the script on my laptop and specified the ‘Exam’ VM. You can see the space that it recovered in the column on the right, also 1.656Gb 🙂 (I used the same file that I copied into the VM and removed later so that there was some space to recover)

The script

Below is the script, you can save this locally and add this to your PowerShell profile by:

notepad $profile
add ". c:\data\compact-vhdx.ps1"
Close/Save and start a new PowerShell session

It has error checking/handling for:

  • Is the Hyper-V PowerShell module installed, if not… Install it before continuing
  • Is the VMName valid if specified?
  • Is the disk Dynamic?
  • Is the VM running?
  • Errors during mounting/compacting/dismounting
function Compact-VHDX {
    param (
        [Parameter(Mandatory = $false, HelpMessage = "Enter the name of the machine from which space of the VHDX(s) should be recovered")][string]$VMName
    )
      
    #Validate if hyper-v module is available, install when needed
    if (-not (Get-Module -listAvailable -Name Hyper-V)) {
        Write-Warning "Hyper-V module is not installed, installing now..."
        Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-Management-PowerShell -NoRestart:$true
    }

    #Check if specified VMName is valid if specified
    if ($VMName) {
        if (-not (Get-VM -Name $VMName -ErrorAction SilentlyContinue)) {
            Write-Warning ("Specified VM {0} was not found, aborting..." -f $VMName)
            break
        }
    }
    
    #Validate if the Virtual machine specified is running, abort if yes
    if ($VMName) {
        if ((Get-VM -Name $VMName).State -eq 'Running') {
            Write-Warning ("Specified VM {0} is found but is running, please shutdown VM first. Aborting..." -f $VMName)
            break
        }
    }

    #Validate if Virtual machines are running when $VMName was not specified, abort if yes
    if (-not ($VMName)) {
        if (Hyper-V\Get-VM | Where-Object State -eq Running) {
            Write-Warning ("Hyper-V VM(s) are running, aborting...")
            Write-Host ("Shutdown VM(s):") -ForegroundColor Red
            hyper-v\get-vm | Where-Object State -eq Running | Select-Object Name, State | Sort-Object Name | Format-Table
            break
        }
    }
    
    #Gather all VHDXs from the VMs (or VM is $VMName was specified which don't have a parent/snapshot
    if (-not ($VMName)) {
        $vhds = Get-VM | Get-VMHardDiskDrive | Where-Object Path -Match '.vhdx' | Sort-Object VMName, Path
    }
    else {
        $vhds = Get-VM $VMName | Get-VMHardDiskDrive | Where-Object Path -Match '.vhdx' | Sort-Object Path
    }

    if ($null -eq $vhds) {
        Write-Warning ("No disk(s) found without parent/snapshot configuration, aborting....")
    }

    #Gather current size of VHDX files
    $oldsize = @()
    foreach ($vhd in $vhds) {
        if ((get-vhd $vhd.path).VhdType -eq 'Dynamic') {
            $size = [PSCustomObject]@{
                VHD     = $vhd.Path
                OldSize = [math]::round((Get-Item $vhd.Path).Length / 1GB, 3)
            }
            $oldsize += $size
        }
    }

    
    #Compress all files
    foreach ($vhd in $vhds) {
        if ((get-vhd $vhd.path).VhdType -eq 'Dynamic') {
            Write-Host ("`nProcessing {0} from VM {1}..." -f $vhd.Path, $vhd.VMName) -ForegroundColor Gray
            try {
                Mount-VHD -Path $vhd.Path -ReadOnly -ErrorAction Stop
                Write-Host "Mounting VHDX" -ForegroundColor Green
            }
            catch {
                Write-Warning ("Error mounting {0}, please check access or if file is locked..." -f $vhd.Path )
                continue
            }

            try {
                Optimize-VHD -Path $vhd.Path -Mode Full
                Write-Host ("Compacting VHDX") -ForegroundColor Green
            }
            catch {
                Write-Warning ("Error compacting {0}, dismounting..." -f $vhd.Path)
                Dismount-VHD $vhd.Path
                break
            }

            try { 
                Dismount-VHD $vhd.Path -ErrorAction Stop
                Write-Host ("Dismounting VHDX`n") -ForegroundColor Green
            }
            catch {
                Write-Warning ("Error dismounting {0}, please check Disk Management and manually dismount..." -f $vhd.Path)
                break
            }        
        }
    }

    #Report on new VHDX sizes
    $report = @()
    foreach ($vhd in $vhds) {
        if ((get-vhd $vhd.path).VhdType -eq 'Dynamic') {
            $newsize = [PSCustomObject]@{
                VM                     = $vhd.VMName
                VHD                    = $vhd.Path
                'Old Size (Gb))'       = ($oldsize | Where-Object VHD -eq $vhd.Path).OldSize
                'New Size (Gb)'        = [math]::round((Get-Item $vhd.Path).Length / 1GB, 3)
                'Space recovered (Gb)' = ($oldsize | Where-Object VHD -eq $vhd.Path).OldSize - [math]::round((Get-Item $vhd.Path).Length / 1GB, 3)
            }
            $report += $newsize
        }
    }
    
    #Show overview
    if ($null -ne $report) {
        return $report | Format-Table -AutoSize
    }
    else {
        Write-Warning ("No dynamic disk(s) found to recover hard-disk space from, aborting....")
    }
}

Download the script(s) from GitHub here

Leave a Reply

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