Container image
ghcr.io/zyvorai/fluxvm is one multi-arch (amd64, arm64) image that carries every FluxVM binary. Which one runs is chosen
by the container's command: (fluxvm serve, fluxvm-kube, fluxvm-microvm controller|node-agent). The Kubernetes
manifests that use it are in deploy/k8s.
How it is built
The image is assembled, not compiled. Dockerfile copies the staged tree that the Linux release
already built (bin/, lib/bpf/, share/ of fluxvm-<ver>-linux-<arch>.tar.gz, see linux-package.md) and
installs the runtime packages on top. Two consequences:
- No glibc mismatch. The release binaries are linked on
ubuntu:22.04(glibc 2.35, the floor enforced byscripts/check-glibc-floor.sh). The earlier Dockerfile rebuilt them on Ubuntu 24.04, producing binaries the floor gate had never seen. The runtime base is nowubuntu:24.04(glibc 2.39), which is never older than the builder. - The image runs the released bytes. What was tested and signed in the tarball is what is in the image.
Runtime packages mirror packaging/nfpm.yaml: libhivex0, libsystemd0, qemu-system-x86 (amd64) or qemu-system-arm
(arm64), qemu-utils, cloud-image-utils, iproute2, dnsmasq-base, nftables, bpftool (linked from linux-tools-generic; Ubuntu 24.04 has no bpftool package), util-linux. There is no
python3, curl, or build toolchain, and nothing is downloaded at build time except Ubuntu packages.
The base image is pinned by index digest (ubuntu:24.04@sha256:68ee2200...; both amd64 and arm64 are in that index). To
refresh it: crane digest docker.io/library/ubuntu:24.04, edit the FROM line, and rebuild.
Cloud Hypervisor and Firecracker
The old image fetched "latest" of both from GitHub at build time. That is removed. They are not in the image yet: they
will be added from pinned, checksummed binaries once packaging/vendor.lock exists (see the TODO(vendor.lock) markers in
the Dockerfile). Until then the QEMU backend is the supported one in the container.
Building by hand
mkdir -p stage && tar -xzf fluxvm-0.4.8-linux-x86_64.tar.gz -C stage
docker buildx build --platform linux/amd64 \
--build-context stage=stage/fluxvm-0.4.8-linux-x86_64 \
--build-arg VERSION=0.4.8 -t fluxvm:0.4.8 .
Use the tarball that matches --platform; the architectures are not interchangeable.
How it is published
publish-image.yml, on a v* tag or by hand (workflow_dispatch, input
version; a moving tag such as latest is rejected):
- plan validates the version and computes the tags:
<ver>always,<major>.<minor>for final releases only. No manifest is ever taggedlatest. - build (per architecture, on a runner of that architecture): downloads
fluxvm-<ver>-linux-<arch>.tar.gzfrom thev<ver>release, checks its.sha256, builds the image withprovenance: mode=maxandsbom: true, and pushes it by digest only. On a tag push it waits up to 60 minutes forrelease-linux.ymlto upload the tarball. The arm64 leg iscontinue-on-errorbecause the arm64 package is still experimental. - merge joins the pushed digests with
docker buildx imagetools create, then:- signs the manifest list keyless (
cosign sign, GitHub OIDC, recorded in the Rekor log); - attaches a
cosign atteststatement (custom predicate: version, Dockerfile commit, workflow run, SHA-256 of each release tarball); - verify gate:
cosign verifyandcosign verify-attestationagainst this workflow's identity, plus checks that provenance and an SBOM are present and that each tag resolves to the signed digest. The job fails, and no manifest is attached to the release, if any of it fails; - renders
fluxvm-k8s-<ver>.yaml(below) and attaches it, with its.sha256, to thev<ver>release.
- signs the manifest list keyless (
Verifying an image yourself
cosign verify ghcr.io/zyvorai/fluxvm@sha256:<digest> \
--certificate-identity-regexp '^https://github.com/zyvorai/zyvor-fluxvm/\.github/workflows/publish-image\.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Kubernetes
The manifests under deploy/k8s name their image as
ghcr.io/zyvorai/fluxvm:__VERSION__@sha256:__DIGEST__. The placeholders are deliberate: the checked-in files cannot be
applied by mistake. Use the release asset, which has real values:
kubectl apply -f fluxvm-k8s-0.4.8.yaml
or render it yourself for a version and digest you have verified:
scripts/render-k8s.sh --version 0.4.8 --digest sha256:<digest> --out fluxvm-k8s-0.4.8.yaml
The script rejects a malformed digest, refuses to leave a placeholder behind, and checks that every image reference ends
up pinned by digest. It renders the namespace, RBAC, ConfigMap, both CRDs, the node DaemonSet and the MicroVM controller and
node agent. It leaves out microvm/secret.yaml (a token you create) and microvm/servicemonitor.yaml (needs the
Prometheus Operator CRDs).
Host preparation
The node DaemonSet has a host-prep init container (the same image, privileged, only that container) that runs before the VMM
starts, on every pod start:
- fails if
/dev/kvmis missing; - runs the host's
modprobe nbd max_part=16(itchroots into the host root mounted at/host), and warns ifnbdwas already loaded with a smallermax_part; - writes
/etc/qemu/bridge.confcontainingallow allon the host, which thefluxctlcontainer mounts read-only at/etc/qemuso the image'sqemu-bridge-helpersees it.allow alllets any bridge be used by QEMU TAP networking; narrow it if the node is shared.
If the init container fails the pod stays in Init:Error, with the reason in its log.
What a DaemonSet still cannot do
- Create
/dev/kvm. Virtualization has to be on in firmware and thekvmmodules present. The init container only checks. - Provide a kernel. It cannot upgrade or replace the node kernel, and cannot add
nbdto a kernel built without it or without a module tree at/lib/modules/$(uname -r);modprobethen fails. Distros that strip modules (some container-optimised OSes) are out. - Change an already loaded
nbd.max_partis only read at load time. Fixing it needsrmmodwhile idle, or a reboot. - Persist across reboot. The module load and
bridge.confare redone when the pod starts again, but anything that needs them before the kubelet starts a pod (host services, other DaemonSets) cannot rely on them. - Make the host bridge. Bridges and macvtap parents for
network_mode: tap|macvtapare host network configuration. - Stage VM images. There is still no Kubernetes-native image pull; images live under
/var/lib/fluxvm/images. - Replace a hardened host. The pod is
hostNetworkand holdsNET_ADMIN,SYS_ADMINand more; the init container is fully privileged. Pod Security admission must allowprivilegedinfluxvm-system, and SELinux/AppArmor policy on the node may still block the helper binaries. - Run Cloud Hypervisor or Firecracker until the vendor lock lands (above).
Status of this change
The Dockerfile, workflow and manifests were written on a machine without Docker. What was checked there: YAML parses,
shellcheck on scripts/render-k8s.sh and on every run: block of the workflow, kubeconform on the rendered manifest
(19 resources, none invalid; 5 skipped as unknown kinds), and the render script's failure paths. Not checked: that the
image builds, that the packages exist under those names on arm64, that qemu-bridge-helper works inside the container,
that host-prep succeeds on a real node, and the whole publish workflow. The first run is by workflow_dispatch against a
version that already has a release.