The course has spent four modules missing the same thing. In Module 4, release 3.3.0 would not start and deploy.sh performed an automatic rollback — correctly — but nobody ever got to diagnose why it would not start, because there was nowhere to reproduce it. In 07-01 you practised boot recovery by breaking the real server's fstab, with a snapshot taken first and your fingers crossed. In 07-03, the last exercise called for dropping the page cache, removing the ionice and stopping the application in order to measure the I/O scheduler: three things you do not do in production. And checking whether the CPU exposes AES-NI to the guest was left outstanding, which is a change to the virtual machine's configuration.

All of it points to the same gap: a test environment is missing. This lesson builds one, and along the way it teaches you the virtualisation stack from the inside — which is what will make the next lesson, containers, comprehensible by contrast rather than by the wrong analogy.

Contents

  1. What virtualising is: the three models
  2. Type 1 and type 2 hypervisors
  3. Virtualisation versus containers
  4. KVM, QEMU and libvirt: who does what
  5. Installation and checking for support
  6. virsh: managing machines from the command line
  7. Creating a machine with virt-install
  8. Unattended provisioning with cloud-init
  9. Storage: formats, pools and snapshots
  10. Networking: NAT, isolated and bridged
  11. Performance: virtio and CPU configuration
  12. Cloning and creating srv-tramontana-test

What virtualising is: the three models

Virtualising is running a complete operating system inside another, making it believe it has hardware of its own. The underlying technical problem: certain CPU instructions are privileged and only the kernel may execute them. If the guest system believes itself to be the kernel and executes one of them, it has to be intercepted and something done with it. The three ways of resolving that define the three models:

Model How it handles privileged instructions Performance Requires modifying the guest
Emulation Translates each instruction in software 10-100 times slower No
Paravirtualisation The guest knows it is virtualised and asks the hypervisor for services through an API 90-95% of native Yes
Hardware-assisted The CPU has a specific mode (VT-x/AMD-V) that intercepts them by itself 95-99% of native No

Emulation still has its uses: it is what lets you run ARM on x86 (qemu-system-aarch64) or a system from 1985. For virtualising Linux on Linux on the same architecture, it is absurdly slow.

Paravirtualisation was the dominant technique before 2006 (Xen popularised it) and it demanded a modified kernel. Today it survives in something far more important than it looks: the paravirtualised drivers, virtio, which you will see later on and which are the difference between a slow VM and a fast one.

Hardware-assisted virtualisation is what srv-tramontana uses and what is used everywhere today. Intel calls it VT-x and AMD calls it AMD-V; both add a privilege level below the kernel where the hypervisor lives.

Type 1 and type 2 hypervisors

Type 1 (native) Type 2 (hosted)
Where it runs Directly on the hardware As an application on top of an OS
Examples VMware ESXi, Xen, Hyper-V VirtualBox, VMware Workstation
Performance Higher: no OS in between Lower, though not by much with VT-x
Typical use Data centres, the cloud Desktop, development, the lab

And KVM defies that classification, which is the interesting part: it is a Linux kernel module, so technically there is an operating system underneath (type 2), but that operating system becomes the hypervisor when you load it, with direct access to the virtualisation extensions (type 1). The honest answer is that it is a hybrid, and in practice it performs like a type 1.

Your laptop is running VirtualBox, which is type 2. srv-tramontana is a VM inside it. And in this lesson you are going to create a VM inside srv-tramontana, which is called nested virtualisation and requires VirtualBox to expose the CPU extensions to the guest. We will come back to that in the installation section.

Virtualisation versus containers

This comparison is the bridge to the next lesson, and it is worth seeing before you touch containers so that you do not pick up the wrong analogy:

graph TB
    subgraph VM["Virtual machine"]
        H1["Physical hardware"] --> K1["Host kernel + KVM"]
        K1 --> Q1["QEMU"] & Q2["QEMU"]
        Q1 --> KG1["Guest kernel 1"] --> A1["Libraries + app"]
        Q2 --> KG2["Guest kernel 2"] --> A2["Libraries + app"]
    end
    subgraph CT["Containers"]
        H2["Physical hardware"] --> K2["A SINGLE host kernel"]
        K2 --> N1["namespace + cgroup"] --> B1["Libraries + app"]
        K2 --> N2["namespace + cgroup"] --> B2["Libraries + app"]
    end

The structural difference is in a single row of the diagram: every VM has its own kernel; containers share the host's. Every other difference follows from that:

Virtual machine Container
Kernel Its own Shared with the host
Startup 10-60 seconds Milliseconds
Weight on disk GB (a complete system) MB (only the application and its libraries)
Memory overhead 200 MB - 1 GB per VM Practically none
Isolation Strong: a virtual hardware boundary Weaker: a kernel boundary
Can it run another OS Yes (Windows, BSD, another Linux kernel) No: only Linux, and the host's at that
Typical density Dozens per host Hundreds or thousands

And the two consequences to hold on to:

  • For security isolation, the VM is superior. A container escape is a failure in the shared kernel and gives access to the host. A VM escape requires breaching the hypervisor, which is a far smaller surface. That is why cloud providers run different customers' workloads in separate VMs, not in containers on the same kernel.
  • For density and speed, the container wins by orders of magnitude. Starting a hundred containers is a matter of seconds; a hundred VMs are a hundred kernels.

They are not alternatives: they combine. What is usual today is running containers inside virtual machines, taking the VM's isolation between tenants and the container's density within each one.

KVM, QEMU and libvirt: who does what

Three pieces that get confused constantly. Each solves a different problem:

Piece What it is What it solves
KVM A kernel module (/dev/kvm) Giving access to VT-x/AMD-V: running the virtual CPU at native speed
QEMU A user-space program Emulating everything else: disk, network, keyboard, graphics, BIOS
libvirt A daemon and a library Management: define, start, stop, networking, storage, a stable API

The division of labour between the first two is the key: KVM handles the CPU and the memory, which is where performance matters; QEMU emulates the devices, which are accessed far less. Without KVM, QEMU would emulate the CPU as well and you would have the slow model from the first section. The combination is usually written "QEMU/KVM".

And libvirt contributes something that is not obvious until you manage more than two machines: a stable abstraction layer. QEMU's command line is enormously long and changes between versions; libvirt defines the machine in XML, and virsh always speaks the same way. It also manages virtual networks, storage pools and permissions, and it is the API used by virt-manager, Vagrant, OpenStack and Terraform.

Installation and checking for support

First, check that hardware support is there. And here the complication of nested virtualisation shows up:

$ sudo apt install cpu-checker
$ kvm-ok
INFO: /dev/kvm does not exist
HINT:   sudo modprobe kvm_intel
INFO: Your CPU supports KVM extensions
INFO: KVM (vmx) is disabled by your BIOS

srv-tramontana is a VirtualBox VM, and by default VirtualBox does not expose the virtualisation extensions to the guest. You have to enable it from the host, with the machine powered off:

