Bootstrapping VMs on a virtualization server using Terraform and cloud-init (Part 2/2)
In the first part, we have set-up a cheap virtualization server on a dedibox. My next objective was to use my cheap server to spawn VMs, instead of buying expensive VPS or cloud instances. For this matter, I use Terraform, and I’ve published my Terraform configuration on github.
Terraform workflow
The typical workflow fits in 4 commands:
- Prepare the working directory:
terraform init
- Show changes to the infrastructure required by the current configuration:
terraform plan
- Create or modify the infrastructure:
terraform apply
- Destroy all the infrastructure:
terraform destroy
Cloud-init
Cloud-init is a tool developed by Canonical to configure instances on boot, with support to many cloud platforms and operating systems. It permits to initialize cloud instance and provide a base configuration applied at boot time. The documentation is available here:
System images with cloud-init pre-installed are usually named “cloud images” and distributed for many Linux distributions alongside the installation ISOs.
Cloud-init supports a “NoCloud” mode, where the configuration is passed to the system in an ISO file attached to the VM. This is the mode that we will use below.
Terraform provider for libvirt
Terraform supports many different infrastructures, especially cloud infrastructure such as Azure or Amazon AWS. But I don’t want to pay for a public cloud, I want to use my own virtualization server as backend.
I’ve found that this unofficial Terraform provider for libvirt is actually reliable: https://github.com/dmacvicar/terraform-provider-libvirt
Some examples are given in the repository.
I will briefly explain this example, which will spawn an Ubuntu VM with a custom disk size.
- Specify the terraform provider source
terraform {
required_version = ">= 0.13"
required_providers {
libvirt = {
source = "dmacvicar/libvirt"
version = "0.6.2"
}
}
}
- Configure the libvirt provider, specify the URI of your libvirt daemon, it can be local like in this example or accessed remotely via SSH (
qemu+ssh://<ssh host>/system
)
provider "libvirt" {
uri = "qemu:///system"
}
- Specify the source of the system image, and the system disk size, using resources of type
libvirt_volume
resource "libvirt_volume" "os_image_ubuntu" {
name = "os_image_ubuntu"
pool = "default"
source = "https://cloud-images.ubuntu.com/releases/xenial/release/ubuntu-16.04-server-cloudimg-amd64-disk1.img"
}
resource "libvirt_volume" "disk_ubuntu_resized" {
name = "disk"
base_volume_id = libvirt_volume.os_image_ubuntu.id
pool = "default"
size = 5361393152
}
- Specify your cloud-init configuration in a resource of type
libvirt_cloudinit_disk
. The provided configuration will be transformed into an iso file and attached to the virtual machine on boot.
# Use CloudInit to add our ssh-key to the instance
resource "libvirt_cloudinit_disk" "cloudinit_ubuntu_resized" {
name = "cloudinit_ubuntu_resized.iso"
pool = "default"
user_data = <<EOF
#cloud-config
disable_root: 0
ssh_pwauth: 1
users:
- name: root
ssh-authorized-keys:
- ${file("id_rsa.pub")}
growpart:
mode: auto
devices: ['/']
EOF
}
- Define a virtual machine (called “domain” in libvirt)
resource "libvirt_domain" "domain_ubuntu_resized" {
name = "doman_ubuntu_resized"
memory = "512"
vcpu = 1
cloudinit = libvirt_cloudinit_disk.cloudinit_ubuntu_resized.id
[...]
disk {
volume_id = libvirt_volume.disk_ubuntu_resized.id
}
[...]
}
My Terraform configuration
My terraform configuration is public on github.
This terraform configuration permits to manage a single libvirt server at a time.
The cool feature is that all the configuration is provided in one tfvars
file using the standard terraform syntax (HCL), and converted seamlessly to cloud-init.
The configuration layout is the following:
versions.tf
- terraform and libvirt provider version requirementprovider.tf
- libvirt provider configurationvms/
- specific module to define one virtual machine based on the description provided in inputmain.tf
- entry point, iterate other the variable vms_list to define virtual machines using the modulevms
variables.tf
- input variables definitionsterraform.tfvars
- input variables, processed inmain.tf
- this is the main configuration file
One server configuration is stored per tfvars
file. In my case, I have only one server, and its configuration is stored in the form of variable definitions, the file terraform.tfvars
which is loaded by default.
I could manage several servers by creating more tfvars
and selecting them on the command line (terraform apply -var-file="new_libvirt_server.tfvars"
).
I will only explain in detail my terraform.tfvars
, since it’s the only file that has to be regularly updated.
- This is the libvirt server configuration
server_uri = "qemu+ssh://sysadmin@srv.nbsdn.fr.eu.org:443/system"
pool_name = "terraform"
pool_path = "/var/lib/libvirt/terraform"
- Since all VMs share the same network, I define the gateways and nameservers in common
network_defaults = {
gateway4 = "192.168.0.1"
gateway6 = "2001:bc8:3feb:100::2"
nameservers = ["2001:bc8:3feb:100::2"]
}
- I specify default user settings for all VMs. The root account is locked for all VMs.
users_default
is a map of user objects, so it could be used to define several users.
users_defaults = {
"root" = {
hashed_passwd = "!"
lock_passwd = true
}
}
- I define the map
vms_list
, which is actually the list of VMs.
vms_list = {
[...]
"tf-debian" = {
bridge_name = "vmbr0"
vm_memory = 384
vm_vcpu = 1
vm_disk_size = 100
cloud_image_url = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2"
network_interfaces = {
ens3 = {
addresses = [
"192.168.0.9/16",
"2001:bc8:3feb:100::9/64",
]
}
}
system = {
hostname = "tf-debian"
packages = ["wget"]
}
users = {
"sysadmin" = {
shell = "/bin/bash"
sudo = "ALL=(ALL) NOPASSWD:ALL"
hashed_passwd = "!"
lock_passwd = true
ssh_authorized_keys = ["ssh-ed25519 ......................"]
}
}
}
[...]
}
After running terraform apply
, the VM will be quickly reachable via SSH on the network.
Spawning an OpenBSD instance
Lucky you, OpenBSD does not provide cloud images, but you can use mine !
I’ve created the github project openbsd-cloud-image, in order to generate cloud-init enabled images of OpenBSD.
The project provides:
- a bash script
build_openbsd_qcow2.sh
, which starts an unattended PXE installation of OpenBSD and produce a qcow2 system image. - a CI/CD pipeline to generate new system images, test them briefly and release them
- the qcow2 images, which I use directly in my Terraform configuration.
In my terraform set-up, I can spawn an OpenBSD instance using this configuration:
vms_list = {
[...]
"tf-openbsd" = {
bridge_name = "vmbr0"
vm_memory = 384
vm_vcpu = 1
vm_disk_size = 100
cloud_image_url = "https://github.com/hcartiaux/openbsd-cloud-image/releases/download/v7.5_2024-05-13-15-25/openbsd-min.qcow2"
network_interfaces = {
vio0 = {
addresses = [
"192.168.0.10/16",
"2001:bc8:3feb:100::10/64",
]
}
}
system = {
hostname = "tf-openbsd"
packages = ["wget", "bash", "vim--no_x11"]
}
users = {
"sysadmin" = {
shell = "/usr/local/bin/bash"
doas = "permit nopass sysadmin as root"
hashed_passwd = "!"
lock_passwd = true
ssh_authorized_keys = ["ssh-ed25519 ......................"]
}
}
}
[...]
}
That’s all folks
This is how I set-up my “homelab” experiments on a cheap dedicated server, or should I say my “remotelab”. Once my VMs are booted and reachable, I customize them using my ansible configuration.