Reduce qcow2 size with `qemu-img convert -c -O qcow2`

Virtualisation works but we all know that images can will grow uncontrollably. In the client I run windows and schedule a cleanmgr /sagerun:1 frequently to keep windows hogging too much data. I see that the image grows but stay large.

# ls -lhs
total 47G
 14G -rw-r--r--. 1 root root  14G Mar 20 13:57 win11-active-compact.qcow2
 26G -rwxr-xr-x. 1 root root  30G Mar 20 13:51 win11-activ.qcow2
7.6G -rw-r--r--. 1 root root 7.7G Mar 20 13:45 win11.qcow2

In this case the smallest image win11.qcow2is a bare bones setup of windows without any application installed (and most of the bloat removed). The big active one is a clone of the same client after a using it for a couple of weeks. But the win11-active-compact.qcow is the same image after doing:

qemu-img convert -c -O qcow2 win11-activ.qcow2 win11-active-compact.qcow2

I wonder if I have to do this regularly by hand on a switched off client or if this is can be run automatically?

You’ve reminded me to take another look at this, as I recently ended up doing the qemu-img convert dance to reclaim some storage.

It turns out that qemu will handle it while a VM is running if the OS inside the VM that can issue TRIM commands (which includes Windows) as long as discard=unmap is set when that image is attached to that VM.

If you’re using virtual machine manager, go to the details page and look for “Discard mode” under advanced options for that disk image. You’ll then need to shut down and restart the virtual machine for the setting to take effect.

For a Windows guest, ask it to defragment/optimize the drive and you should see the image on disk size shrink on the host while it runs.

Shrink Qcow2 Disk Files - Proxmox VE and QEMU, KVM and trim | Anteru's Blog were helpful as a references - and also point out that you probably need to be using VirtIO as the storage driver for this to work.

You might look up the virt-sparsify command. I added the following to my VM backup script:

#Trim the source VM file now that it's backed up to reclaim unused disk space
virt-sparsify --in-place $path
echo "Source VM image after Trim/virt-sparsify"
qemu-img info $path

It seems it is:

Thanks for those!

On the win11 client:
image

Running Optimize-Volume C seems to have no discernible effect on the image size nor does Optimize-Volume C -Retrim

Running sdelete -z c: after installing it with winget install sdelete actually increased (doubled) the image size so that currently is bad advice.

I hadn’t tried that, but it makes sense as there’s a separate detect-zeros option that would be needed to pick up on that behaviour, and I didn’t seem to be able to set in virtual machine manager.

Script to compress an exising qcow2 file:

#!/usr/bin/bash

# Function to get file size in human-readable format
get_size() {
    du -h "$1" | cut -f1
}

# Function to get file size in bytes for calculation
get_size_bytes() {
    stat -c %s "$1" 2>/dev/null || stat -f %z "$1" 2>/dev/null
}

# Check if filename provided
if [ $# -eq 0 ]; then
    echo "Usage: $0 <filename>"
    echo "Example: $0 mydisk"
    exit 1
fi

# Validate input (no path traversal, no special chars)
filename="$1"
if [[ "$filename" =~ [^a-zA-Z0-9_.-] ]]; then
    echo "Error: Filename contains invalid characters"
    exit 1
fi

# Check if source exists
source_file="${filename}.qcow2"
if [ ! -f "$source_file" ]; then
    echo "Error: Source file '$source_file' not found"
    exit 1
fi

# Display initial info
echo "=========================================="
echo "Processing: $source_file"
echo "=========================================="

# Get original size
original_size=$(get_size "$source_file")
original_bytes=$(get_size_bytes "$source_file")
echo "Original size: $original_size"

# Create backup with timing
backup_file="${filename}_backup.qcow2"
echo ""
echo "[1/2] Creating backup..."

backup_start=$(date +%s.%N)
cp "$source_file" "$backup_file"
backup_status=$?
backup_end=$(date +%s.%N)

if [ $backup_status -ne 0 ]; then
    echo "Error: Failed to create backup"
    exit 1
fi

backup_time=$(echo "$backup_end - $backup_start" | bc)
echo "  ✓ Backup created: $backup_file"
echo "  ⏱  Time: ${backup_time} seconds"

# Check if qemu-img is available
if ! command -v qemu-img &> /dev/null; then
    echo "Error: qemu-img not found"
    exit 1
fi

# Compress with timing
echo ""
echo "[2/2] Compressing with qemu-img (using -c flag)..."

compress_start=$(date +%s.%N)
qemu-img convert -O qcow2 -c "$backup_file" "$source_file"
compress_status=$?
compress_end=$(date +%s.%N)

if [ $compress_status -ne 0 ]; then
    echo "Error: Compression failed"
    exit 1
fi

compress_time=$(echo "$compress_end - $compress_start" | bc)

# Get compressed size
compressed_bytes=$(get_size_bytes "$source_file")
compressed_size=$(get_size "$source_file")

# Calculate size reduction
if [ $original_bytes -gt 0 ]; then
    reduction_bytes=$((original_bytes - compressed_bytes))
    reduction_percent=$(echo "scale=2; ($reduction_bytes * 100) / $original_bytes" | bc)

    # Convert reduction bytes to human readable
    if [ $reduction_bytes -gt 1073741824 ]; then
        reduction_hr=$(echo "scale=2; $reduction_bytes / 1073741824" | bc)"G"
    elif [ $reduction_bytes -gt 1048576 ]; then
        reduction_hr=$(echo "scale=2; $reduction_bytes / 1048576" | bc)"M"
    elif [ $reduction_bytes -gt 1024 ]; then
        reduction_hr=$(echo "scale=2; $reduction_bytes / 1024" | bc)"K"
    else
        reduction_hr="${reduction_bytes}B"
    fi
fi

# Display results
echo ""
echo "=========================================="
echo "COMPLETE! Summary:"
echo "=========================================="
echo "Original size:      $original_size"
echo "Compressed size:    $compressed_size"
echo "Space saved:        $reduction_hr ($reduction_percent%)"
echo ""
echo "Timing:"
echo "  Backup:           ${backup_time} seconds"
echo "  Compression:      ${compress_time} seconds"
echo "  Total time:       $(echo "$backup_time + $compress_time" | bc) seconds"
echo ""
echo "Files:"
echo "  Original (compressed): $source_file"
echo "  Backup (uncompressed): $backup_file"
echo "=========================================="

# Optional: Show compression ratio
if [ $original_bytes -gt 0 ]; then
    ratio=$(echo "scale=2; $compressed_bytes / $original_bytes" | bc)
    echo "Compression ratio:  ${ratio}:1"
    echo "=========================================="
fi