# On the host laptop, with srv-tramontana POWERED OFF
$ VBoxManage modifyvm srv-tramontana --nested-hw-virt on
$ VBoxManage showvm srv-tramontana --machinereadable | grep -i nested
nestedHWVirt="on"

On VMware the equivalent option is Virtualize Intel VT-x/EPT, and on a physical server it is enough to enable VT-x/AMD-V in the UEFI. After powering back on:

$ kvm-ok
INFO: /dev/kvm exists
KVM acceleration can be used

$ grep -o -m1 -E 'vmx|svm' /proc/cpuinfo
vmx
$ lsmod | grep kvm
kvm_intel             376832  0
kvm                  1146880  1 kvm_intel
irqbypass              12288  1 kvm

If kvm-ok still fails, virtualisation will work by pure emulation: usable for trying out the mechanics of this lesson, unusable for real work.

$ sudo apt install qemu-kvm libvirt-daemon-system libvirt-clients \
      virtinst bridge-utils libguestfs-tools cloud-image-utils

$ systemctl is-active libvirtd
active

# The libvirt group allows managing VMs without sudo.
# SECURITY WARNING: anybody who can define a VM can mount any of the host's
# disks inside it and read it. Belonging to this group is equivalent to root
# in practice. It is the same warning that will be needed with the docker
# group in 07-05.
$ sudo usermod -aG libvirt operator
$ newgrp libvirt
$ virsh --connect qemu:///system version
Compiled against library: libvirt 10.0.0
Using library: libvirt 10.0.0
Using API: QEMU 10.0.0
Running hypervisor: QEMU 8.2.2

Note qemu:///system versus qemu:///session: the first is system machines, managed by the daemon as root, with access to the shared networks and pools. The second is user machines, unprivileged, with limited networking. For a server, always system, and it is worth fixing it so you do not have to type it:

$ echo 'export LIBVIRT_DEFAULT_URI="qemu:///system"' >> ~/.bashrc
$ source ~/.bashrc

And an important check that closes the circle with 07-01: installing libvirt brings up virbr0, the default NAT network's interface, and it is exactly the one that cost 35 seconds of boot time because systemd-networkd-wait-online was waiting for it to get a carrier:

$ ip -brief addr show virbr0
virbr0           DOWN           192.168.122.1/24
$ systemd-analyze | tail -1
Startup finished in 3.402s (kernel) + 9.118s (userspace) = 12.520s

It is still at 12 seconds because in the 07-01 exercise you limited systemd-networkd-wait-online to enp0s3 with a drop-in. Without that fix, installing libvirt would have added 35 seconds to the boot all over again, and probably nobody would have connected the two things.

virsh: managing machines from the command line

virsh is the main tool. Its essential operations:

$ virsh list --all
 Id   Name   State
--------------------

$ virsh net-list --all
 Name      State    Autostart   Persistent
------------------------------------------------
 default   active   yes         yes

$ virsh pool-list --all
 Name       State    Autostart
-------------------------------
 default    active   yes
Command What it does
virsh list --all Every defined machine, with its state
virsh dominfo <vm> CPU, memory, autostart, security
virsh start <vm> Starts it
virsh shutdown <vm> An orderly shutdown: asks the guest to shut down (ACPI)
virsh destroy <vm> Pulling the power: immediate and with no warning to the guest
virsh reboot <vm> An orderly reboot
virsh autostart <vm> Starts when the host boots
virsh console <vm> The serial console: the way in when there is no network
virsh edit <vm> Edits the XML with validation
virsh dumpxml <vm> Dumps the definition
virsh undefine <vm> Deletes the definition (not the disks, unless --remove-all-storage)
virsh domifaddr <vm> The machine's IP addresses

The distinction between shutdown and destroy deserves emphasis because the name is misleading: destroy deletes nothing, it is the equivalent of pulling the cable. It does not destroy the definition or the disks, but it can leave the guest's filesystem inconsistent. It is the same logic as shutdown versus pulling the cable from 01-05, and the correct order is always to try shutdown first and wait:

$ virsh shutdown srv-tramontana-test
Domain 'srv-tramontana-test' is being shutdown
$ for i in {1..30}; do
      [[ "$(virsh domstate srv-tramontana-test)" == "shut off" ]] && break
      sleep 2
  done
$ virsh domstate srv-tramontana-test
shut off

It is exactly the SIGTERM → wait → verify → SIGKILL pattern you learned in 03-06, applied to whole machines.

And virsh console is the tool that makes everything in 07-01 practicable: it gives access to the guest's serial console, so you can see the GRUB menu, enter emergency mode and repair a broken fstab with no need for a graphical interface. You exit with Ctrl+].

Creating a machine with virt-install

virt-install is the non-interactive way of defining and starting a machine. The complete example, commented line by line:

$ sudo virt-install \
    --name srv-tramontana-test \
    --memory 2048 \
    --vcpus 2 \
    --cpu host-passthrough \
    --disk path=/var/lib/libvirt/images/test.qcow2,size=20,format=qcow2,bus=virtio \
    --network network=default,model=virtio \
    --os-variant ubuntu24.04 \
    --graphics none \
    --console pty,target_type=serial \
    --location 'http://archive.ubuntu.com/ubuntu/dists/noble/main/installer-amd64/' \
    --extra-args 'console=ttyS0,115200n8'
Option Why that value
--cpu host-passthrough Exposes the real CPU to the guest, AES-NI included — the outstanding item from 07-03
bus=virtio The paravirtualised disk driver: indispensable for performance
model=virtio The same for the network
--os-variant Lets libvirt pick the optimal values for that system
--graphics none A server needs no virtual screen
--console pty,target_type=serial A serial console, which is what virsh console uses
console=ttyS0,115200n8 Tells the guest's kernel to talk over the serial port

The last two go together and they are the pair that makes virsh console work: without console=ttyS0 on the kernel command line, the guest would write to a virtual screen nobody is looking at, and you would see a blank console. It is a baffling and very common failure.

An interactive installation from the Ubuntu installer takes twenty minutes and requires answering questions. For a test environment you want to be able to recreate, that is no use. The alternative is the next section.

Unattended provisioning with cloud-init

The distributions publish cloud images: pre-installed disks, with cloud-init inside, that configure themselves on first boot from some data you hand them.

$ cd /var/lib/libvirt/images
$ sudo wget -q https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
$ sudo qemu-img info noble-server-cloudimg-amd64.img
image: noble-server-cloudimg-amd64.img
file format: qcow2
virtual size: 3.5 GiB (3758096384 bytes)
disk size: 588 MiB

Note the difference between virtual size and disk size: 3.5 GiB declared, 588 MiB occupied. That is qcow2's thin provisioning, explained in the next section.

The configuration data is delivered in two YAML files:

$ mkdir -p ~/cloud-init && cd ~/cloud-init
$ cat > user-data <<'EOF'
#cloud-config
hostname: srv-tramontana-test
fqdn: srv-tramontana-test.tramontana.example
manage_etc_hosts: true

users:
  - name: operator
    groups: [sudo, adm]
    shell: /bin/bash
    sudo: 'ALL=(ALL) NOPASSWD:ALL'
    lock_passwd: false
    ssh_authorized_keys:
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... operator@laptop-student

