Skip to content
brdelphusPublic

About

Reproducible QCOW2 image builder for Void Linux — runit+OpenRC, k3s-ready, multi-cloud

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

42 Commits

Folders and files

Repository files navigation

void-oci

Reproducible QCOW2 image builder for Void Linux OCI instances (Oracle Cloud), purpose-built for running k3s clusters.

This image is what the K3s-void-v3 project (Terraform + cloud-init for HA K3s on OCI) boots: build it, import it into your tenancy, and point void_oci_image_display_name at it.

Why

Oracle Cloud has no official Void Linux images. Running k3s on mainstream distro images (Ubuntu, Oracle Linux) means fighting systemd's cgroup management, which conflicts with containerd's resource isolation. Void solves this cleanly:

  • No systemd — runit as PID 1 stays out of cgroup management entirely
  • cgroup v2 unified hierarchy (cgroup_no_v1=all) — required by containerd; eliminates the v1/v2 hybrid that breaks k3s on most cloud images
  • OpenRC as the service manager — both cloud-init and k3s expect an OpenRC/SysV-compatible service interface (rc-service, rc-update, init scripts in /etc/init.d/). This image runs OpenRC for all service management; runit is kept as PID 1 purely for its boot stage handling and process supervision, but it supervises only one thing: the OpenRC service itself
  • Reproducible image — OCI requires uploading a custom image; without a build script, every new cluster node would need manual setup

Init architecture

runit is Void's native PID 1. It supervises exactly two services: the OpenRC driver, and sshd (which must never daemonize — see the note below):

runit (PID 1)
  stage 1 — mounts /proc, /sys, sets up the environment
  stage 2 — runsvdir /var/service/
               ├─ openrc (drives everything below)
               │    ├─ openrc sysinit  →  devfs, dmesg, sysfs
               │    ├─ openrc boot     →  dhcpcd, cloud-init chain, chronyd
               │    └─ openrc default  →  rsyslogd, k3s (when installed)
               └─ sshd (foreground, respawned, logs via vlogger)
  stage 3 — openrc shutdown → system halt

sshd is supervised by runit, not OpenRC — deliberately. openssh ships /etc/sv/sshd, whose run script regenerates missing host keys and then exec sshd -D. Wrapping that in an OpenRC init script instead means the binary daemonizes: OpenRC loses the PID (rc-status reports stopped while sshd is running), nothing respawns it if it crashes, and upgrading openssh leaves the old daemon alive against replaced libraries — new connections get kex_exchange_identification: Connection reset by peer and the node is unreachable with no supervisor to fall back on. With runsvdir, a crash or an upgrade is just a respawn.

Everything else (networking, cloud-init, NTP, k3s) is an OpenRC service managed with the standard rc-service / rc-update commands.

This matters because:

  • cloud-init's OpenRC init scripts call rc-service to start/stop services during provisioning; they expect OpenRC, not runit's sv command
  • k3s ships an OpenRC init script and registers itself with rc-update; on a pure runit system this would silently do nothing
  • operators familiar with Alpine or Gentoo can manage services the same way

Migrating a running instance (images built before this change)

Images built before sshd moved to runit have /etc/init.d/sshd plus a /etc/runlevels/boot/sshd symlink, and no /etc/runit/runsvdir/default/sshd. Patch a live node with:

sudo rm -f /etc/init.d/sshd /etc/runlevels/boot/sshd
sudo ln -sfn /etc/sv/sshd /etc/runit/runsvdir/default/sshd
sudo ln -sfn /etc/sv/sshd /var/service/sshd          # current run, no reboot
sudo pkill -f 'sshd: /usr/bin/sshd' || true          # drop the unsupervised daemon
sleep 2 && sudo sv status sshd                       # expect: run: sshd (pid N)

Verify with sv status sshd (supervised) and sudo ss -lntp | grep :22 (the listener PID must match the one runsv reports). If SSH is already gone, reach the node through a privileged pod with hostPath: / mounted and chroot /host.

Generates bootable images for x86_64 and aarch64 with:

  • Void Linux (runit as PID 1) + OpenRC layered on top
  • cgroup v2 unified hierarchy (cgroup_no_v1=all)
  • cloud-init 26.1 (Oracle datasource)
  • GRUB with serial console
  • OpenRC built from source (embedded in this repo under openrc/)

Requirements

