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.
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
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-serviceto start/stop services during provisioning; they expect OpenRC, not runit'ssvcommand - 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
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/)
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
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.
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/nbd0cp /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: voidlinuxBoots in ~20s with KVM. Serial output goes to /tmp/void-qemu.serial.
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: voidlinuxNo 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.
curl -sfL https://get.k3s.io | sudo sh -
sudo k3s kubectl get nodesk3s 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).
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:
- 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 restartand reports failure. - 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.shaborts unless/host/etc/os-releasereally says Void Linux. curl+ca-certificatespresent — the binary fallback downloads from GitHub when the controller did not stage/opt/k3s. Both are already inCOMMON_PKGSofbuild.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.
| 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 |
| 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 |
GPT, 8G image
p1 256M EFI (vfat)
p2 rest Linux root (ext4, label: void-oci)
| 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.
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
bootcmdfetches it fromhttp://169.254.169.254/opc/v1/instance/metadata/ssh_authorized_keysand writes it to/home/void/.ssh/authorized_keys(correct ownership/permissions) before thesshmodule runs — key injection stays reliable even when the standard module is slow or flaky on first boot - Root filesystem resize —
growpart+resizefsexpand the root partition to the full boot volume (the image ships as 8G) - Hostname & hosts —
set_hostname/update_hostname/update_etc_hostsapply the instance name from OCI - User setup — user
void(UID 1000, groupswheel+adm) with passwordless sudo;disable_root: true;ssh_pwauth: true(password login kept as a fallback) - NTP / timezone — chronyd via the
ntpmodule - Hooks — runs
scripts-per-boot/scripts-per-instance/scripts-userfrom 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.
| 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 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).
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)
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-agentCached 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.
/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|statusThe openrc/ directory contains the OpenRC source with patches for Void Linux:
tools/meson_final.sh— supports bothMESON_BUILDDIR(meson ≥1.5) andMESON_BUILD_ROOT(meson <1.5)etc/openrc.conf— replacesetc/rc.conf(which conflicts with Void's runit config)sh/init.sh.Linux.in— cgroup v2 unified hierarchy support