Virtualization with Arch Linux and Qemu/KVM - part 3

For the Virtual Machines I need a Operating System. I’ll use Ubuntu Cloud Images. In my case Ubuntu 22.04. These kind of images are already well prepared to run as Virtual Machines. That means they only have important packages installed e.g. They also have cloud-init installed and enabled. This tool helps to setup a VM during startup e.g. by configuring the hostname, networking, and other stuff.

I’ll use Ubuntu 22.04 LTS as operation system (OS) for my VMs. There is a Ubuntu 22.04 server cloud image available which I’ll use. You need around 650 MByte disk space for the image. So lets download it to a directory on the laptop:

wget https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img

The downloaded image is of format qcow2. This one can’t be used to transfer it to the Logical Volumes. E.g.:

qemu-img info jammy-server-cloudimg-amd64.img

image: jammy-server-cloudimg-amd64.img
file format: qcow2
virtual size: 2.2 GiB (2361393152 bytes)
disk size: 643 MiB
cluster_size: 65536
Format specific information:
    compat: 0.10
    compression type: zlib
    refcount bits: 16
Child node '/file':
    filename: jammy-server-cloudimg-amd64.img
    protocol type: file
    file length: 643 MiB (673972224 bytes)
    disk size: 643 MiB

So lets convert it to raw format. This needs around 2.3 GByte additional disk space temporary:

qemu-img convert -f qcow2 -O raw jammy-server-cloudimg-amd64.img jammy-server-cloudimg-amd64.raw

This file needs to be copied to all hosts in k8s_01 group (so all physical hosts). Ansible’s copy module can be used for this task. If the file is located at /tmp directory e.g. and should be stored in /tmp directory on the hosts the command looks like this:

ansible -m copy -a "src=/tmp/jammy-server-cloudimg-amd64.raw dest=/tmp/jammy-server-cloudimg-amd64.raw" k8s_01

The raw image now needs to be transferred to every Logical Volume (used for the Virtual Machines) on every Physical Host. This can be done with a little Bash script and Ansible (be VERY careful as this will override ANY data in the specified devices! But it also allows you to quickly “reset” a VM in case you want to start from the very beginning 😉):

for host in 01 02 03; do
  for vm in 01 02 03; do
    ansible -m command -a "dd if=/tmp/jammy-server-cloudimg-amd64.raw of=/dev/sdd01/k8s-01${host}${vm} bs=4M status=progress" k8s_01
  done
done

While it now would be already possible to start the Virtual Machines they wouldn’t be of much use as a few details are missing like a user to login, the hostname, a network configuration, and so on. This is where cloud-init becomes handy. It’s basically the standard for customizing cloud instances during startup. When a VM with a Ubuntu cloud image starts, cloud-init is enabled by default. So lets prepare a cloud-init.cfg file for all VMs.

First I need a little directory structure in my playbooks directory which will look like this when very thing is done (don’t care about the files yet it’s explained below):

playbooks/
├── bootstrap_python.yml
├── files
│   └── cloud-init
│       ├── k8s-010101
│       │   ├── cloud-init.iso
│       │   ├── network-config.yml
│       │   └── user-data.yml
│       ├── k8s-010102
│       │   ├── cloud-init.iso
│       │   ├── network-config.yml
│       │   └── user-data.yml
│       ├── k8s-010103
│       │   ├── cloud-init.iso
│       │   ├── network-config.yml
│       │   └── user-data.yml
│       ├── k8s-010201
│       │   ├── cloud-init.iso
│       │   ├── network-config.yml
│       │   └── user-data.yml
│       ├── k8s-010202
│       │   ├── cloud-init.iso
│       │   ├── network-config.yml
│       │   └── user-data.yml
│       ├── k8s-010203
│       │   ├── cloud-init.iso
│       │   ├── network-config.yml
│       │   └── user-data.yml
│       ├── k8s-010301
│       │   ├── cloud-init.iso
│       │   ├── network-config.yml
│       │   └── user-data.yml
│       ├── k8s-010302
│       │   ├── cloud-init.iso
│       │   ├── network-config.yml
│       │   └── user-data.yml
│       └── k8s-010303
│       │   ├── cloud-init.iso
│       │   ├── network-config.yml
│       │   └── user-data.yml
└── libvirt.yml

The files bootstrap_python.yml and libvirt.yml are already in place and were used earlier already. With in playbooks directory I created two new directories files/cloud-init. And within that directory there is now a directory for every Virtual Machine host. The directory structure can be easily created with this command:

mkdir -p files/cloud-init/k8s-010{1,2,3}0{1,2,3}

So lets create a user-data.yml file for the the first Virtual Machine k8s-010101.example.com. I’ll put that file into files/cloud-init/k8s-010101 directory. Here is an example user-data file:

#cloud-config
users:
  - name: my_user 
    groups: adm, cdrom, sudo, dip, plugdev, lxd
    shell: /bin/bash
    sudo: ALL=(ALL) NOPASSWD:ALL
    ssh_authorized_keys:
      - ssh-rsa AAAAB...

hostname: k8s-010101

manage_etc_hosts: true

locale: en_US.UTF-8
timezone: Europe/Berlin

