Sandbox backends: kfd, kql, k7d
August 18, 2026 · View on GitHub
k7 turns every sandbox into a Kubernetes Deployment whose pod runs inside a
hardware-isolated microVM. How that VM is built, stored, snapshotted, and
forked is the backend's job. Three backends exist today; a node can install
any combination (k7 install --backend kfd,kql,k7d) and each sandbox picks
one (k7 create --backend …, annotation k7.katakate.org/backend).
kfd — kata-firecracker-devmapper | kql — kata-qemu-longhorn | k7d | |
|---|---|---|---|
| VMM | Firecracker via Kata Containers | QEMU via Kata Containers | k7d, Katakate's purpose-built KVM VMM |
| containerd RuntimeClass | kata | kata-qemu | k7 |
| Rootfs / storage | devmapper LVM thin-pool (needs a spare raw disk) | overlayfs + Longhorn PVC mounted at /mnt/state | cached erofs images on virtio-blk, guest tmpfs upper, reflink-XFS volume images |
Named snapshots (k7 snapshot) | ✗ | ✅ Longhorn VolumeSnapshot (disk-only, crash-consistent) | ✗ by design — see below |
k7 fork | ✗ | disk clone: snapshot → new PVC → cold boot | warm fork: CoW disk and memory of the live VM |
k7 pause / resume | scale to 0/1 (state lost) | scale to 0/1, disk persists on the PVC | VM frozen in place: vCPUs stop, memory stays |
k7 restore | ✗ | ✅ boot a new sandbox from any named snapshot | ✗ (fork the live sandbox instead) |
| Docker-in-VM sidecar | ✅ (emptyDir data) | ✅ (PVC-persistent data) | ✅ (VM-lifetime data) |
Measured numbers for all of this live in PERFORMANCE.md:
on the same node, k7d creates in ~2.1s vs kql's ~17.1s, forks to a usable
sandbox in ~2.4s vs ~46.7s (and the k7d fork inherits the source's memory),
and pauses/resumes in ~0.2s/~0.3s vs ~1.3s/~4.1s — while kql keeps named
persistent snapshots and the faster in-sidecar docker pull.
Which backend when?
- kfd — smallest attack surface and fast boots, when you don't need any snapshot/fork lifecycle. Requires a dedicated raw disk for the thin-pool.
- kql — durable sandboxes. The root disk is a replicated Longhorn volume: it survives pod restarts and node reboots, can be snapshotted by name, restored into brand-new sandboxes, and forked (at the cost of a full VM boot on the cloned disk).
- k7d — ephemeral-but-forkable compute, e.g. agent/RL rollouts. The whole VM (filesystem and RAM: running processes, page cache, sockets) can be forked in seconds, and pause/resume is instantaneous because nothing is torn down. State does not survive pod deletion — persistence is "fork it again", not "write it to a disk".
The k7d backend
Architecture: Linux amd64 / x86_64 only (Debian amd64 ≡ tarball
x86_64). Unlike kfd / kql, there is no arm64 build yet.
What k7 installs (k7 install --backend k7d)
The Ansible playbook:
- checks
/dev/kvmexists (loud failure otherwise) and loadsvhost_vsock+tun; - installs
erofs-utils,xfsprogs,virtiofsd, and provisions a sparse reflink-capable XFS image loop-mounted at/var/lib/k7d/disks(warm forks clone writable volume images withFICLONEreflinks); - downloads the k7d release tarball
(
k7d_artifact_url, default the publicKatakate/k7dGitHub release fork7d_version, currently 0.2.1) and runs the bundledinstall.sh, which installsk7d+containerd-shim-k7-v1into/usr/local/bin, guest kernel/initramfs into/usr/local/share/k7d, and startsk7d.service(control socket/run/k7d/k7d.sock). Override withk7 install --k7d-version <ver>(same GitHub URL, other tag) or--k7d-artifact <path>/k7d_artifact_local_path(local tarball). The PPAk70.2.1 playbook still defaults to k7d 0.1.0 — pass--k7d-version 0.2.1on that package until the next k7 cut. - registers the
k7runtime in the k3s containerd template withpod_annotations = ["k7d.katakate.org/*"]and without aBinaryNameoption (containerd resolves the shim fromruntime_type = "io.containerd.k7.v1"), restarts k3s, and creates RuntimeClassk7; - labels the node
k7.katakate.org/backend-k7d=true.
How a sandbox maps to a VM
One pod = one k7d microVM. The pod's containers (sandbox + optional docker
sidecar) all run as runc containers inside that single VM — multi-container
pods share the guest kernel, network namespace, and tmpfs. VM size follows the
pod's CPU/memory limits (the shim reads the CRI sandbox annotations, so
k7 create --cpu 2 --memory 1Gi gives a 2-vCPU/1 GiB VM with a host-side CFS
cap).
The fork story
k7 fork src dst on a k7d sandbox does not copy a disk. Instead the new
Deployment's pod carries two annotations:
k7d.katakate.org/fork-source-cluster: <source CRI sandbox id>
k7d.katakate.org/fork-source-vm: <source CRI sandbox id>
The pod's containerd shim resolves the source VM through the k7d daemon and
issues a fork_vm: the daemon briefly pauses the source, captures dirty
pages, and builds the child from a MAP_PRIVATE CoW mapping of the source's
memory plus reflink clones of its disk overlays. The child inherits
everything — files, tmpfs, running processes, page cache — and diverges
independently from that point. The source keeps running (its vsock identity
is preserved). The fork pod then adopts the workload container already
running inside the forked guest, so kubectl exec / k7 exec transparently
target the inherited state.
Properties and limits (fail loudly, never silently):
- Warm fork works for single-workload sandboxes; forking a sandbox with a sidecar is rejected (no reliable container mapping — use kql for that).
- The fork is pinned to the source's node (the k7d daemon is node-local; cross-node fork is k7d's M12 roadmap item).
- If the fork pod is ever restarted by Kubernetes it re-forks from the (then current) source — a fork is a live branch, not a stored artifact.
k7 snapshot/k7 restoreare rejected on k7d: named, storable snapshots are a Longhorn/kql feature. k7d has its own richer VM snapshot trees (fork/rollback/suspend of whole VM states, including multi-VM clusters) driven through the k7d daemon API — see the k7d project. Whole-cluster fork (forking an inner k8s cluster of VMs as one unit) is deliberately a k7d-level feature, not a k7 verb.
k7d VM ops work on any node (per-node k7-agent)
k7d pause/resume/fork need three things that only exist on the node
hosting the sandbox: the k7d daemon socket (/run/k7d/k7d.sock), the k3s
containerd socket, and crictl. Since spec 18g this is handled by the
k7-agent DaemonSet (kube-system, same k7-api:local image running
k7.api.agent:app), so VM ops work through the API for a sandbox on any
node:
- A VM op on a sandbox co-located with the k7-api pod runs directly (the
deployment mounts both sockets and ships
crictl). - A VM op on a sandbox on any OTHER node is forwarded to the k7-agent pod
on that node (
POST /agent/v1/vm/{pause,resume,fork,lookup}on the pod IP). Forwarding authenticates with the shared token the install playbook writes to/etc/k7/agent_token(root, 0600) on every node; a CiliumNetworkPolicy limits pod-originated agent ingress to the k7-api pod. No Ready agent on the node / missing token → loud error, never a silent no-op. - CLI on a node also works for any sandbox: local sandboxes talk to the local daemon, remote ones are forwarded the same way (root can read the token).
- A fork still lands on the source's node — the agent proxies to the node-local daemon; the cross-node fork data path is k7d spec 9a M12 (daemon side, not built yet). When it lands, only the forwarding target changes.
kql pause/resume/fork have none of these constraints (they are pure Kubernetes/Longhorn operations) and work through the API for any node.
Pause / resume
k7 pause on k7d asks the daemon (over /run/k7d/k7d.sock) to stop the VM's
vCPU threads and park its device workers: RAM, devices, and the vsock CID all
stay. The pod object remains scheduled (annotated
k7.katakate.org/k7d-paused=true), so k7 resume is just "restart the vCPU
loop" — sub-second, and every in-memory byte survives. Compare kql, where
pause scales the Deployment to zero (the VM is destroyed; only the Longhorn
disk survives) and resume pays a full VM boot.
Egress modes
A sandbox has exactly one of three egress modes (spec 18f issue 4):
| Mode | CLI | YAML / API | Result |
|---|---|---|---|
| block-all (CLI default) | k7 create … (no egress flag) | egress_whitelist: [] | deny-all egress NetworkPolicy |
| whitelist | --egress <entry> (repeatable) | egress_whitelist: [<entries>] | only listed CIDRs/domains |
| open | --egress-open | omit egress_whitelist (or null) | no egress policy at all |
--egress-open and --egress are mutually exclusive. Note the asymmetric
defaults, kept for backward compatibility: the CLI without flags is
block-all, while an API/YAML request that omits egress_whitelist
entirely is open.
Wildcard semantics: --egress '*.docker.com' covers subdomains at any
depth (registry.docker.com and production.cloudfront.docker.com) but
not the apex docker.com itself — add it as its own entry. Under the hood
k7 translates a leading *. into Cilium's multi-label **. matchPattern; a
bare Cilium * never crosses label boundaries, which used to silently break
CDN-backed registries (spec 18f issue 3). The install also sets Cilium
dnsProxy.minTtl=3600 so clients that cache a resolved IP longer than the
CDN's 30–60s DNS TTL (dockerd's blob downloader does) keep their learned
FQDN→IP allowance for an hour.
Disk pool sizing (kfd + k7d)
Both node-local storage pools have fixed sizes chosen at install time (spec 18f issue 5) — set them per node in the inventory:
| Pool | Backend | Default | Inventory knob | Utilization |
|---|---|---|---|---|
kata-vg/thin-pool (LVM, on the spare disk) | kfd | 100G PV | kata_thinpool_pv_size | lvs kata-vg (Data%/Meta%) |
/var/lib/k7d/disks (sparse XFS loopback) | k7d | 32G image | k7d_disks_image_size | df -h /var/lib/k7d/disks |
Failure modes when a pool fills — both are invisible to kubelet (no disk-pressure eviction, the pools are not part of the root filesystem):
- kfd thin-pool full: LVM autoextend (
thin_pool_autoextend_threshold=80) grows the pool within the PV; once the PV itself is exhausted writes inside sandboxes start failing with I/O errors and new kfd pods fail to create their devmapper snapshots (CreateContainerError). Only the firstkata_thinpool_pv_sizeof the spare disk is used — size it generously. - k7d pool full: writable volume images and fork reflink clones fail;
the k7d daemon rejects new sandboxes/forks loudly (
no space left on device). The image is sparse, sols -lshows the virtual size — usedu/dffor actual usage.
Utilization for both pools on every node is surfaced through the API
(spec 18g) and CLI/SDK (spec 18h): GET /api/v1/nodes/storage,
k7 nodes storage (--json for raw), and Client.nodes_storage()
return a per-node map of kata_thinpool (lvs size/data%/metadata%)
and k7d_disks (df size/used/avail), collected from the k7-agent
DaemonSet. A node whose agent is unreachable gets a loud
{"error": ...} entry.
Memory limits (--memory)
All three backends honour k7 create --memory <qty> (Kubernetes quantity
like 2Gi / 4096Mi):
| Backend | Mechanism |
|---|---|
| kfd | Kata stamps io.katacontainers.config.hypervisor.default_memory (MiB). containerd forwards io.katacontainers.* (pod_annotations on runtimes.kata) and configuration-fc.toml allowlists default_memory (spec 18h — before that the annotation was silently ignored and the VM stayed at the 2048 MiB default). |
| kql | Same annotation path via runtimes.kata-qemu + configuration-qemu.toml (spec 18g). |
| k7d | No Kata annotation — the k7d shim sizes the VM straight from the pod's CRI CPU/memory limits. |
Known issues
- kata-fc VMM leak under churn (spec 18f issue 1b): kata 3.24.0 with the
jailer records the
--daemonized jailer's PID, so its SIGTERM fallback at pod deletion signals a dead PID. When the graceful in-guest shutdown fails (dead agent under parallel churn), the firecracker process is orphaned and spins at 100% CPU.k7 installdeploys a per-node systemd timer (k7-vmm-reaper.timer, 1-minute cadence) that kills VMM processes whose kata shim is gone, and the integration suite asserts zero orphans cluster wide after teardown (tests/integration/test_zz_leaks.py). - kql docker-in-VM wedge under heavy fsync + replicas ≥ 2 — FIXED
(spec 18f issue 8 / spec 18g): kata's default single-threaded virtiofsd
serialized all virtio-fs IO; under a sustained fsync burst against an
r≥2 Longhorn volume the kata-agent's health ping starved behind the IO
convoy and the shim killed the healthy VM.
k7 installnow widens the virtiofsd thread pool (--thread-pool-size=16); see PERFORMANCE.md for the root-cause narrative and post-fix numbers.