# Consistent with what was learned in 06-02: no passwords over SSH
ssh_pwauth: false
disable_root: true

package_update: true
package_upgrade: true
packages:
  - postgresql-16
  - restic
  - ufw
  - auditd
  - libpam-pwquality
  - sysstat
  - linux-tools-generic

write_files:
  - path: /etc/tramontana/app.conf
    owner: root:root
    permissions: '0640'
    content: |
      db_host=127.0.0.1
      db_port=5432
      db_name=tramontana_bookings
      max_connections=80
      query_timeout=30
      log_level=debug
      listen=127.0.0.1
  - path: /etc/sysctl.d/70-performance.conf
    owner: root:root
    permissions: '0644'
    content: |
      # Copied from production so that the tests are representative
      net.core.default_qdisc = fq
      net.ipv4.tcp_congestion_control = bbr
      vm.swappiness = 10
      vm.dirty_background_ratio = 5
      vm.dirty_ratio = 10

runcmd:
  - [ install, -d, -m, '0750', -o, root, -g, root, /opt/tramontana ]
  - [ install, -d, -m, '2770', /srv/tramontana/backups ]
  - [ sysctl, --system ]
  - [ systemctl, enable, --now, sysstat ]

final_message: "srv-tramontana-test ready after $UPTIME seconds"
EOF

$ cat > network-config <<'EOF'
version: 2
ethernets:
  enp1s0:
    dhcp4: true
EOF

The two files are packed into a tiny ISO image that the machine reads as though it were a CD:

$ cloud-localds -N network-config seed.iso user-data
$ ls -lh seed.iso
-rw-rw-r-- 1 operator operator 366K Aug 18 18:04 seed.iso
$ sudo mv seed.iso /var/lib/libvirt/images/

And now the machine is created, with one important detail: the downloaded image is not used directly, a new disk is created that uses it as a backing file:

$ cd /var/lib/libvirt/images
# The VM's disk is backed by the base image, which stays untouched
$ sudo qemu-img create -f qcow2 -F qcow2 \
      -b noble-server-cloudimg-amd64.img test.qcow2 20G
Formatting 'test.qcow2', fmt=qcow2 cluster_size=65536 ...

$ sudo virt-install \
    --name srv-tramontana-test \
    --memory 2048 --vcpus 2 --cpu host-passthrough \
    --disk path=/var/lib/libvirt/images/test.qcow2,device=disk,bus=virtio \
    --disk path=/var/lib/libvirt/images/seed.iso,device=cdrom \
    --network network=default,model=virtio \
    --os-variant ubuntu24.04 \
    --graphics none --console pty,target_type=serial \
    --import --noautoconsole

Domain creation completed.

$ virsh list
 Id   Name                  State
-------------------------------------
 1    srv-tramontana-test   running

$ sleep 60 && virsh domifaddr srv-tramontana-test
 Name       MAC address          Protocol     Address
-------------------------------------------------------------------------------
 vnet0      52:54:00:8a:c1:f2    ipv4         192.168.122.104/24

$ ssh [email protected] 'hostname; sysctl -n net.ipv4.tcp_congestion_control'
srv-tramontana-test
bbr

Under two minutes, without a single question, and with the production configuration already applied. And — this is what makes the method valuable — the user-data is a text file that gets versioned in git: the test machine is reproducible. It is the same infrastructure-as-code idea you will see with Ansible in 07-06, in its simplest form.

Storage: formats, pools and snapshots

raw versus qcow2

$ sudo qemu-img create -f raw demo.raw 10G
$ sudo qemu-img create -f qcow2 demo.qcow2 10G
$ ls -lh demo.raw demo.qcow2
-rw-r--r-- 1 root root  10G Aug 18 18:12 demo.raw
-rw-r--r-- 1 root root 193K Aug 18 18:12 demo.qcow2
$ du -h demo.raw demo.qcow2
0	demo.raw
196K	demo.qcow2

Look at the difference between ls and du for demo.raw: ls says 10 GB, du says 0. It is a sparse file, so raw also does thin provisioning on filesystems that support it. The real difference between the formats lies elsewhere:

raw qcow2
Performance The maximum possible 95-98% of raw's
Thin provisioning Yes, with sparse files Yes, natively
Internal snapshots No Yes
Backing files No Yes
Compression and encryption No Yes
Can it be mounted on the host Directly with losetup Requires qemu-nbd

The practical decision: qcow2 unless you measure that the 2-5% of performance matters. The snapshots and the backing files are worth far more than that margin in a test environment, and for production there are better alternatives than either (a direct LVM volume, which is what many hypervisors use).

$ sudo qemu-img info test.qcow2
image: test.qcow2
file format: qcow2
virtual size: 20 GiB (21474836480 bytes)
disk size: 1.24 GiB
backing file: noble-server-cloudimg-amd64.img
backing file format: qcow2

# Enlarging a disk: the file first, THEN the filesystem inside it
$ sudo qemu-img resize test.qcow2 +10G
Image resized.
# And inside the guest, with what you learned in 05-04:
$ ssh [email protected] 'sudo growpart /dev/vda 1 && sudo resize2fs /dev/vda1'

# Convert between formats
$ sudo qemu-img convert -f qcow2 -O raw test.qcow2 test.raw

The backing file is the piece that makes a lab efficient: the base image is shared and each VM only stores its differences. Ten test machines take up the base plus ten small difference files, not ten complete systems. With one rule that has to be respected: if you modify the base image, every disk backed by it is corrupted. The base is read-only, in practice.

# Flatten a disk: absorb the base's content and break the dependency
$ sudo qemu-img rebase -p -b "" test.qcow2

Storage pools

libvirt organises storage into pools, which abstract away where the disks live:

$ virsh pool-list --all --details
 Name      State    Autostart  Persistent  Capacity  Allocation  Available
-------------------------------------------------------------------------------
 default   running  yes        yes         14.51 GiB    1.31 GiB   13.20 GiB

$ virsh pool-dumpxml default | grep -E '<path>|<type|name>'
<pool type='dir'>
  <name>default</name>
      <path>/var/lib/libvirt/images</path>

# Create a pool on the data volume, which has more room
$ virsh pool-define-as test dir --target /srv/tramontana/vm
$ virsh pool-build test && virsh pool-start test
$ virsh pool-autostart test
$ virsh vol-list test --details

A pool can be a directory, an LVM volume group, an iSCSI device, NFS or Ceph. The logical type over LVM is interesting here: vg-data has existed since 05-04 and using logical volumes directly as disks avoids the file layer.

Snapshots, and the warning

$ virsh snapshot-create-as srv-tramontana-test \
    --name clean-24.04 \
    --description "Freshly provisioned by cloud-init, before any testing" \
    --atomic
Domain snapshot clean-24.04 created

$ virsh snapshot-list srv-tramontana-test
 Name          Creation Time               State
----------------------------------------------------
 clean-24.04   2026-08-18 18:22:41 +0200   shutoff

