Compact Hyper-V VHDX files using PowerShell

I used VMware Workstation for a while, which can 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 easily. 🙂

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 that use differencing disks that have child virtual disks associated with them
  • Virtual hard disks 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 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. The optimize-vhd cmdlet does not support fixed disk files
  • 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 one or more specific VMs (separated by a comma), it will only compact the .vhdx(s) on those machine(s).

Note: Requires to be run as a user with Administrator rights.

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

I ran the script on my laptop and specified the ‘Exam’ VM in this example. You can see the space it recovered in the column on the right, also 1.656Gb. 🙂 (I used the same file that I copied into the VM and removed it 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
#Requires -RunAsAdministrator
function Compact-VHDX {
    param (
        [Parameter(Mandatory = $false, HelpMessage = "Enter the name of the machine(s) 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 (Hyper-V\Get-VM -Name $VMName -ErrorAction SilentlyContinue)) {
            Write-Warning ("Specified VM {0} was not found, aborting..." -f $VMName)
            return
        }
    }
    
    #Validate if the Virtual machine specified is running, abort if yes
    if ($VMName) {
        foreach ($vm in $VMName) {
            if ((Hyper-V\Get-VM -Name $VM).State -eq 'Running') {
                Write-Warning ("One or more of the specified VM(s) {0} were found but are running, please shutdown VM(s) first. Aborting..." -f $VM)
                return
            }
        }
    }
    
    #Gather all VHDXs from the VMs (or VM is $VMName was specified which don't have a parent/snapshot
    if (-not ($VMName)) {
        $vhds = Hyper-V\Get-VM | Hyper-V\Get-VMHardDiskDrive | Where-Object Path -Like '*.vhdx' | Sort-Object VMName, Path
    }
    else {
        $vhds = Hyper-V\Get-VM $VMName | Hyper-V\Get-VMHardDiskDrive | Where-Object Path -Like '*.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 ((Hyper-V\Get-VHD $vhd.path).VhdType -eq 'Dynamic') {
            [PSCustomObject]@{
                VHD     = $vhd.Path
                OldSize = [math]::round((Get-Item $vhd.Path).Length / 1GB, 3)
            }
        }
    }

    #Compress all files
    foreach ($vhd in $vhds) {
        if (-not (Hyper-V\Get-VM $vhd.VMName  | Where-Object State -eq Running)) {
            Write-Host ("`nProcessing {0} from VM {1}..." -f $vhd.Path, $vhd.VMName) -ForegroundColor Gray
            try {
                Hyper-V\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 {
                Hyper-V\Optimize-VHD -Path $vhd.Path -Mode Full
                Write-Host ("Compacting VHDX") -ForegroundColor Green
            }
            catch {
                Write-Warning ("Error compacting {0}, dismounting..." -f $vhd.Path)
                Hyper-V\Dismount-VHD $vhd.Path
                return
            }

            try { 
                Hyper-V\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)
                return
            }        
        }
        else {
            Write-Warning ("VM {0} is Running, skipping..." -f $vhd.VMName)
        }
    }

    #Report on new VHDX sizes
    $report = foreach ($vhd in $vhds) {
        if ((Hyper-V\Get-VHD  $vhd.path).VhdType -eq 'Dynamic') {
            [PSCustomObject]@{
                '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)
                VM                     = $vhd.VMName
                VHD                    = $vhd.Path
            }
        }
    }
    
    #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

One thought on “Compact Hyper-V VHDX files using PowerShell

  1. Pingback: PowerShell is fun :)PowerShell Profile

Leave a Reply

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