Building Debian VMs with debootstrap
Following the awful time I had setting up a working preseed configuration for Debian installer I am exploring the alternative method of pre-building the disk image using debootstrap.
I am starting with the process described by Diogo Gomes on his blog.
Like the preseed method, this took a lot more effort than anticipated to get it working. This time it was getting grub to install a UEFI loader into the disk image when the host has a BIOS and persuading systemd to play nicely while building in the chroot.
Pre-requisites
In order to build a working disk image suitable for use with QEMU/KVM, I need debootstrap (assuming kvm is already installed) and dosfstools for the UEFI system partition:
apt-get install debootstrap dosfstools
We will also need qemu-img
from qemu-utils but that packages should already be installed.
Preparing to install
Create the image
This is straight-forward, for example to create a 10G qcow2 format image:
qemu-img create -f qcow2 debian-buster.qcow2 10G
This can be done as any user - we will move it to the right place and set permissions latter - however from now on almost everything needs to be run as root.
Mount the image
First I need to load the nbd
(Network Block Device) kernel module, which will create the /dev/nbd[0-9]
devices:
modprobe nbd
Then the disk can be exposed as a network block device using qemu-tool’s server:
qemu-nbd -c /dev/nbd0 debian-buster.qcow2
Partition
The virtual disk can then be partitioned using your favourite tool:
parted -s -a optimal -- /dev/nbd0 \
mklabel gpt \
mkpart primary fat32 1MiB 270MiB \
mkpart primary linux-swap 300MiB 1GiB \
mkpart primary ext4 1GiB -0 \
name 1 uefi \
name 2 swap \
name 3 root \
set 1 esp on
This:
- Non-interactively (
-s
) - Aligning for best performance (
-a optimal
) - Makes a new GPT partition table (
mklabel gpt
) - Makes a 269 EFI system partition (
mkpart primary fat32 1MiB 270MiB
) - Makes a 754MiB swap partition (
mkpart primary linux-swap 270MiB 1GiB
) - Makes a partition, as large as possible in the remaining space (
mkpart primary ext4 1GiB -0
) - Sets the name of the EFI system partition (1) to “efi” (
name 1 efi
) - Sets the name of the new swap partition (2) to “swap” (
name 1 swap
) - Sets the name of the new partition (3) to “root” (
name 2 root
) - Sets the EFI system partition flag on the UEFI partition (1) (
set 1 esp on
)
Format
Using the usual tools:
mkfs -t fat -F 32 -n EFI /dev/nbdp1
mkswap -L swap /dev/nbd0p2
mkfs -t ext4 -L root /dev/nbd0p3
Mount
Now we have a filesystem, we can mount it and continue with the Debian installation instructions for installing a new Debian system with debootstrap.
Using the UUIDs to mount will help grub configure itself correctly (or it will add root=/dev/..
to the kernel options, and Linux will not be able to find its root filesystem as the device names will be different inside the VM. Finding the UUIDs can be easily scripted (remember I used generic but specific labels for the filesystems?), since I know which NBD mount I used:
swap_uuid="$(blkid | grep '^/dev/nbd0' | grep ' LABEL="swap" ' | grep -o ' UUID="[^"]\+"' | sed -e 's/^ //' )"
root_uuid="$(blkid | grep '^/dev/nbd0' | grep ' LABEL="root" ' | grep -o ' UUID="[^"]\+"' | sed -e 's/^ //' )"
efi_uuid="$(blkid | grep '^/dev/nbd0' | grep ' LABEL="EFI" ' | grep -o ' UUID="[^"]\+"' | sed -e 's/^ //' )"
mount $root_uuid /mnt
Installing the OS
I am now in familiar territory for building Debian chroots.
Bootstrapping the basic system
Begin with bootstrapping the install. Note I am installing my configuration management tool of choice (SaltStack) immediately:
debootstrap --arch amd64 --include=salt-minion buster /mnt http://ftp.uk.debian.org/debian
Mount proc and dev
Deviating slightly from the Debian manual, to do it the way I used to in my Gentoo days:
mount -o bind,ro /dev /mnt/dev
mount -t proc none /mnt/proc
mount -t sysfs none /mnt/sys # Grub will need this to install
The bind mount is read-only to address this note in the Debian manual:
There are different ways to go about this and which method you should use depends on the host system you are using for the installation, on whether you intend to use a modular kernel or not, and on whether you intend to use dynamic (e.g. using udev) or static device files for the new system.
A few of the available options are: … bind mount /dev from your host system on top of /dev in the target system; note that the postinst scripts of some packages may try to create device files, so this option should only be used with care
chroot
into the new system
LANG=C.UTF-8 chroot /mnt /bin/bash
Once in the chroot, set the terminal to something sensible:
export TERM=xterm-color
Configuring fstab
Despite what the manual says, /proc
does not need to be specified in fstab (and is not on the host system) these days.
See this answer from StackExchange:
The claimed statement is generally true:
It is not necessary to list /proc and /sys in the fstab unless some special options are needed. The boot system will always mount them. (Debian Wiki)
- The only reason Debian (and by extension, Ubuntu) includes it is as a forgotten remnant of the bad old days when it used to be necessary.
- This behavior is a bug in the installer package partman-target and was recently fixed in Debian Testing (wheezy) on June 15, 2012.
So we only need to list the disks we have created. I have used the UUID method, as is current good practice (header taken from host system’s fstab, substituting in the UUIDs looked up from earlier):
# /etc/fstab: static file system information.
#
# Use 'blkid' to print the universally unique identifier for a
# device; this may be used with UUID= as a more robust way to name devices
# that works even if disks are added and removed. See fstab(5).
#
# <file system> <mount point> <type> <options> <dump> <pass>
$swap_uuid none swap sw 0 0
$root_uuid / ext4 errors=remount-ro 0 1
$efi_uuid /boot/efi vfat defaults 0 1
We now need to mount the extra filesystems:
# Make sure the efi mount point exists
[[ -d /boot/efi ]] || mkdir /boot/efi
mount -a
Configuring the timezone
This can be done interactively (dpkg-reconfigure tzdata
) or by preseeding the answers to debconf then non-interactively letting dpkg reconfigure it. The latter is more scriptable (which it should be obvious is where this blog post is heading by now):
debconf-set-selections <<EOF
tzdata tzdata/Areas select Europe
tzdata tzdata/Zones/Europe select London
EOF
# This is necessary as tzdata will assume these are manually set and override the debconf values with their settings
rm -f /etc/localtime /etc/timezone
DEBCONF_NONINTERACTIVE_SEEN=true dpkg-reconfigure -f noninteractive tzdata
Networking
When the VM is configured and boots we will want networking to come up automatically. With Debian’s splitting of /etc/interfaces
preparing the system for that is easy by creating /etc/network/interfaces.d/enp1s0
allow-hotplug enp1s0
iface enp1s0 inet dhcp
The debootstrapped system also has no loopback (lo
) interface configured, so I added it to /etc/network/interfaces
directly:
auto lo
iface lo inet loopback
The debootstrap install manual said this about loopback, but I am yet to come across a real system without it configured so I have put it in anyway:
The loopback interface isn’t really required any longer, but can be used if needed
Since the system will be configured by DHCP, there is not point editing /etc/resolv.conf
as it will be rebuilt when the VM reconfigures its network.
Put the correct hostname into /etc/hostname
:
echo "debian-buster" > /etc/hostname
And configure /etc/hosts
:
127.0.0.1 localhost
127.0.1.1 debian-buster.my.domain.tld debian-buster
# The following lines are desirable for IPv6 capable hosts
::1 localhost ip6-localhost ip6-loopback
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
Apt sources
debootstrap
will have created a very minimal /etc/apt/sources.list
but we also want to get source and security packages:
deb http://ftp.uk.debian.org/debian/ buster main
deb-src http://ftp.uk.debian.org/debian/ buster main
deb http://security.debian.org/debian-security buster/updates main
deb-src http://security.debian.org/debian-security buster/updates main
# buster-updates, previously known as 'volatile'
deb http://ftp.uk.debian.org/debian/ buster-updates main
deb-src http://ftp.uk.debian.org/debian/ buster-updates main
Don’t forget to refresh the cache:
apt-get update
Locales and keyboard layout
Preseed the answers before installing the packages:
debconf-set-selections <<EOF
locales locales/locales_to_be_generated multiselect en_GB.UTF-8 UTF-8
locales locales/default_environment_locale select en_GB.UTF-8
keyboard-configuration keyboard-configuration/layoutcode string gb
keyboard-configuration keyboard-configuration/variant select English (UK)
keyboard-configuration keyboard-configuration/model select Generic 105-key PC (intl.)
EOF
# Stop anything overriding debconf's settings
rm -f /etc/default/locale /etc/locale.gen /etc/default/keyboard
apt-get install locales console-setup
Kernel
As this will be a bootable VM image, we need a kernel installed:
apt-get install linux-image-amd64
Bootloader
I just used the usual grub install commands:
apt-get install grub-efi-amd64
# Add console=ttyS0 so we get early boot messages on the serial console.
sed -i -e 's/^\\(GRUB_CMDLINE_LINUX="[^"]*\\)"$/\\1 console=ttyS0"/' /etc/default/grub
# Tell GRUB to use the serial console
cat - >>/etc/default/grub <<EOF
GRUB_TERMINAL="serial"
GRUB_SERIAL_COMMAND="serial --unit=0 --speed=9600 --stop=1"
EOF
grub-install --target=x86_64-efi
update-grub
In the chroot, grub is using the disk path (e.g. /dev/nbd0s3
) rather than the UUID - presumably because this is how it was mounted.
I also copied the fallback bootloader to the default bootloader location, as this is the only OS and when we first boot our VM we will need this to initialise the boot options in the nvram:
mkdir /boot/efi/EFI/BOOT
cp /boot/efi/EFI/debian/fbx64.efi /boot/efi/EFI/BOOT/bootx64.efi
Tidying up
Enable the serial console:
systemctl enable serial-getty@ttyS0.service
Set a password for root (this can be automated by passing a ‘username:encrypted_password’ to chpassed -e
on stdin):
passwd
Delete the cached base packages:
apt-get clean
Exit the chroot:
exit
Unmount the partitions:
umount /mnt/dev /mnt/proc /mnt/sys /mnt/boot/efi /mnt
Disconnect the image from the NBD:
qemu-nbd -d /dev/nbd0
Moving the image
When building a VM around an existing image virt-install
does not move it, so ideally we need to put it in the same place a new images would be created and with the same permissions:
mv debian-buster.qcow2 /var/lib/libvirt/
chown libvirt-qemu:libvirt-qemu /var/lib/libvirt/debian-buster.qcow2
chmod 600 /var/lib/libvirt/debian-buster.qcow2
Scripting the whole thing
The build-debian-image script, which automates all of these steps, is available in my GitHub repository. At the time of writing, it supported these options:
Usage: build-debian-image [-hSDM] [-s suite] [-f file] [-z size] [-r passwd] [name]
-h: Display this message and exit
-S: Skip debootstrap initialising a blank image and go stright to
mounting. Requires an already setup image file as target.
-D: Do debootstrap only and exit without chrooting and doing the
stage 2 process. As the debootstrap is the most time-consuming
element, this can be helpful combined with -S during debugging and
development. The stage 2 script will still be written out.
-M: Leave the NBD attached and chroot filesystems mounted at the end.
-s suite: the Debian suite to build, defaults to buster (taken from
first line matching /^deb / in /etc/apt/sources.list).
-f file: filename for the image, defaults to <name>.qcow2
-z size: Size of the image, in a format understood by the qemu-img command.
Defaults to 5G, first 1GiB is entirely consumed by boot and swap
partitions.
-w size: Size of the image's swap partition in MiB (set to 0 to disable),
defaults to 753
-r var: Use passwd in var environment variable as the encrypted root
password in the built image (avoids exposing the password via the
command-line, which any user on the system can see).
-d domain: Use domain as the new hosts domain, defaults to my.domain.tld (
taken from this host's 'hostname -d')
name: hostname to configure the image to, defaults to debian-<suite>
Building a virtual machine around the image
The key to this is to specify --import
to virt-install
. Note that I am now using EFI for the VM’s boot, not the default BIOS emulation.
virt-install --virt-type kvm --name debian-buster \
--vcpus 2 \
--memory 2000 \
--os-variant debian10 \
--disk /var/lib/libvirt/debian-buster.qcow2 \
--import \
--network bridge=br0 \
--boot uefi \
--graphics none \
--noautoconsole \
--console pty,target_type=serial
Performance comparison (Mythbusters’ style)
As I was going through this, and discovering that both methods (vanilla install with preseed vs build disk image directly via debootstrap) have their pain points I did wonder which method was quickest to deploy a new VM. Clearly neither will be as fast as taking a “golden image”, changing the host-specific features (pretty much just hostname in a disk-image-only, although it might be advantageous to regenerate the virtual disk, partition and filesystem UUID to be unique) and booting a new VM around it.
In true Mythbusters’ style I timed each method once, so one sample datapoint.
debootstrap / direct disk image creation
$ time ( sudo --preserve-env=my_root_pass build-debian-image -z 10G -r my_root_pass && virt-install --connect qemu:///system --virt-type kvm --name debian-buster \
--vcpus 2 \
--memory 2000 \
--os-variant debian10 \
--disk ./debian-buster.qcow2 \
--import \
--network bridge=br0 \
--boot uefi \
--graphics none \
--noautoconsole \
--console pty,target_type=serial )
...
Domain creation completed.
real 13m35.923s
user 1m59.798s
sys 0m26.988s
Full install / preseed
Note that the preseed file has been updated for an efi install - mainly the partitioning and I also added console=ttyS0
to the kernel options (d-i debian-installer/add-kernel-opts string console=ttyS0
).
$ time virt-install --connect qemu:///system --virt-type kvm --name debian-buster \
--vcpus 2 \
--memory 2000 \
--location http://deb.debian.org/debian/dists/buster/main/installer-amd64/ \
--os-variant debian10 \
--extra-args "auto-install/enable=true netcfg/get_hostname=debian-buster netcfg/get_domain=$( hostname -d ) preseed/url=http://debian-preseed.$( hostname -d )/d-i/buster/./preseed-vm.cfg console=ttyS0" \
--disk size=10 \
--network bridge=br0 \
--boot uefi \
--graphics none \
--console pty,target_type=serial
...
real 10m22.966s
user 0m3.136s
sys 0m1.782s
Conclusion
While this was a fun excursion that reminded me of installing Gentoo, in the end it was as painful as figuring out the preseed configuration file (albeit in different ways) and scripting it is reinventing a wheel that is already provided by virt-install and preseed. The preseeded install is also almost 3 minutes faster (approx. 22% faster).
The only remaining deficiency in that method is I have still not managed to get the scripting hooks work in a way that allows me to dynamically configure the partitioning based on kernel options (e.g. auto-install/classes
). With Red Hat’s kickstart this is easy with a pre script to generate a partition scheme into a temporary file, and then including that file back in the kickstart. This StackExchange answer looks like it might help with the Debian version of this.
It is important to note that in order to remove a UEFI VM, I have to give the --nvram
option (presumably to confirm I know I am deleting the VM’s persistent nvram storage):
virsh --connect qemu:///system undefine --nvram debian-buster