# Break something, test, and go back
$ virsh snapshot-revert srv-tramontana-test --snapshotname clean-24.04
$ virsh snapshot-delete srv-tramontana-test --snapshotname clean-24.04

And the warning, carrying the same weight as the one in 05-04 about RAID:

A snapshot is not a backup. It lives in the same file (or beside it), on the same disk, on the same machine. If the disk fails, if the file is corrupted or if somebody deletes the directory, the snapshot and the original go together. It meets none of the three requirements of the 3-2-1 rule from 05-08.

What a snapshot is: a fast point of return for a risky operation. Exactly what you need before trying a kernel change. And it has two costs you need to know about: every active snapshot degrades write performance, because each modified block requires copying the original first; and accumulating dozens of chained snapshots can make the disk unmanageable.

Snapshots taken with the machine running (--live) also capture the memory and are far more delicate: if the application has data half written, the restored state can be inconsistent. The --quiesce option asks the guest agent to flush the filesystems first, and it requires qemu-guest-agent installed inside. For a test environment, shutting the machine down and taking the snapshot cold is simpler and more reliable.

Networking: NAT, isolated and bridged

The three modes, and when to use each:

Mode The VM sees The network sees the VM Between VMs When
NAT (default) The Internet, yes No Yes The default; the lab
Isolated Nothing outside No Yes Testing with no way out; malware
Bridged Everything Yes, with an IP of its own Yes Servers that have to be reachable
$ virsh net-dumpxml default
<network>
  <name>default</name>
  <forward mode='nat'/>
  <bridge name='virbr0' stp='on' delay='0'/>
  <ip address='192.168.122.1' netmask='255.255.255.0'>
    <dhcp>
      <range start='192.168.122.2' end='192.168.122.254'/>
    </dhcp>
  </ip>
</network>

Under NAT, libvirt sets up a bridge (virbr0), a DHCP and DNS server (dnsmasq) and the address translation rules. The VM reaches the Internet with the host's IP, and nobody on the network can initiate a connection towards it. For a test environment that is the right thing: isolation by default with nothing to configure.

And this is a good place to close the matter of virbr0 and the boot, because the complete mechanism is now visible: the interface exists from the moment libvirt starts, but it has no carrier until a VM connects to it. systemd-networkd-wait-online, in its default configuration, waited for all the managed interfaces to be online, and that one never would be with the machines powered off. Hence the 35 seconds.

$ virsh net-define /dev/stdin <<'EOF'
<network>
  <name>isolated</name>
  <bridge name='virbr1' stp='on' delay='0'/>
  <ip address='192.168.200.1' netmask='255.255.255.0'>
    <dhcp><range start='192.168.200.10' end='192.168.200.100'/></dhcp>
  </ip>
</network>
EOF
$ virsh net-start isolated && virsh net-autostart isolated

With no <forward> element, the network has no way out: the machines can see each other and the host, and nothing else. It is what you use to test a firewall configuration or to analyse something suspicious.

The bridge is the mode you need when the VM has to be a reachable server. It requires reconfiguring the host's network with netplan, applying 06-01 — and with the same care, because it can leave you with no access to the machine:

$ sudo cp -p /etc/netplan/50-cloud-init.yaml \
      /etc/netplan/50-cloud-init.yaml.bak-$(date +%F)
$ sudo tee /etc/netplan/60-bridge.yaml >/dev/null <<'EOF'
network:
  version: 2
  ethernets:
    enp0s3:
      dhcp4: false
      dhcp6: false
  bridges:
    br0:
      interfaces: [enp0s3]
      addresses: [10.0.2.15/24]
      routes:
        - to: default
          via: 10.0.2.2
      nameservers:
        addresses: [10.0.2.2, 1.1.1.1]
      parameters:
        stp: false
        forward-delay: 0
EOF
$ sudo chmod 600 /etc/netplan/60-bridge.yaml

# netplan try with automatic rollback after 120 s: the safety net from 06-01
$ sudo netplan try
Do you want to keep these settings?
Press ENTER before the timeout to accept the new configuration

That netplan try is not optional. Configuring a bridge over SSH is exactly the case it exists for: if the bridge goes wrong, you lose the session, and the automatic rollback after 120 seconds gives the machine back to you.

And a specific warning: the bridge moves the physical interface's IP onto the bridge. During the transition there is a network outage of a few seconds, and if the configuration is wrong the outage is permanent. On a remote server with no console, this is done with enormous care.

$ virsh attach-interface srv-tramontana-test bridge br0 \
      --model virtio --persistent

Performance: virtio and CPU configuration

virtio: the difference between slow and fast

The devices QEMU presents to the guest can be emulated (imitating a real device, such as an Intel e1000 card) or paravirtualised (virtio: a device that does not exist in the real world, designed to talk directly to the hypervisor).

The difference is large and worth seeing measured:

Device Emulated virtio
Disk ~40% of native performance 95-98%
Network ~1 Gbit/s with a lot of CPU 10 Gbit/s+ with little CPU
$ virsh dumpxml srv-tramontana-test | grep -A2 -E "<disk|<interface"
    <disk type='file' device='disk'>
      <driver name='qemu' type='qcow2' cache='none' io='native' discard='unmap'/>
      <source file='/var/lib/libvirt/images/test.qcow2'/>
      <target dev='vda' bus='virtio'/>
    <interface type='network'>
      <mac address='52:54:00:8a:c1:f2'/>
      <source network='default'/>
      <model type='virtio'/>

The clues that virtio is active: the disk is called vda (not sda) and the bus is virtio. If you see sda with a sata bus, you are using emulation and losing more than half of the disk performance.

The three attributes of the <driver> matter as well:

Attribute Value Why
cache none Avoids double host/guest caching; safer against power cuts
io native The kernel's asynchronous I/O; better with cache=none
discard unmap Propagates the guest's TRIM: the qcow2 file shrinks on deletion

discard=unmap is what stops a qcow2 disk growing indefinitely: without it, deleting data inside the guest frees no space on the host.

CPU configuration

$ virsh dumpxml srv-tramontana-test | grep -A3 '<cpu'
  <cpu mode='host-passthrough' check='none' migratable='on'/>

The three modes:

Mode What it exposes Live migration
host-passthrough The real CPU, all its extensions Only to identical hosts
host-model A model equivalent to the host's To similar hosts
custom A specific model you choose To any host with that model or better

And here the outstanding item from 07-03, the AES-NI one, gets resolved:

$ ssh [email protected] 'grep -o -m1 aes /proc/cpuinfo'
aes

$ ssh [email protected] 'openssl speed -evp aes-256-cbc 2>&1 | tail -2'
type             16 bytes     64 bytes    256 bytes   1024 bytes   8192 bytes
aes-256-cbc    1284412.10k  3841204.88k  4218844.12k  4402118.42k  4441204.88k

4.4 GB/s of AES encryption: that is only possible with hardware acceleration. With host-model instead of host-passthrough, or with a generic virtual CPU, the same openssl speed would give between 200 and 600 MB/s, and there is the factor of five to ten that was put forward as a hypothesis in the last exercise of 07-03 for the slowness of the encrypted backup.