qemu-img  qemu-nbd  parted  partx  mkfs.vfat  mkfs.ext4  blkid  curl  tar
meson  ninja  gcc                  # for OpenRC build inside chroot

For aarch64 cross-builds:

qemu-aarch64-static    # Void: xbps-install qemu-user-static

Usage

sudo ./build.sh [x86_64|aarch64] [oracle|aws|azure|gcp|auto]

The second argument selects the cloud target (default: oracle):

Target cloud-init datasource
oracle Oracle
aws Ec2
azure Azure
gcp GCE
auto Oracle, Ec2, Azure, GCE (auto-detect)

Examples:

sudo ./build.sh x86_64 oracle    # → void-oracle-x86_64.qcow2
sudo ./build.sh x86_64 aws       # → void-aws-x86_64.qcow2
sudo ./build.sh aarch64 gcp      # → void-gcp-aarch64.qcow2
sudo ./build.sh x86_64 auto      # → void-auto-x86_64.qcow2 (any cloud)

Output images are ~2GB. Build takes 15–25 min per arch (dominated by package download + OpenRC compile). The rootfs tarballs are cached locally after the first download.

Local QEMU testing

The images use GRUB-EFI (GPT disk, no BIOS boot partition). SeaBIOS cannot boot them — OVMF is required (xbps-install edk2-ovmf).

Prerequisite: disable cloud-init — without an IMDS endpoint at 169.254.169.254 the Oracle datasource hangs during the boot runlevel, so sshd never starts. Patch a copy of the image (keeps the artifact pristine) and add cloud-init=disabled to the GRUB cmdline:

cp void-oracle-x86_64.qcow2 /var/tmp/void-test.qcow2
sudo qemu-nbd --connect=/dev/nbd0 /var/tmp/void-test.qcow2
sudo partx -u /dev/nbd0
sudo mount /dev/nbd0p2 /mnt
# anchor on cgroup_no_v1=all: the cmdline no longer contains console=tty0
sudo sed -i 's|cgroup_no_v1=all|cloud-init=disabled cgroup_no_v1=all|g' /mnt/boot/grub/grub.cfg
grep -c 'cloud-init=disabled' /mnt/boot/grub/grub.cfg   # expect 3 (default + 2 advanced entries)
sudo umount /mnt && sudo qemu-nbd --disconnect /dev/nbd0

x86_64

cp /usr/share/edk2/x64/OVMF_VARS.fd /tmp/ovmf-vars.fd

qemu-system-x86_64 \
    -enable-kvm -cpu host -m 2G \
    -drive if=pflash,format=raw,unit=0,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd,readonly=on \
    -drive if=pflash,format=raw,unit=1,file=/tmp/ovmf-vars.fd \
    -drive file=void-oracle-x86_64.qcow2,if=virtio,format=qcow2 \
    -netdev user,id=net0,hostfwd=tcp::2223-:22 \
    -device virtio-net-pci,netdev=net0 \
    -serial file:/tmp/void-qemu.serial \
    -display none &

ssh -o StrictHostKeyChecking=no -p 2223 void@localhost  # password: voidlinux

Boots in ~20s with KVM. Serial output goes to /tmp/void-qemu.serial.

aarch64

cp /usr/share/edk2/aarch64/QEMU_VARS.fd /tmp/qemu-aarch64-vars.fd

qemu-system-aarch64 \
    -M virt -cpu cortex-a57 -m 2G \
    -drive if=pflash,format=raw,unit=0,file=/usr/share/edk2/aarch64/QEMU_CODE.fd,readonly=on \
    -drive if=pflash,format=raw,unit=1,file=/tmp/qemu-aarch64-vars.fd \
    -drive file=void-oracle-aarch64.qcow2,if=virtio,format=qcow2 \
    -netdev user,id=net0,hostfwd=tcp::2224-:22 \
    -device virtio-net-pci,netdev=net0 \
    -serial file:/tmp/void-qemu.serial \
    -display none &

ssh -o StrictHostKeyChecking=no -p 2224 void@localhost  # password: voidlinux

No KVM — aarch64 emulation on an x86 host is software-only. Expect ~2min to reach the login prompt. GRUB prints serial port 'com0' isn't found because -M virt uses a PL011 UART (ttyAMA0) rather than a 16550 COM port; this is harmless — the boot continues and the kernel still comes up.

Install k3s

curl -sfL https://get.k3s.io | sudo sh -
sudo k3s kubectl get nodes

