Provisioning a KVM QEMU Ubuntu VM without a pre‑built image can be faster than cloning an existing disk because QEMU allocates storage lazily on first write. Automating KVM QEMU Ubuntu VM provisioning with Ansible is achieved by describing the VM in libvirt XML, generating a qcow2 disk, and invoking libvirt commands from playbooks. Ansible coordinates each step, making the entire process repeatable.
📑 Table of Contents
- 💻 Understanding the Stack — What KVM Provides
- 🛠 Preparing the Host — Installing Packages and Configuring libvirt
- 📦 Defining the VM Template — libvirt XML and Ansible Variables
- 🔧 Core XML Structure
- 🤖 Provisioning Workflow — Ansible Playbook that Calls Python Helpers
- 🔧 Python Cloud‑Init Helper
- 🔧 Ansible Playbook
- 📊 Comparison of Approaches — Ansible Modules vs. Shell Commands
- 🟩 Final Thoughts
- ❓ Frequently Asked Questions
- How can I attach an additional data disk to the provisioned VM?
- Is it possible to use a bridged network instead of the default NAT?
- Can I provision VMs on a remote libvirt host?
- 📚 References & Further Reading
💻 Understanding the Stack — What KVM Provides
A KVM hypervisor turns the Linux kernel into a type‑1 virtual machine manager and exposes QEMU as the user‑space emulator that executes guest code.
When a guest starts, the kernel creates a kvm file descriptor, which the QEMU process uses to trap privileged instructions. Guest memory is mapped with mmap, so reads and writes become direct host memory accesses without extra copies. Disk I/O is handled by libvirt’s qemu driver, which translates XML domain definitions into QEMU command‑line arguments. The qcow2 format stores metadata in a B‑tree, allowing copy‑on‑write allocation to defer block allocation until the first write, reducing initial provisioning time from minutes to seconds.
What this does: (More onPythonTPoint tutorials)
- KVM: registers the process as a virtual CPU manager.
- QEMU: emulates hardware devices based on the libvirt XML.
- libvirt: provides a stable API for creating, modifying, and destroying VMs.
Key point: KVM handles low‑level virtualization; QEMU and libvirt translate high‑level definitions into executable processes, enabling fast, scriptable provisioning.
🛠 Preparing the Host — Installing Packages and Configuring libvirt
Installing the required packages and enabling the libvirt daemon creates a ready‑to‑use virtualization platform.
$ sudo apt-get update
Hit:1 http://archive.ubuntu.com/ubuntu focal InRelease
Get:2 http://archive.ubuntu.com/ubuntu focal-updates InRelease [114 kB]
...
Fetched 5,321 kB in 2s (2,660 kB/s)
Reading package lists... Done
$ sudo apt-get install -y qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils
Reading package lists... Done
Building dependency tree Reading state information... Done
The following NEW packages will be installed: bridge-utils libvirt-clients libvirt-daemon-system qemu-kvm
0 upgraded, 4 newly installed, 0 to remove and 0 not upgraded.
Need to get 2,345 kB of archives.
...
Setting up qemu-kvm (1:4.2-3ubuntu6.6) ...
Setting up libvirt-daemon-system (6.0.0-2ubuntu8.4) ...
Processing triggers for systemd (245.4-4ubuntu3.15) ...
After installation, start and enable the libvirt service.
$ sudo systemctl enable -now libvirtd
Created symlink /etc/systemd/system/multi-user.target.wants/libvirtd.service → /lib/systemd/system/libvirtd.service.
Verify that the default network is active. (Also read: ⚙️ Ansible roles vs playbooks for Docker provisioning — which one should you use?)
$ virsh net-list -all Name State Autostart Persistent -------------------------------------------- default active yes yes
Using the systemd unit ensures the daemon restarts automatically on reboot, providing a stable foundation for Ansible automation.
Key point: A properly configured libvirt host supplies the low‑level plumbing that Ansible later consumes to spin up VMs.
📦 Defining the VM Template — libvirt XML and Ansible Variables
A libvirt domain XML file describes the hardware layout, storage, and network interfaces that QEMU will use to launch an Ubuntu VM.
🔧 Core XML Structure
# ubuntu_vm.xml
<domain type='kvm'> <name>{{ vm_name }}</name> <memory unit='MiB'>{{ memory_mb }}</memory> <vcpu placement='static'>{{ vcpu_count }}</vcpu> <os> <type arch='x86_64' machine='pc-q35-5.2'>hvm</type> <boot dev='hd'/> </os> <devices> <disk type='file' device='disk'> <driver name='qemu' type='qcow2'/> <source file='{{ disk_path }}'/> <target dev='vda' bus='virtio'/> </disk> <interface type='network'> <source network='default'/> <model type='virtio'/> </interface> <graphics type='vnc' port='-1' listen='0.0.0.0'/> </devices>
</domain>
What this does:
-
name: the VM identifier referenced by
virshand Ansible. - memory / vcpu: allocate RAM and CPU cores for the guest.
- disk driver: tells QEMU to use the qcow2 format, enabling COW allocation.
-
interface: connects the VM to the libvirt
defaultNAT network. - graphics: exposes a VNC server so the VM can be inspected.
An XML template can be rendered with Jinja2 variables, making it reusable across many hosts and enabling Ansible to generate a unique file per VM.
Key point: The XML file is the single source of truth for VM hardware, and Ansible can populate it dynamically to achieve automating KVM QEMU Ubuntu VM provisioning with Ansible.
🤖 Provisioning Workflow — Ansible Playbook that Calls Python Helpers
The playbook orchestrates disk creation, XML rendering, and VM start‑up, while a small Python helper prepares a cloud‑init ISO for user data.
🔧 Python Cloud‑Init Helper
# generate_cloudinit_iso.py
import os
import subprocess
from pathlib import Path def create_iso(ssh_key_path: str, hostname: str, iso_path: str) -> None: """Generate a minimal cloud‑init ISO containing user‑data and meta‑data.""" user_data = f"""#cloud-config
hostname: {hostname}
ssh_authorized_keys: - {Path(ssh_key_path).read_text().strip()}
""" meta_data = "instance-id: iid-local01\nlocal-hostname: {}\n".format(hostname) tmp_dir = Path("/tmp/cloudinit") tmp_dir.mkdir(parents=True, exist_ok=True) (tmp_dir / "user-data").write_text(user_data) (tmp_dir / "meta-data").write_text(meta_data) subprocess.run([ "genisoimage", "-output", iso_path, "-volid", "cidata", "-joliet", "-rock", str(tmp_dir / "user-data"), str(tmp_dir / "meta-data") ], check=True) if __name__ == "__main__": create_iso( ssh_key_path=os.getenv("SSH_KEY", "/home/ubuntu/.ssh/id_rsa.pub"), hostname=os.getenv("VM_HOSTNAME", "kvm-guest"), iso_path="/var/lib/libvirt/images/cloudinit.iso" )
What this does:
-
genisoimage: builds an ISO with the
cidatavolume label required by cloud‑init. - user-data: contains the cloud‑config that sets the hostname and injects the SSH key.
- meta-data: provides instance identifiers used by cloud‑init during first boot.
Generating the ISO at provisioning time ensures the SSH key matches the current developer’s key, keeping access secure without manual steps.
🔧 Ansible Playbook
# provisioning.yml
- name: Provision Ubuntu VM on KVM hosts: localhost connection: local vars: vm_name: ubuntu-kvm-01 memory_mb: 2048 vcpu_count: 2 disk_path: /var/lib/libvirt/images/{{ vm_name }}.qcow2 iso_path: /var/lib/libvirt/images/cloudinit.iso tasks: - name: Ensure qcow2 disk exists command: qemu-img create -f qcow2 {{ disk_path }} 20G args: creates: "{{ disk_path }}" register: img_create - name: Create cloud‑init ISO command: python3 generate_cloudinit_iso.py environment: SSH_KEY: "{{ lookup('env','HOME') }}/.ssh/id_rsa.pub" VM_HOSTNAME: "{{ vm_name }}" - name: Render libvirt XML from template template: src: ubuntu_vm.xml dest: /tmp/{{ vm_name }}.xml - name: Define and start the VM command: virsh define /tmp/{{ vm_name }}.xml register: define_vm - name: Start the VM command: virsh start {{ vm_name }} when: define_vm.rc == 0
What this does:
-
qemu-img create: allocates a sparse 20 GiB qcow2 file; the
createsargument makes the task idempotent. - generate_cloudinit_iso.py: builds a cloud‑init ISO with the current user’s SSH key.
- template: renders the earlier XML, substituting Ansible variables.
- virsh define / start: registers the domain with libvirt and powers it on.
Splitting the steps gives Ansible visibility into each operation, enabling precise failure handling and repeatable runs.
Running the playbook yields the following output:
$ ansible-playbook provisioning.yml
PLAY [Provision Ubuntu VM on KVM] ********************************************* TASK [Ensure qcow2 disk exists] **********************************************
changed: [localhost] TASK [Create cloud‑init ISO] ***********************************************
changed: [localhost] TASK [Render libvirt XML from template] ************************************
changed: [localhost] TASK [Define and start the VM] **********************************************
changed: [localhost] TASK [Start the VM] *********************************************************
changed: [localhost] PLAY RECAP ******************************************************************
localhost: ok=5 changed=5 unreachable=0 failed=0
Key point: The playbook demonstrates automating KVM QEMU Ubuntu VM provisioning with Ansible by chaining discrete, idempotent tasks that together deliver a fully functional VM.
📊 Comparison of Approaches — Ansible Modules vs. Shell Commands
Two common ways to drive libvirt from Ansible are using the community.libvirt collection or falling back to generic command modules that invoke virsh directly.
| Aspect | community.libvirt | command + virsh |
|---|---|---|
| Idempotence | Built‑in checks for existing domains | Manual creates checks needed |
| Error handling | Raises AnsibleException with libvirt error codes | Requires parsing rc and stderr
|
| Flexibility | Limited to module‑supported fields | Full virsh feature set available |
According to the libvirt documentation, the XML format is the canonical representation of a domain, and any deviation introduced by a wrapper module must still be expressed in XML under the hood. Choosing the command path gives full control, while the community.libvirt module reduces boilerplate for straightforward use‑cases.
Key point: For highly customized provisioning pipelines, combining pure shell commands with Ansible’s templating offers the best balance of control and readability.
🟩 Final Thoughts
By treating libvirt XML as the source of truth and letting Ansible render and apply it, the entire lifecycle of an Ubuntu VM becomes declarative. The Python helper for cloud‑init isolates the only part that must run on the host, keeping the playbook clean and portable. This pattern scales from a single developer workstation to a fleet of build servers because each run reproduces the same disk layout, network configuration, and initial user data without manual intervention.
When the same playbook is executed repeatedly, Ansible’s idempotent tasks prevent unnecessary disk writes or VM restarts, preserving system stability while delivering the agility that automation promises.
❓ Frequently Asked Questions
How can I attach an additional data disk to the provisioned VM?
Add a new <disk> element to the XML template, create the backing file with qemu-img create, and re‑define the domain. Ansible can loop over a list of disks to automate this.
Is it possible to use a bridged network instead of the default NAT?
Yes. Define a bridge interface in the XML and ensure the host has a bridge device (e.g., br0) configured. Update the <source network='default'/> line to reference the bridge.
Can I provision VMs on a remote libvirt host?
Configure the LIBVIRT_DEFAULT_URI environment variable to point to the remote daemon (e.g., qemu+ssh://user@host/system) and run the same playbook; Ansible will execute the commands over SSH while libvirt handles the remote VM creation.
💡 Want to practise this hands-on? DigitalOcean gives new accounts $200 free credit for 60 days — enough to spin up a full Linux/Docker/Kubernetes environment at no cost.
📚 Recommended reading: Best DevOps & cloud books on Amazon — from Linux fundamentals to Kubernetes in production, curated for working engineers.
📚 References & Further Reading
- Official libvirt XML reference — complete schema for domain definitions: libvirt.org
- QEMU documentation on qcow2 format and lazy allocation: qemu.org
- Ansible documentation for the community.libvirt collection: docs.ansible.com
Top comments (0)