The downside of host-passthrough is live migration: a machine that sees a processor's exact extensions cannot be migrated to another host that does not have them, because the guest is using them. For a test environment on a single host, host-passthrough is the right choice.

Two more memory settings, mentioned for completeness:

$ virsh dumpxml srv-tramontana-test | grep -A3 -E '<memballoon|<memoryBacking'
    <memballoon model='virtio'>

Ballooning lets the host reclaim memory from a guest that is not using it, through a driver inside the guest that "inflates" it. It is useful for density, and counterproductive for a database, which reserves shared memory and does not expect to lose it. Hugepages (<memoryBacking><hugepages/></memoryBacking>) reduce the guest's TLB misses, with the same logic as the THP from 07-03, and they are common on large database machines.

Cloning and creating srv-tramontana-test

The cloud-init machine is clean and reproducible, but to reproduce a production problem you sometimes need a copy of the real state. virt-clone makes one:

# The source machine must be POWERED OFF
$ virsh shutdown srv-tramontana-test
$ virt-clone --original srv-tramontana-test \
             --name srv-tramontana-test-2 \
             --auto-clone
Allocating 'test-2.qcow2'   |  20 GB  00:00:04
Clone 'srv-tramontana-test-2' created successfully.

virt-clone copies the disks and generates new MAC addresses and UUIDs, which is what avoids the conflict caused by copying the file by hand. But it leaves the inside of the guest untouched: the same machine name, the same SSH host keys, the same machine-id. And that causes real problems:

$ sudo virt-sysprep -d srv-tramontana-test-2 \
    --hostname test-2 \
    --operations defaults,-ssh-userdir
[   0.0] Examining the guest ...
[   4.2] Performing "abrt-data" ...
[   4.2] Performing "bash-history" ...
[   4.3] Performing "machine-id" ...
[   4.4] Performing "ssh-hostkeys" ...
[   4.5] Performing "logfiles" ...
[   4.8] Setting a random seed
[   4.9] Setting the machine ID in /etc/machine-id
[   5.0] Setting the hostname: test-2

virt-sysprep cleans out what makes a machine unique: the machine-id — which systemd and the journal use as an identifier — SSH host keys, logs, shell history, and network configuration tied to the previous MAC. Without this step, two machines with the same machine-id produce mixed-up journal entries if they are centralised, and two with the same host keys trigger the changed-fingerprint warning from 06-02.

And libguestfs also lets you work with a powered-off machine's disk without starting it, which solves the 07-01 scenario without needing rescue media:

# Inspect a disk without starting the machine
$ sudo virt-ls -d srv-tramontana-test /etc/tramontana/
app.conf

# Read a file
$ sudo virt-cat -d srv-tramontana-test /etc/fstab

# And REPAIR a broken fstab without booting or using a live CD
$ sudo guestfish -d srv-tramontana-test -i edit /etc/fstab

That is what turns the recovery of 07-01 into a two-minute operation when the machine is virtual: instead of booting from an ISO, mounting and doing a chroot, you edit the file directly on the powered-off disk.

The test environment, and what it is for

With srv-tramontana-test up and running, the four debts from the start of the lesson can be settled:

# 1. The 3.3.0 release that would not start (the Module 4 debt)
$ virsh snapshot-create-as srv-tramontana-test --name before-3.3.0 --atomic
$ scp /srv/tramontana/backups/outgoing/tramontana-3.3.0.tar.gz \
      [email protected]:/tmp/
$ ssh [email protected] \
      'sudo ~/scripts/deploy.sh 3.3.0; sudo journalctl -u tramontana -n 40'
# And with the complete log in front of you, it can finally be diagnosed

# 2. The I/O scheduler (exercise 3 of 07-03), without degrading production
$ ssh [email protected] \
      'sync; echo 3 | sudo tee /proc/sys/vm/drop_caches; \
       echo mq-deadline | sudo tee /sys/block/vda/queue/scheduler'

# 3. The boot recovery from 07-01, with virsh console
$ virsh console srv-tramontana-test
# (Ctrl+] to exit)

# 4. And AES-NI, already checked
$ ssh [email protected] 'grep -c -m1 aes /proc/cpuinfo'
1

# Go back to the starting point when you are done
$ virsh snapshot-revert srv-tramontana-test --snapshotname before-3.3.0

On live migration, to close: libvirt lets you move a running machine from one host to another without interrupting it, copying the memory in several passes until so little is left to transfer that it can be paused for a few milliseconds and the switch completed.

$ virsh migrate --live srv-tramontana-test qemu+ssh://host2/system

It requires shared storage (or --copy-storage-all, which is much slower) and compatible CPUs — hence the downside of host-passthrough. It is the basis of maintenance with no outage: the hosts are drained one by one to update them. And it is a preview of 07-07: moving a machine is not the same as having high availability, because migration requires the source host to still be working. Against a sudden failure, it is no use.

Graphical and higher-level tools, for the record: virt-manager is libvirt's graphical interface, convenient for exploring and for reaching the console of a machine from a graphical environment; Vagrant defines development machines in a versionable Vagrantfile and works over libvirt, VirtualBox or VMware, and it is the right tool when a whole team needs identical environments; and Terraform and OpenStack operate at infrastructure scale, also talking to libvirt underneath.

Common Mistakes and Tips

  • Not enabling nested virtualisation. kvm-ok says KVM is disabled by your BIOS and everything works by emulation, ten times slower. On VirtualBox, VBoxManage modifyvm <vm> --nested-hw-virt on with the machine powered off.
  • Forgetting console=ttyS0 on the kernel command line. virsh console shows a blank screen and it looks as though the machine is not booting. It is paired with --console pty,target_type=serial.
  • Using emulated devices instead of virtio. If the guest's disk is called sda rather than vda, you are losing more than half of the disk performance. Check for bus='virtio' in the XML.
  • Confusing destroy with deleting. virsh destroy is pulling the cable: it deletes nothing, but it can leave the guest's filesystem inconsistent. Use shutdown and wait.
  • Modifying a base image that has disks backed by it. It corrupts every derived disk. The base is read-only in practice; use qemu-img rebase if you need to make one independent.
  • Trusting a snapshot as a backup. It lives on the same disk and on the same machine. It meets none of the three requirements of the 3-2-1 rule.
  • Accumulating chained snapshots. Every active one degrades writing, and a long chain makes the disk unmanageable. Consolidate or delete.
  • Cloning without virt-sysprep. Two machines with the same machine-id and the same SSH host keys cause mixed-up journals and changed-fingerprint warnings.
  • Configuring a bridge over SSH without netplan try. A badly defined bridge locks you out of the machine permanently. The automatic rollback after 120 seconds exists for exactly this.
  • Leaving out discard=unmap. The qcow2 file grows indefinitely because deleting inside the guest frees no space on the host.
  • Enabling ballooning on a database machine. The database reserves shared memory and does not expect it to be taken away. It causes degradation that is hard to diagnose.
  • A tip on method. Version the cloud-init user-data in git alongside the scripts. A test machine that can be recreated with one command in two minutes gets used; one that has to be reinstalled by hand gets abandoned the moment it drifts out of configuration.