k3s detects OpenRC and installs its init script to /etc/init.d/k3s, enables it in the default runlevel, and starts it immediately. The node goes Ready within ~15s on x86_64 (longer on emulated aarch64).

Upgrading k3s on these nodes

This image only provides the ground k3s stands on: the binary itself is installed by get.k3s.io at /usr/local/bin/k3s and is never a package (xbps does not manage it). Fleet upgrades are driven from the cluster repo K3s-void-v3 through the system-upgrade-controller, using the public image ghcr.io/brdelphus/k3s-upgrade-void:v<version>-k3s1 (see that repo's "Upgrade image (GHCR)" section for tags, build cadence and the no-chain rule).

Three things on the OS side make that work — break any of them and the upgrade either does nothing or writes to the wrong filesystem:

  1. k3s under a supervisor that respawns it. The upgrade script SIGTERMs k3s; here the OpenRC service under runit brings it back. If it does not come back, the script falls back to rc-service k3s restart and reports failure.
  2. The real host root reachable at /host. The upgrade pod mounts / as a hostPath; on some nodes it silently gets an overlay of the container rootfs instead, so the "new" binary lands in the container and the node never changes. upgrade.sh aborts unless /host/etc/os-release really says Void Linux.
  3. curl + ca-certificates present — the binary fallback downloads from GitHub when the controller did not stage /opt/k3s. Both are already in COMMON_PKGS of build.sh.

Never pin k3s by installing a package or hand-editing /usr/local/bin/k3s: the next plan run replaces that file with the image's /opt/k3s, so any local pin is silently undone.

Verified

Arch Kernel k3s containerd Where / when
aarch64 7.2.15_1 v1.37.2+k3s1 2.3.7-k3s1 Ready in QEMU (-M virt, emulated, ~2 min to the prompt) — build 2026-11-04: sshd under runit, /etc/init.d/sshd absent, k3s node Ready
aarch64 7.2.15_1 v1.37.1+k3s1 2.3.4-k3s1 4-node production cluster k3s-v3 (OCI A1 Flex, sa-saopaulo-1) — 2026-11-04
x86_64 7.2.15_1 v1.37.2+k3s1 2.3.7-k3s1 Ready in QEMU (KVM, OVMF, cloud-init disabled) — image build of 2026-11-04: kernel 7.2.15_1 booted, sshd supervised by runit (pid alive, /etc/init.d/sshd absent), k3s node Ready

What gets built

Component Detail
Base Void Linux ROOTFS 20250202
Kernel (x86_64) linux7.2 (pinned 7.x series) + linux6.6 LTS as GRUB fallback
Kernel (aarch64) linux7.2 (pinned 7.x series) + linux6.6 LTS as GRUB fallback
Bootloader GRUB EFI (removable)
Init runit (PID 1) → OpenRC (services)
Network dhcpcd on eth0
Cloud init cloud-init 26.1, Oracle datasource
OCI agent oracle-cloud-agent 1.59.0-12 (oracle builds only)
Default user void (UID 1000, wheel)
Passwords void / root → voidlinux (change on first login)
SSH cloud-init injects SSH key on first boot via OCI IMDS (bootcmd fallback + Oracle datasource); password auth always available as fallback (sshd_config.d/00-void-oci.conf); sshd runs under runit (/etc/runit/runsvdir/default/sshd), not the OpenRC script

Disk layout

GPT, 8G image
  p1  256M  EFI (vfat)
  p2  rest  Linux root (ext4, label: void-oci)

OpenRC runlevels

Runlevel Services
sysinit devfs, dmesg, sysfs
boot cloud-init-local, dhcpcd, cloud-init, cloud-config, cloud-final, chronyd, sshd
default rsyslogd, oracle-cloud-agent (oracle builds only)

sshd is in the boot runlevel (not default) so it becomes available as soon as networking is up, without waiting for the cloud-init chain to complete. On OCI, cloud-init-local can be slow on first boot (downloading instance metadata); if sshd were in default, SSH would be unreachable until cloud-init finished.

No cgroups service in sysinit — runit mounts cgroup2 in stage 1.

Cloud-init

files/cloud.cfg (installed at /etc/cloud/cloud.cfg) drives first-boot provisioning. On OCI the datasource is Oracle, talking to the instance metadata service (IMDS) at 169.254.169.254.

What cloud-init does on first boot:

  • SSH key injection — OCI provides exactly one SSH public key per instance. A bootcmd fetches it from http://169.254.169.254/opc/v1/instance/metadata/ssh_authorized_keys and writes it to /home/void/.ssh/authorized_keys (correct ownership/permissions) before the ssh module runs — key injection stays reliable even when the standard module is slow or flaky on first boot
  • Root filesystem resize — growpart + resizefs expand the root partition to the full boot volume (the image ships as 8G)
  • Hostname & hosts — set_hostname / update_hostname / update_etc_hosts apply the instance name from OCI
  • User setup — user void (UID 1000, groups wheel + adm) with passwordless sudo; disable_root: true; ssh_pwauth: true (password login kept as a fallback)
  • NTP / timezone — chronyd via the ntp module
  • Hooks — runs scripts-per-boot / scripts-per-instance / scripts-user from the instance

Network dependency: cloud-init needs IMDS reachable before anything works, so the boot runlevel starts dhcpcd with -w (wait for network to complete) and provide net network-online. On OCI the DHCP server hands out routes via option 121 (classless static routes) without a default route — files/dhcpcd.conf uses nooption classless_static_routes so dhcpcd falls back to the router option (option 3) and gets the default gateway. Without this, the instance has no internet, IMDS is unreachable, and the cloud-init chain hangs on first boot.

sshd also runs in the boot runlevel, so SSH becomes available as soon as the network is up — no need to wait for the cloud-init chain to finish.

Configuration files

File Destination
files/grub /etc/default/grub
files/openrc.conf /etc/openrc.conf
files/cloud.cfg /etc/cloud/cloud.cfg
files/sshd_config /etc/ssh/sshd_config (includes sshd_config.d/)
(generated) /etc/ssh/sshd_config.d/00-void-oci.conf (PasswordAuthentication yes)
files/dhcpcd /etc/init.d/dhcpcd
files/sudoers-void /etc/sudoers.d/void

Oracle Cloud Agent

Oracle images include Oracle Cloud Agent, the lightweight Go daemon that enables OCI console features: monitoring, bastion sessions, run command, OS management, and HPC plugins.

The agent is packaged as a Void xbps package built from the official snap (statically linked Go binaries — no snapd required at runtime).

Package location

srcpkgs/oracle-cloud-agent/
  template                          # xbps-src build template
  files/etc/oracle-cloud-agent/
    agent.yml                       # plugin config with corrected paths
  files/etc/init.d/
    oracle-cloud-agent              # OpenRC init script (supervise-daemon)

Build

The template lives in srcpkgs/oracle-cloud-agent/ in this repo. build.sh copies it to $VOID_PACKAGES/srcpkgs/ automatically before building, so the repo is the single source of truth for the package.

build.sh builds and installs the package automatically for oracle images if /usr/src/void-packages (or $VOID_PACKAGES) exists. To pre-build manually:

# copy template first
cp -r srcpkgs/oracle-cloud-agent /usr/src/void-packages/srcpkgs/

# x86_64
cd /usr/src/void-packages && ./xbps-src pkg oracle-cloud-agent

# aarch64
cd /usr/src/void-packages && ./xbps-src -a aarch64 pkg oracle-cloud-agent

Cached packages in hostdir/binpkgs/ are reused on subsequent image builds. If $VOID_PACKAGES is absent or the build fails, the image is built without the agent and a warning is printed.

Runtime

/usr/lib/oracle-cloud-agent/agent          # main daemon
/usr/lib/oracle-cloud-agent/plugins/       # gomon, bastions, oci-osmh, ...
/etc/oracle-cloud-agent/agent.yml          # plugin configuration
/var/log/oracle-cloud-agent/agent.log      # log (created at first start)

Manage with standard OpenRC commands:

rc-service oracle-cloud-agent start|stop|status

OpenRC source

The openrc/ directory contains the OpenRC source with patches for Void Linux:

  • tools/meson_final.sh — supports both MESON_BUILDDIR (meson ≥1.5) and MESON_BUILD_ROOT (meson <1.5)
  • etc/openrc.conf — replaces etc/rc.conf (which conflicts with Void's runit config)
  • sh/init.sh.Linux.in — cgroup v2 unified hierarchy support

About

Reproducible QCOW2 image builder for Void Linux — runit+OpenRC, k3s-ready, multi-cloud

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages