Linux virtual machines using QEMU

A picture of complicated plumbing.
qemu plumbing.

A virtual machine (vm) is a computer within your computer. Running a vm can make some system administration tasks very easy. Backups are easy, just copy the disk image file to your backup drive. You can take snapshots, which are like savepoints that you can revert the vm back to. Suppose you want to try some new software, but what if it doesn't work? What if it messes up your computer? Well, this becomes easy with a vm. You can shutdown the vm, take a snapshot. Run the vm, try the new software and if you don't like it, you can shutdown the vm and revert back to the snapshot as if nothing happened.

Slackware guest vm running on ubuntu host os.
Slackware guest vm running on ubuntu host os.

I am using ubuntu 22.04 as my host system. I needed to install qemu and virt-viewer and maybe some other stuff that I don't remember, but this blog post is not really about that part.

First, know that there is a workaround needed for virt-viewer version 7 on ubuntu 22.04:

# For Error:
# qemu-system-x86_64: You need a capable Spice client, 
# such as virt-viewer 8.0
# Workaround for virt-viewer 7:
# Add or update this section in ~/.config/mimeapps.list:
[Added Associations]
x-scheme-handler/spice+unix=remote-viewer.desktop;

I use this script to create the vm(s):

#!/bin/bash
# setup.sh
# Create a qemu vm.

CDROM="/iso/slackware/slackware15/slackware64-15.0-install-dvd.iso"
# CDROM="/iso/ubuntu/ubuntu-22.04.3-desktop-amd64.iso"
# CDROM="/iso/ubuntu/ubuntu-24.04.2-desktop-amd64.iso"

echo "To get your mouse and keyboard back from the vm, press ctrl-alt-g"
qemu-img create -f qcow2 "${1:-diskimage}.qcow2" 32G \
&& qemu-system-x86_64 \
-enable-kvm \
-cpu host \
-m 16G \
-smp cores=2,threads=2,sockets=1 \
-device AC97 \
-device virtio-net-pci,netdev=net0 \
-netdev user,id=net0,net=192.168.0.0/24,dhcpstart=192.168.0.9 \
-hda diskimage.qcow2 \
-cdrom "${CDROM}" \
-boot d \
-vga qxl \
;

I use this next script to run a vm. It is a bit more complicated than the setup script because it will be used more often. This script runs the vm with audio, internet access, and a shared directory. The variables that you might want to change are all conveniently near the beginning of the file. Each vm should have its own copy of this script.

#!/bin/bash
# run.sh
# Run a qemu vm.

# Notes:
# I have chosen the 192.168.10.0/24 network for my vm(s).

# VARIABLES:
DISKIMG="diskimage.qcow2"
CDROM="/iso/slackware/slackware15/slackware64-15.0-install-dvd.iso"
# NAME (descriptive name for the VM)
# NUM (two-digit identifier)
# NUM should be unique amongst vm(s) because it specifies the
#  last octet of the vm(s) ip address, and also more things
#  if using SPICE.
# The NAME variable is only used if using SPICE.
NAME=slackware
NUM=10
# END VARIABLES


USAGE=" Usage: run.sh [-i DISKIMG|--img=DISKIMG] [OPTS] [ARGS]

 Run a qemu vm.

 Options:
   -h,--help                  Print this message.
   -i DISKIMG,--img=DISKIMG   Defaults to '${DISKIMG}'
                              if option is not specified.

 Any other options/args will be passed to qemu.
 "


# Function to prompt for user confirmation
confirm() {
    read -r -p "Do you want to continue? (y/n): " response
    case "$response" in
        [yY][eE][sS]|[yY])
            return 0
            ;;
        *)
            echo "Operation cancelled."
            exit 1
            ;;
    esac
 }

# Check for help options
for arg in "$@"; do
    case $arg in
        -h|--help)
            echo "$USAGE"
            exit 0
            ;;
     esac
 done

# Process additional options. The -i/--img option detection is
# very permissive. It accepts all of these forms:
# -i DISKIMG, --img DISKIMG
# -i=DISKIMG, --img=DISKIMG
# -i:DISKIMG, --img:DISKIMG
# Any other options/args will be passed to qemu.
# For example, the "-snapshot" option will be passed through.
filtered_args=() # options/args to be passed to qemu.
SKIP=false
for arg in "$@"; do
    if  [[ "$arg" == --img=* \
        || "$arg" == --img:* \
        || "$arg" == --img   \
        || "$arg" == -i=*    \
        || "$arg" == -i:*    \
        || "$arg" == -i     ]]; then
        if [[ "$arg" == --img=*    ]]; then
            DISKIMG="${arg#--img=}"
         elif [[ "$arg" == --img:* ]]; then
            DISKIMG="${arg#--img:}"
         elif [[ "$arg" == --img   ]]; then
            DISKIMG="$2"
            SKIP=true
         elif [[ "$arg" == -i=* ]]; then
            DISKIMG="${arg#-i=}"
         elif [[ "$arg" == -i:* ]]; then
            DISKIMG="${arg#-i:}"
         elif [[ "$arg" == -i   ]]; then
            DISKIMG="$2"
            SKIP=true
         fi
     else
        if [[ "$SKIP" != true ]]; then
            filtered_args+=("$arg")
         fi
        # Reset SKIP for the next iteration
        SKIP=false
     fi
 done

# Check variables and prompt user before proceeding.
# Output the extracted image and the filtered arguments
echo "Disk image: $DISKIMG"
# shellcheck disable=SC2145
echo "Additional args: ${filtered_args[@]}"

if [[ ! -f "$DISKIMG" ]]; then
    echo "Error: Disk image '$DISKIMG' not found."
    exit 1
 fi

if [[ -z "$NAME" ]]; then
    echo "Error: NAME must not be empty."
    exit 1
 fi

if ! [[ "$NUM" =~ ^[0-9]{2}$ ]]; then # must be 2 digits
    echo "Error: NUM must be a two-digit number (00-99)."
    exit 1
 fi

# Prompt for confirmation before proceeding.
confirm;

# Run qemu:
# With AC97 audio.
# With "user" networking. The simplest type. Basically NAT.
# With a shared directory /vm/share (host) --> /mnt/share (vm),
#   configured for the vm in setup-guest.sh .
# The qxl graphics dipslay is scalable/resizeable.
# To boot from cdrom, change: "-boot c" to "-boot d"
echo "To get your mouse and keyboard back from the vm, press ctrl-alt-g"
qemu-system-x86_64 \
-enable-kvm \
-cpu host \
-m 16G \
-smp cores=2,threads=2,sockets=1 \
-device AC97 \
-device virtio-net-pci,netdev=net0 \
-netdev user,id=net0,net=192.168.10.0/24,dhcpstart=192.168.10.${NUM} \
-hda "$DISKIMG" \
-cdrom "${CDROM}" \
-boot c \
-vga qxl \
-monitor stdio \
-virtfs local,path=/vm/share/,mount_tag=host0,security_model=mapped,id=host0 \
"${filtered_args[@]}" \
;

# The previous version used the SPICE viewer.
# It says "Remote Viewer" in the title bar of the app. Ha!
# SPICE is a protocol for just that. As I am running the vm(s)
# on my local computer, I don't need SPICE, so...
# I leave this here for reference.

# SPICE does work locally though.

# SPICE viewer, Remote Viewer, spice-app, or whatever you call 
# it doesn't seem to allow for a scalable display window,
# which is less convenient than the technique used above.

# Maybe allow an option: --spice to activate this mode?

## spicechannel${NUM}
## ${NAME}.home.arpa.${NUM}
## ip: 192.168.10.${NUM}
## spice port 59${NUM}