For a full example see Cloud config examples. But lets get quickly through the example above. user-data always has to start with #cloud-config. Then I specified that cloud-init should create a user my_user and add it to the groups specified in groups. We don’t need to specify a group or home directory. By default cloud-init will create a primary group that has the same name as the user and it will create a directory in /home that is also called like the user. Login shell will be /bin/bash. Insudo I specified that this user can run sudo command without password without any restrictions. This will create a file called /etc/sudoers.d/90-cloud-init-users later during VM startup. To be able to login via SSH I also added my public SSH key in ssh_authorized_keys. I haven’t specified a password for security reasons. So I won’t be able to login via QEMU console e.g. This is of course a tradeoff. You might also want to specify a passwd key here. The password needs to be hashed. Use mkpasswd --method=SHA-512 --rounds=4096 to create such a password. Then there is also the hostname. manage_etc_hosts: true will basically add a line to /etc/hosts like 127.0.1.1 k8s-010101 k8s-010101. For further information see Update Etc Hosts module. And finally locale generates the locale specified and makes it default and timezone sets the timezone for this host.

That would be already good enough. But I want to have a static IPv4 network configuration (this could also be achieved by using DHCP and the MAC address of the VM). By default cloud-init will configure networking to fetch an IP, Gateway and DNS servers from DHCP. So I also need a file called network-config.yml (same directory as user-data.yml) for the network configuration. E.g.:

network:
  version: 2
  ethernets:
    enp1s0:
      addresses: [192.168.11.2/23]
      gateway4: 192.168.10.1
      dhcp4: false
      nameservers:
        search: [example.com]
        addresses: [9.9.9.9, 1.1.1.1]

For all possible options see Networking config version 2. I guess the file is not that hard to understand. Ubuntu Virtual Machines (at least for Ubuntu 20.04 and 22.04) normally have a network interface called enp1s0 if a virtio “network card” is used (more on this later). That’s why enp1s0 is used as network interface here. For addresses I only specified an IPv4 address but you can add more addresses and also add IPv6 addresses. gateway4 is the IPv4 gateway IP e.g. of the Internet router. DHCP is disabled via dhcp4: true. I used example.com as the DNS zone and therefore the search list for domains is example.com but you can add more of course. And finally I specified the Quad9 and Cloudflare DNS services for DNS resolution.

With that two files in place I’ll create an ISO file that will be later mounted as CDROM by the VMs. cloud-init can fetch the files I created above from various datasources. One of these data sources is NoCloud. cloud-init will search for a disk (or CDROM in my case) with the volume label cidata and retrieve the files from it.

To create such a disk I installed a litte tool called cloud-localds (for further possibilities like retrieving files form a HTTP server see NoCloud). So while still in files/cloud-init/k8s-010101 directory I execute the following command:

cloud-localds cloud-init.iso user-data.yml -N network-config.yml

Creating user-data.yml, network-config.yml and running cloud-localds command has to be done for every Virtual Machine in their directories. Esp. make sure to adjust hostname and the network address(es) accordingly. Of course that can also be automated with Ansible 😉

The playbook (which comes next) which transfers these .iso files to the hosts, needs to know which files it needs to transfer for every host. So I extend the host_vars accordingly. E.g. for host_vars/k8s-010100.example.com I add:

vms:
  - k8s-010101
  - k8s-010102
  - k8s-010103

Do the same for host_vars/k8s-010200.example.com and host_vars/k8s-010200.example.com and adjust the list accordingly.

When done then these .iso files have to be transferred to the Physical Hosts. So I create an Ansible playbook file called playbooks/cloud-init.yml and it looks like this:

---
- name: Setup cloud-init
  hosts: k8s_01
  gather_facts: true
  vars:
    cloud_init_directory: "/var/lib/libvirt/cloud-init"

  tasks:
    - name: Create directory for cloud-init ISO files
      ansible.builtin.file:
        state: directory
        path: "{{ cloud_init_directory }}"
        mode: "0750"
        owner: "libvirt-qemu"
        group: "libvirt-qemu"

    - name: Copy cloud-init ISO files
      ansible.builtin.copy:
        src: "cloud-init/{{ vm_name }}/cloud-init.iso"
        dest: "{{ cloud_init_directory }}/{{ vm_name }}.iso"
        owner: "libvirt-qemu"
        group: "libvirt-qemu"
        mode: "0640"
      loop: "{{ vms }}"
      loop_control:
        loop_var: "vm_name"

On Physical Host it will create a directory /var/lib/libvirt/cloud-init and copies the .iso files relevant for only this host to this directory. Later I’ll create a libvirt Directory Storage Pool which uses this directory with all the .iso files in it.

If everything is in place execute the playbook:

ansible-playbook playbooks/cloud-init.yml

Before finishing this part one final note: As you may remember I’ve created a Logical Volume for every Virtual Machine. The sizes are 25G, 25G and 100G for the three VMs on every host. At the beginning of this part of the blog series I transferred the Ubuntu cloud image to these Logical Volumes with dd utility. This raw image was only 2.2 GByte. So one would expect that after launching the VMs the root / disk is only about that size. But actually they still will have 25G, 25G and 100G. Why is this? That’s because of a cloud-init module called resizefs that’s enabled by default. It will automatically resize the root / partition to the maximum available space of the underlying disk which is 25G, 25G and 100G.

In the next part I’ll finally create the Virtual Machines with virt-manager.