Exercises

Exercise 1

Design the cloud-init user-data for an srv-tramontana-test machine that reproduces production's security configuration as faithfully as possible, and explain what cannot be reproduced and why. Justify every decision.

Exercise 2

Release 3.3.0 will not start. Design the complete procedure for diagnosing it in the test environment, using what you learned in 07-01, 07-02 and this lesson, in such a way that you can repeat the attempt as many times as necessary.

Exercise 3

Marta asks whether it would be worth moving Tramontana Bookings to virtual machines managed by KVM on a server of our own, instead of the current VPS. Write the analysis: what is gained, what is lost, and what you recommend.

Solutions

Solution 1

#cloud-config
# user-data for srv-tramontana-test
# Aim: reproduce production well enough for the tests to be valid, WITHOUT
# copying anything that is a real secret.
hostname: srv-tramontana-test
fqdn: srv-tramontana-test.tramontana.example
manage_etc_hosts: true

# --- Identities: the same uid/gid as production ---
# Reason: permissions, ACLs and SGID are only representative if the numeric
# identifiers match. A 2770 on gid 1002 only behaves the same way if the
# tramontana group IS 1002.
groups:
  - tramontana: [operator]

users:
  - name: operator
    uid: 1000
    groups: [sudo, adm, tramontana]
    shell: /bin/bash
    lock_passwd: false
    sudo: 'ALL=(ALL) NOPASSWD:ALL'
    ssh_authorized_keys:
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... operator@laptop-student
  - name: luis
    uid: 1001
    groups: [tramontana]
    shell: /bin/bash
    lock_passwd: true
  - name: svc-tramontana
    uid: 997
    system: true
    shell: /usr/sbin/nologin
    homedir: /opt/tramontana
    create_groups: false
    no_create_home: true

ssh_pwauth: false
disable_root: true

# --- Packages: the same as production, plus the diagnostic ones ---
package_update: true
package_upgrade: true
packages:
  - postgresql-16
  - restic
  - ufw
  - fail2ban
  - auditd
  - aide
  - apparmor-utils
  - libpam-pwquality
  - needrestart
  - sysstat
  # Diagnostic tools: YES in testing, not needed in production
  - linux-tools-generic
  - strace
  - bpfcc-tools
  - bpftrace
  - qemu-guest-agent

write_files:
  # The application's configuration, with log_level=debug (the only difference)
  - path: /etc/tramontana/app.conf
    owner: root:tramontana
    permissions: '0640'
    content: |
      db_host=127.0.0.1
      db_port=5432
      db_name=tramontana_bookings
      max_connections=80
      query_timeout=30
      log_level=debug
      listen=127.0.0.1

  # Performance sysctl: identical to production. Without this, any
  # comparative measurement would be invalid.
  - path: /etc/sysctl.d/70-performance.conf
    owner: root:root
    permissions: '0644'
    content: |
      net.core.default_qdisc = fq
      net.ipv4.tcp_congestion_control = bbr
      vm.swappiness = 10
      vm.dirty_background_ratio = 5
      vm.dirty_ratio = 10
      fs.inotify.max_user_watches = 524288

  # Security sysctl: identical, INCLUDING ptrace_scope.
  # A deliberate decision: if we relaxed ptrace_scope in testing, we would
  # not reproduce the "Operation not permitted" from 07-02, and the
  # diagnostic tests would not be representative.
  - path: /etc/sysctl.d/60-hardening.conf
    owner: root:root
    permissions: '0644'
    content: |
      kernel.dmesg_restrict = 1
      kernel.kptr_restrict = 2
      kernel.randomize_va_space = 2
      fs.protected_hardlinks = 1
      fs.protected_symlinks = 1
      fs.protected_fifos = 2
      fs.protected_regular = 2
      kernel.unprivileged_bpf_disabled = 1
      kernel.yama.ptrace_scope = 1

  - path: /etc/modprobe.d/blacklist-tramontana.conf
    owner: root:root
    permissions: '0644'
    content: |
      install cramfs /bin/true
      install freevxfs /bin/true
      install jffs2 /bin/true
      install udf /bin/true
      install dccp /bin/true
      install sctp /bin/true

  # SYNTHETIC test data, generated, never copied from production
  - path: /home/operator/data/houses.txt
    owner: operator:operator
    permissions: '0640'
    content: |
      mas-figueres
      can-ventos
      la-solana
      cal-ferrer
      el-moli

runcmd:
  # Paths with the same permissions and SGID as production
  - [ install, -d, -m, '0755', -o, root, -g, root, /opt/tramontana ]
  - [ install, -d, -m, '2770', -o, svc-tramontana, -g, tramontana,
      /opt/tramontana/shared/uploads ]
  - [ install, -d, -m, '0750', -o, svc-tramontana, -g, adm,
      /var/log/tramontana ]
  - [ install, -d, -m, '2770', -o, operator, -g, tramontana,
      /srv/tramontana/backups ]
  # Firewall with the same allowlist policy
  - [ ufw, --force, default, deny, incoming ]
  - [ ufw, --force, default, allow, outgoing ]
  - [ ufw, limit, '22/tcp' ]
  - [ ufw, allow, from, 192.168.122.0/24, to, any, port, '5432', proto, tcp ]
  - [ ufw, --force, enable ]
  # Apply and enable
  - [ sysctl, --system ]
  - [ update-initramfs, -u, -k, all ]
  - [ systemctl, enable, --now, sysstat ]
  - [ systemctl, enable, --now, qemu-guest-agent ]
  # Generate synthetic bookings.csv with the same structure
  - [ bash, -c, 'printf "id;date;house;guest;nights;amount\n" > /home/operator/data/bookings.csv' ]
  - [ bash, -c, 'for i in $(seq 1001 1025); do printf "%s;2026-08-%02d;mas-figueres;Guest %s;2;250.00\n" "$i" "$((i-1000))" "$i"; done >> /home/operator/data/bookings.csv' ]
  - [ chown, 'operator:operator', /home/operator/data/bookings.csv ]

final_message: "srv-tramontana-test ready after $UPTIME seconds"

What CANNOT be reproduced, and why. This is the important part of the exercise, because it determines which tests are valid:

Not reproducible Why Consequence for the tests
The real secrets (db_password, the restic password, the pass GPG key) Copying them would double their exposure and violate the rule from 06-05 The tests use credentials of their own. Real rotation cannot be tested
The systemd-creds .cred file It is encrypted with a key derived from the production machine; it is undecryptable here A new one has to be generated. It is the limitation already documented in 06-05
The Let's Encrypt certificate It requires the public domain and the ACME challenge. Besides, every issuance consumes quota A self-signed one is used. Real renewal cannot be tested
The guests' personal data GDPR: a test environment has fewer controls, more access and more copies. Copying it would be an infringement Synthetic data with the same structure and volume
The LUKS volume with its key The key is a secret and the disk is physical A new LUKS can be created with a test key. Valid for measuring performance, not for restoring real backups
The AIDE database It depends on the checksums of this machine's real files It is initialised here. Valid for testing the mechanism, not for comparing against production
The exact hardware It is a nested VM, with two hypervisor layers Absolute performance measurements are not comparable; only relative before/after ones within the machine itself