# echo "To get your mouse and keyboard back from the vm,"
# echo "press left-ctrl+left-alt"
# qemu-system-x86_64 \
# -m 16G \
# -cpu host \
# -smp cores=2,threads=2,sockets=1 \
# -device AC97 \
# -device virtio-serial-pci \
# -device \
#  virtserialport,chardev=spicechannel${NUM},name=${NAME}.home.arpa.${NUM} \
# -chardev spicevmc,id=spicechannel${NUM},name=vdagent \
# -device virtio-net-pci,netdev=net0 \
# -netdev user,id=net0,net=192.168.10.0/24,dhcpstart=192.168.10.${NUM} \
# -enable-kvm \
# -hda "$DISKIMG" \
# -monitor stdio \
# -spice port=59${NUM},disable-ticketing=on \
# -display spice-app \
# -vga qxl \
# -virtfs \
#  local,path=/vm/share/,mount_tag=host0,security_model=mapped,id=host0 \
# "${filtered_args[@]}" \
# ;

This script will set up the shared directory for the guest os (the vm):

#!/bin/bash
# setup-guest.sh
# Set up a shared folder in a linux guest qemu vm.
# Run this file as root in the vm:
# Ex: sudo bash setup-guest.sh

# Run setup.sh first. Install the operating system,
# then shutdown the vm.
#
# Run run.sh to start the vm.
# In the vm:
#	sudo mkdir /mnt/share
#	sudo mount -t 9p -o trans=virtio,version=9p2000.L host0 /mnt/share

# Then you can use the share folder to run the file "setup-guest.sh"
# in the vm. Set up the vm as you like. When you have done so,
# shutdown and take a snapshot.

# Note: on slackware 15, /etc/initramfs-tools and update-initramfs
# do not exist. Slackware 15 has 9p already enabled if you install
# everything.

# Initialize the distribution flags
SLACKWARE=true
UBUNTU=false

# Function to toggle the distribution variable
toggle_distro() {
    case "$1" in
        slackware)
            SLACKWARE=true
            UBUNTU=false
            ;;
        ubuntu)
            SLACKWARE=false
            UBUNTU=true
            ;;
        *)
            echo "Invalid distribution specified."
            ;;
     esac
 }

# Check the OS type:
# /etc/os-release defines some variables.
# The $ID variable will be either "slackware" or "ubuntu"
# on either of those sytems.
if [[ -f /etc/os-release ]]; then
    # shellcheck disable=SC1091
    source /etc/os-release
    if [[ "$ID" == "slackware" ]]; then
        echo "Running on Slackware"
        toggle_distro slackware
     elif [[ "$ID" == "ubuntu" ]]; then
        echo "Running on Ubuntu"
        toggle_distro ubuntu
     else
        echo "Running on another distribution: $ID"
     fi
 else
    echo "Unable to determine the distribution."
    echo "Read the file setup-guest.sh yourself and figure it out."
    exit 1
 fi

if [[ "$SLACKWARE" == true ]]; then
mkdir /mnt/share
echo "host0 /mnt/share 9p trans=virtio,version=9p2000.L 0 0" >> /etc/fstab
fi

if [[ "$UBUNTU" == true ]]; then
mkdir /mnt/share
echo "9p
9pnet
9pnet_virtio
" >> /etc/initramfs-tools/modules
update-initramfs -u
echo "host0 /mnt/share 9p trans=virtio,version=9p2000.L 0 0" >> /etc/fstab
fi

Once you have your vm set up how you like, shut the vm down and take a snapshot:

qemu-img create -f qcow2 -b diskimage.qcow2 -F qcow2 snapshot.img

You can then edit the DISKIMG variable in run.sh so that the snapshot is the default disk image. Make diskimage.qcow2 read only so that you always have a base image and never have to wait for the guest os to install ever again. Easy.

There is quite a bit more to the whole snapshot thing, but I won't cover that here. Just know that the snapshotting command that I just showed you makes an external snapshot (which produces a new file). Know that you can also take internal snapshots of a qcow2 file using qemu-img snapshot . Read the docs.

So, yeah, I did this and it changed my life.