FreeBSD on Hetzner Cloud 2: Cloud Init Boogaloo
So in my previous post I detailed the packer build to pull down the FreeBSD Cloud image and write it to /dev/sda from the Hetzner rescue OS. I tested nuageinit but, understandably it doesn’t support pulling metadata from IMDS.
However, cloud-init does, so I decided to tackle trying to shoe horn cloud-init into the FreeBSD image. Now there is probably a clever way to build a custom FreeBSD image with cloud-init pre-baked in but this was the way I thought it would be ’easy'.
The main gist here using packer is as follows:
- Boot a instance into the Hetzner Rescue environment
wgetthenddthe FreeBSD cloud image to the instance’s disk- On the same rescue environment install ZFS support
- Import the
zrootpool we’ve just written to disk and plant some seeds- It took me an annoyingly long time to realise that packer creates a temporary SSH key at the start of the build process that I should be copying into the file system and not my key :sad-panda:
- Reboot the instance from the rescue env > FreeBSD
- Install
py311-cloud-initand set it to start on boot - Copy in my SSH key to
/root/.ssh/authorized_keysfor debug incasecloud-initdoesn’t work properly (spoiler, it didn’t) - Create a server snapshot - why isn’t it a HMI? ;)
- Deploy a new instance using the snapshot
So all of the above works absolutely fine after many, many iterations to get the commands just right to import the zpool et al. The problem I have now is there’s a race condition on boot of cloud-init seems to start before networking is ready:
url_helper.py[DEBUG]: Exception(s) [UrlError('HTTPConnectionPool(host=\'169.254.169.254\', port=80): Max retries exceeded with url: /hetzner/v1/metadata/instance-id (Caused by NewConnectionError("HTTPConnection(host=\'169.254.169.254\', port=80): Failed to establish a new connection: [Errno 51] Network is unreachable"))')] during request to http://169.254.169.254/hetzner/v1/metadata/instance-id, raising last exception
I’ve tried enabling the netwait service to no avail.
After the new instance has booted running these commands kicks cloud-init to do its thing;
cloud-init clean --logs
cloud-init init --local
cloud-init init
cloud-init modules --mode=config
cloud-init modules --mode=final
My FreeBSD instance then gets it’s userdata applied including the hostname getting set, packages installed the lot.
Annoying but, I’m not giving up just yet.
Updated Packer Build
packer {
required_plugins {
hcloud = {
version = "~> 1"
source = "github.com/hetznercloud/hcloud"
}
}
}
variable "freebsd_version" {
type = string
default = "15.0-RELEASE"
}
variable "image_url_arm" {
type = string
default = null
}
variable "image_url_x86" {
type = string
default = null
}
variable "server_location" {
type = string
default = "hel1"
}
locals {
timestamp = formatdate("YYYY-MM-DD_hhmm", timestamp())
image_url_arm = var.image_url_arm != null ? var.image_url_arm : "https://download.freebsd.org/releases/VM-IMAGES/${var.freebsd_version}/aarch64/Latest/FreeBSD-${var.freebsd_version}-arm64-aarch64-BASIC-CLOUDINIT-zfs.raw.xz"
image_url_x86 = var.image_url_x86 != null ? var.image_url_x86 : "https://download.freebsd.org/releases/VM-IMAGES/${var.freebsd_version}/amd64/Latest/FreeBSD-${var.freebsd_version}-amd64-BASIC-CLOUDINIT-zfs.raw.xz"
download_image = <<-EOT
set -ex
echo 'Downloading FreeBSD Image...'
wget --timeout=5 --waitretry=5 --tries=5 --retry-connrefused --inet4-only -O /tmp/freebsd.raw.xz "$FREEBSD_IMAGE_URL"
EOT
write_image = <<-EOT
set -ex
# Verify file exists before attempting to decompress
if [ ! -f /tmp/freebsd.raw.xz ]; then
echo "ERROR: /tmp/freebsd.raw.xz not found!"
exit 1
fi
echo 'FreeBSD image loaded, writing to disk... '
xz -d -c /tmp/freebsd.raw.xz | dd of=/dev/sda && sync
echo 'done.'
EOT
install_zfs_on_rescue_system = <<-EOT
yes | bash /root/.oldroot/nfs/tools/install_openzfs.sh
modprobe zfs
EOT
prepare_freebsd_for_configuration = <<-EOT
# Mount zroot ZFS pool to drop ssh keys and sshd config
zpool import -N -R /mnt zroot
zfs set mountpoint=/zroot zroot/ROOT/default
zfs mount zroot/ROOT/default
mkdir /mnt/zroot/root/.ssh
# Copy Packer's key for reconnection after reboots
if [ -f /root/.ssh/authorized_keys ]; then
cat /root/.ssh/authorized_keys >> /mnt/zroot/root/.ssh/authorized_keys
fi
chmod 700 /mnt/zroot/root/.ssh
chmod 600 /mnt/zroot/root/.ssh/authorized_keys
# Configure sshd
sed -i 's/^#PermitRootLogin no/PermitRootLogin prohibit-password/' /mnt/zroot/etc/ssh/sshd_config
echo 'Exporting zroot and rebooting...'
zpool export zroot
reboot
EOT
install_packages = <<-EOT
pkg bootstrap -f
# cloud-init
pkg install -y py311-cloud-init curl
sysrc cloudinit_enable=YES
# netwait
sysrc netwait_enable=YES
sysrc netwait_ip="1.1.1.1"
EOT
ssh_key_cleanup = <<-EOT
# Overwrite the packer temporary ssh key with mine from GitHub
curl -4 https://github.com/grantbevis.keys > /root/.ssh/authorized_keys
EOT
}
source "hcloud" "freebsd-arm" {
rescue = "linux64"
image = "debian-13"
location = var.server_location
server_type = "cax31"
ssh_username = "root"
snapshot_name = "FreeBSD ${var.freebsd_version} ARM - ${local.timestamp}"
snapshot_labels = {
os = "freebsd",
version = var.freebsd_version,
arch = "arm",
}
}
source "hcloud" "freebsd-x86" {
rescue = "linux64"
image = "debian-13"
location = var.server_location
server_type = "cx22"
ssh_username = "root"
snapshot_name = "FreeBSD ${var.freebsd_version} x86 - ${local.timestamp}"
snapshot_labels = {
os = "freebsd",
version = var.freebsd_version,
arch = "x86",
}
}
build {
sources = [
"source.hcloud.freebsd-arm",
"source.hcloud.freebsd-x86",
]
provisioner "shell" {
inline = [local.download_image]
environment_vars = [
"FREEBSD_IMAGE_URL=${local.image_url_arm}",
]
only = ["hcloud.freebsd-arm"]
}
provisioner "shell" {
inline = [local.download_image]
environment_vars = [
"FREEBSD_IMAGE_URL=${local.image_url_x86}",
]
only = ["hcloud.freebsd-x86"]
}
provisioner "shell" {
inline = [local.write_image]
}
provisioner "shell" {
inline = [local.install_zfs_on_rescue_system]
}
provisioner "shell" {
inline = [local.prepare_freebsd_for_configuration]
expect_disconnect = true
}
provisioner "shell" {
environment_vars = [
"ASSUME_ALWAYS_YES=yes"
]
inline = [local.install_packages]
}
provisioner "shell" {
inline = [local.ssh_key_cleanup]
}
}