And the three decisions worth justifying because they are counterintuitive:

  1. Keeping ptrace_scope = 1 in testing. The temptation is to relax it so as to diagnose comfortably. That would be a mistake: we would then not reproduce the Operation not permitted from 07-02 and the diagnostic tests would be worthless. A test environment that differs in security does not test the security.
  2. The same numeric UIDs and GIDs. It is easy to let cloud-init assign whatever it likes, and then the 2770 on the tramontana group does not behave the same way, and the permission tests are invalid. Numeric identifiers are part of the configuration.
  3. log_level=debug as the only intentional difference, plus the diagnostic tools. Both are more than there is in production, not less, so they invalidate nothing — and they have to be noted, because the log volume does differ.

The last warning goes in the runbook: this user-data contains the SSH public key and the organisation's identifiers, so it gets versioned in the internal repository, not a public one. And it must never contain a real secret: that is what pass and later provisioning are for.

Solution 2

The key to the design is that the diagnosis must be repeatable: every attempt starts from the same state, so that the differences observed are due to the change and not to leftovers from the previous attempt.

# --- Phase 0: a clean, repeatable point of return ---
$ virsh shutdown srv-tramontana-test
$ for i in {1..30}; do
      [[ "$(virsh domstate srv-tramontana-test)" == "shut off" ]] && break
      sleep 2
  done
$ virsh snapshot-create-as srv-tramontana-test \
    --name base-3.2.1 \
    --description "3.2.1 active and verified, before attempting 3.3.0" \
    --atomic
$ virsh start srv-tramontana-test

The cold snapshot is deliberate: a --live one would capture the memory and, without --quiesce, could leave PostgreSQL inconsistent. For a point of return, powered off is simpler and more reliable.

# --- Phase 1: verify the starting state ---
$ VM=192.168.122.104
$ ssh operator@$VM '~/scripts/health_check.sh; echo "status: $?"'
status: 0
$ ssh operator@$VM 'readlink /opt/tramontana/app'
releases/3.2.1

Without this verification, a 3.3.0 failure could be confused with a test environment that was already broken.

# --- Phase 2: set up the observation BEFORE triggering the failure ---
# The error can last milliseconds: you have to be watching when it happens.
$ ssh operator@$VM 'sudo journalctl -f -u tramontana' > /tmp/journal-3.3.0.log &
$ ssh operator@$VM 'sudo execsnoop-bpfcc -T' > /tmp/processes-3.3.0.log &
$ ssh operator@$VM 'sudo journalctl -f -k | grep -i apparmor' > /tmp/apparmor.log &

execsnoop-bpfcc is the informed choice here: if the binary starts and dies in 200 ms, ps will never see it, and this tool captures every execve with its exit code. It is exactly the use case from the table in 07-02.

# --- Phase 3: the attempt, with the dry-run mode first ---
$ scp /srv/tramontana/backups/outgoing/tramontana-3.3.0.tar.gz \
      /srv/tramontana/backups/outgoing/tramontana-3.3.0.tar.gz.sha256 \
      operator@$VM:/tmp/
$ ssh operator@$VM 'cd /tmp && sha256sum -c tramontana-3.3.0.tar.gz.sha256'
tramontana-3.3.0.tar.gz: OK

# --dry-run first: the course's convention, and it rules out script problems
$ ssh operator@$VM 'sudo ~/scripts/deploy.sh --dry-run 3.3.0'
$ ssh operator@$VM 'sudo ~/scripts/deploy.sh 3.3.0'
# --- Phase 4: gather the evidence, from the general to the specific ---

# 4a. The journal. The cause is here 70% of the time.
$ ssh operator@$VM 'sudo journalctl -u tramontana -n 60 --no-pager -o short-precise'

# 4b. How did it die? The exit code and the signal say everything.
$ ssh operator@$VM 'systemctl show tramontana -p ExecMainStatus -p ExecMainCode \
    -p Result -p StatusErrno'

And here is the decision tree that makes this procedure useful, because each symptom has a different tool:

Evidence Hypothesis The tool that confirms it
signal=SYS The seccomp filter from SystemCallFilter ausyscall <n> on the syscall= from the journal
apparmor="DENIED" The AppArmor profile does not cover a new path journalctl -k | grep DENIED
status=127 or not found A shared library is missing ldd on the new binary
status=1 with a message of its own Configuration: a key is missing from app.conf strace -e trace=%file | grep ENOENT
Killed / oom-kill It exceeds MemoryMax=512M journalctl -k | grep -i oom
Starts and dies with no message Any of the above, silenced execsnoop + strace -f from startup
# 4c. Libraries: the most common cause of a new binary that will not start
$ ssh operator@$VM 'ldd /opt/tramontana/releases/3.3.0/tramontana | grep -i "not found"'
	libpq.so.6 => not found

# And if that shows up, the confirmation and the root cause
$ ssh operator@$VM 'apt-cache policy libpq5; dpkg -l | grep libpq'
# 4d. If the journal says nothing useful: trace the startup from the beginning
$ ssh operator@$VM 'sudo -u svc-tramontana strace -f -o /tmp/trace.txt \
    /opt/tramontana/releases/3.3.0/tramontana --config /etc/tramontana/app.conf; \
    grep -E "ENOENT|EACCES|EPERM" /tmp/trace.txt | grep -v "lib\|locale" | tail -20'

# 4e. Compare 3.2.1 with 3.3.0: what has really changed
$ ssh operator@$VM 'diff <(ldd /opt/tramontana/releases/3.2.1/tramontana | sort) \
                         <(ldd /opt/tramontana/releases/3.3.0/tramontana | sort)'
$ ssh operator@$VM 'sudo systemd-analyze security tramontana.service | tail -1'

Step 4e is the one with the best return and the one usually forgotten: comparing the version that works with the one that does not. If the only difference is a new library, you have the answer without tracing anything.

# --- Phase 5: return to the starting point and repeat ---
$ kill %1 %2 %3 2>/dev/null    # close the observers
$ virsh shutdown srv-tramontana-test
$ virsh snapshot-revert srv-tramontana-test --snapshotname base-3.2.1
$ virsh start srv-tramontana-test
$ ssh operator@$VM '~/scripts/health_check.sh; echo "status: $?"'
status: 0

Three design decisions that justify the procedure:

  1. The observers are launched before the attempt. A binary that dies in 200 ms cannot be observed after the fact. It is the difference between having the evidence and having to repeat.
  2. The decision tree is written before you start. Under pressure, the temptation is to fire strace at everything and drown in output. With the table in front of you, each symptom leads to a specific tool.
  3. The snapshot allows unlimited attempts. And that is the reason for the lesson: this diagnosis cannot be done in production. Every failed attempt leaves a half-deployed release, a service down and a rollback. In testing, snapshot-revert and go again.

A final note: when the cause is found, the fix is applied and tested in testing until health_check.sh returns 0, and only then is it deployed to production. deploy.sh having had an automatic rollback since 04-07 avoided disaster at the time; having somewhere to diagnose is what allows it to be fixed.

Solution 3

Analysis: moving Tramontana Bookings to our own virtualisation with KVM To: Marta Vidal · From: Systems Operations · 18 August 2026

Short recommendation: not now. Keep the VPS and use KVM solely for the test environment, which is where the benefit is immediate and the risk is nil. Here is the reasoning in detail.


What we would gain

  1. Complete control of the stack. We could tune the machine's CPU (host-passthrough), the disk configuration and the I/O scheduler. Remember that in this week's performance tests we could not determine whether the backups' encryption was hardware-accelerated, precisely because we do not control that layer.
  2. Strong isolation between services. We could separate the database from the application onto different machines, with a real security boundary between them. Today they share an operating system: a compromise of the application reaches the database directly.
  3. Snapshots before every change. A point of return measured in seconds, instead of depending on restoring backups with their 8-hour RTO.
  4. Predictable cost above a certain volume. A server of our own with capacity for several machines can work out cheaper than four or five VPSs.
  5. No dependence on the provider as regards kernel versions, available features or price changes.

What we would lose, and this is what settles it

  1. The hardware becomes our problem. Disks, power supplies, memory, fans. A disk that fails at three in the morning is the provider's to resolve today; with our own server it is ours, and we would need disks in RAID — remembering that RAID is not a backup — and spare parts.
  2. Power, cooling and networking stop being guaranteed. A server in an office depends on a single electrical circuit and a single data line. A two-hour power cut is a two-hour outage. Hosting with a redundant UPS and dual feeds costs money.
  3. A single point of failure, and a new one: the host. Today we have one server that can fail. With our own virtualisation we would have the virtual machines plus the host, and if the host goes down, they all go down together. This matters: virtualisation does not provide high availability, and believing otherwise is the most common design error in the field. With a single host, the risk of a total outage goes up.
  4. The workload increases, it does not decrease. Today we administer one operating system. With our own virtualisation we would administer the host, the hypervisor, the virtual network, the storage and each guest machine. With the current headcount, that is time taken from somewhere else.
  5. A real up-front cost. A server with capacity and disk redundancy, plus suitable hosting, plus the spares. It is not negligible, and it has to be compared against the annual VPS cost over several years, not one.
  6. Compliance implications. We process guests' personal data. A server of our own makes us responsible for the equipment's physical security, which today belongs to the provider and which appears explicitly in our threat model as out of scope. That would have to be reviewed with the data protection officer, and would probably require full encryption of the root disk and access control to the physical space.

What I do propose, and it is already under way

Using KVM for the test environment, on the administration machine itself or on the current VPS. The benefit is immediate and the risk is none:

  • This week it has made it possible, for the first time, to diagnose the 3.3.0 release that failed in July and was left unexplained.
  • It allows configuration changes and kernel updates to be tested before they are applied in production.
  • It allows the runbook's recovery procedure to be rehearsed without touching the real server.
  • It is recreated from scratch in two minutes from a configuration file versioned in git.

When to reconsider. The analysis changes if at least two of these conditions are met:

Condition Why it changes the decision
We need four or more servers The per-machine cost of our own server drops a great deal
There is budget for two hosts Only then does virtualisation add availability rather than subtract it
Professional hosting is available It resolves power, cooling and networking
There is somebody dedicated to infrastructure The workload stops being taken from somewhere else
The VPS cost clearly exceeds the alternative over three years That is when the investment genuinely pays for itself

And an intermediate alternative worth considering first. If the real objective is separating the database from the application for security, or having a permanent pre-production environment, taking out a second VPS achieves exactly that for a fraction of the cost and without taking on responsibility for the hardware. It is a reversible step; buying a server is not.

Summary for the decision. Our own virtualisation is the right answer when the problem is scale and there are resources to do it properly. Our problem today is not scale: it is that we had no test environment — now resolved — and that we have a single point of failure, which is a redundancy problem and not a virtualisation one. I am looking into that second matter and will bring you a proposal with numbers.

Conclusion

You now have the test environment the course had spent four modules missing, and you understand the stack that supports it. You know what each piece virtualises: KVM gives access to the CPU's extensions, QEMU emulates the devices, libvirt manages the whole with a stable API, and virsh is the day-to-day tool. You know that destroy is pulling the cable and not deleting, that virsh console is the way to recover a broken boot with no graphical interface, and that virtio on the disk and on the network is the difference between a slow machine and a fast one. You provision unattended with cloud-init from a file versioned in git, you choose qcow2 for its snapshots and backing files knowing what that 2-5% of performance costs, and you are clear that a snapshot is not a backup.

And along the way you have closed three outstanding matters. The virbr0 that cost 35 seconds of boot time in 07-01 now has a complete explanation: an interface that exists but has no carrier until a machine starts. The 07-03 question about AES-NI is resolved: with host-passthrough, the guest sees the CPU's extensions and encrypts at 4.4 GB/s, which confirms the factor of five to ten that was put forward as a hypothesis. And the 3.3.0 release that failed in Module 4 finally has somewhere to be reproduced as many times as necessary, with a snapshot as a point of return and a decision tree written before you start.

Notice now the price you have paid for the strong isolation of virtual machines. Every VM carries its own kernel, takes half a minute to start, occupies gigabytes on disk and reserves hundreds of megabytes of memory simply by existing. For a test environment that is perfectly reasonable. For deploying an application and its database, and being able to recreate them in seconds, it is extremely expensive. And in the diagram in the third section you already saw the alternative: share the host's kernel and isolate only what is necessary.

In lesson 07-05: Linux Containers and Docker that is what gets built, and it starts with the idea you have to be clear about before writing your first docker run: a container is not a small machine, it is an isolated process. You will see the three kernel primitives that make it possible — namespaces, which isolate what a process sees; cgroups v2, which are literally the same technology as the MemoryMax=512M you set in 05-05; and the capabilities from 05-02 — and you will recognise in the mount namespaces the chroot you used to repair GRUB in 07-01. Then comes Docker: images and layers, a Dockerfile with a multi-stage build so that the compiler is not shipped to production, volumes versus bind mounts, networks with name resolution, and a compose.yaml that brings the application and PostgreSQL up together with health checks. With two warnings the lesson takes seriously: belonging to the docker group is in practice equivalent to having root, and that kernel.unprivileged_userns_clone you left commented out in 06-06 with a note explaining why — the moment has come to understand it fully.

Linux Course: From Beginner to System Administrator

Module 1: Introduction to Linux

Module 2: Basic Linux Commands

Module 3: Advanced Command-Line Skills

Module 4: Shell Scripting

Module 5: System Administration

Module 6: Networking and Security

Module 7: Advanced Topics

Module 8: Practical Projects

© Copyright 2026. All rights reserved