litek8s dev: a local Kubernetes cluster for development
litek8s dev is a local Kubernetes cluster for your laptop, in the role
kind, k3d, minikube or Docker Desktop’s Kubernetes play, without Docker; as
many as you like, each with as many nodes as you like, all in one small VM
(macOS) or one rootless sandbox (Linux).
It is the same litek8s apiserver (API server, controllers, scheduler,
cluster DNS, service proxy) with a node agent on crun, packaged so that:
litek8s dev up # ~0.1 s on Linux, ~1.5 s on macOS; merges context litek8s-dev into ~/.kube/configkubectl create deployment web --image=nginxkubectl expose deployment web --type=NodePort --port 80curl localhost:$(kubectl get svc web -o jsonpath='{.spec.ports[0].nodePort}')litek8s dev load myapp:dev # an image of your local Docker/Podman, or a `docker save` archivelitek8s dev status # mode, server, pods, memory, disklitek8s dev down # stop; objects and images are keptlitek8s dev delete # stop and delete everything, and the kube context (`reset` does the same)litek8s dev up --name ci --nodes 3 # another cluster: context litek8s-ci, 3 nodeslitek8s dev list # every local cluster: state, API port, nodes, memory, ageNumbers against kind and k3d are in bench/RESULTS.md; the corpus score in compat/RESULTS.md.
Linux: a rootless sandbox
Section titled “Linux: a rootless sandbox”The cluster runs in namespaces of its own, as your user:
| namespace | what it isolates |
|---|---|
| user | your uid keeps its number inside; your /etc/subuid range backs every other id, root included, so image layers keep their owners and files a container creates in a bind mount as your uid are yours on the host (LITEK8S_DEV_IDMAP=root makes you root inside instead, Docker-rootless style) |
| network | the litek8s0 bridge, pod veths and every iptables rule (pod NAT, Service DNAT) |
| mount | a fresh /run (pod network namespaces, crun state), sysfs, a cgroup2 mount rooted at the cluster’s cgroup |
| PID | PID 1 is a small init; stopping it stops every pod process |
| cgroup, UTS, IPC | the node’s cgroup tree, hostname litek8s-dev |
Pods reach the Internet through pasta (or
slirp4netns), a userspace network stack on the host side, the way rootless
Podman does. The host side (litek8s dev supervise) binds the API port on
127.0.0.1 before the sandbox exists, and binds NodePorts and LoadBalancer
ports on 127.0.0.1 when the cluster asks for them (it receives the
listening sockets as file descriptors), so localhost:<nodePort> works
without kind’s extraPortMappings.
What it never touches: the host’s network interfaces, routes and
iptables/nftables rules (Docker’s or anyone’s), /run, other users’ files.
Everything it writes is under ~/.local/share/litek8s/dev.
What it needs
Section titled “What it needs”| need | why | how |
|---|---|---|
crun |
runs the containers | apt install crun / dnf install crun |
newuidmap, newgidmap (setuid) |
map your subordinate ids into the user namespace | package uidmap (Debian/Ubuntu) or shadow-utils; usually already installed |
| a subuid/subgid range of 65536 ids | owners inside images | /etc/subuid, /etc/subgid; useradd creates them on current distros |
pasta or slirp4netns |
pod egress | package passt (or slirp4netns) |
ip, iptables-restore |
used inside the sandbox only | iproute2, iptables (nft or legacy backend) |
| cgroup v2 + a systemd user session | a delegated cgroup for the cluster’s resource limits | dev up starts the supervisor in a systemd-run --user --scope -p Delegate=yes scope |
| Linux ≥ 5.11 | overlayfs in a user namespace (userxattr) |
|
Ubuntu ≥ 23.10 only: sudo litek8s dev setup, once |
AppArmor restricts unprivileged user namespaces; this installs /etc/apparmor.d/litek8s (an unconfined profile with userns, like Ubuntu’s own profile for crun) for the litek8s binary’s path |
re-run if you move the binary |
litek8s dev setup (without sudo) checks all of this and says what is
missing. That is the whole list: no daemon, no group membership, no
capabilities on the binary, no setuid litek8s. Run as root, dev up uses the
same sandbox minus the user namespace (cgroup /sys/fs/cgroup/litek8s-dev).
macOS: a small VM on Virtualization.framework
Section titled “macOS: a small VM on Virtualization.framework”dev up boots a Linux VM with Apple’s Virtualization.framework (the
litek8s dev-vm process; no Docker Desktop, OrbStack, Lima or vfkit). The
guest is Alpine’s linux-virt kernel and a read-only root disk (a romfs
image of crun, iptables, iproute2, e2fsprogs and busybox around a static
litek8s that runs as PID 1; a 0.75 MiB initrd mounts it under a tmpfs
overlay and hands over); a sparse disk holds images and objects. The Mac
side:
- forwards
127.0.0.1:<port>to the API server (6443 when free); - watches the Services and listens on
127.0.0.1:<nodePort>for every NodePort and on127.0.0.1:<port>for LoadBalancer ports (EXTERNAL-IPis127.0.0.1, as with Docker Desktop’s Kubernetes); - shares
$HOME(or--share <dir>) over virtiofs at the same path, sohostPath: /Users/you/projectvolumes work and edits show up live (also/private/tmpand/private/var/folders, where/tmpand$TMPDIRare); - passes file changes under bind-mounted directories into the VM (FSEvents on the Mac, IN_ATTRIB in the guest), so inotify watchers see Mac edits;
- shares Rosetta when it is installed, so
linux/amd64images run; - serves the Docker API on
~/.litek8s/docker.sock(see docker.md); - uses the console as the control channel (the guest reports its address
and its memory,
dev downpowers it off cleanly); - keeps the guest on as little of its memory as it needs, growing it on demand (Memory).
The framework requires the com.apple.security.virtualization entitlement.
Release binaries are ad-hoc signed with it; a binary that lacks it (a
cargo build), makes dev up run an ad-hoc signed copy
(codesign --sign -), so nothing needs sudo or an Apple developer
account. VM size: --memory (MiB,
default 4096, an upper bound the guest grows into, see below) and --cpus
(default half the Mac’s).
The guest (kernel, root image, initrd) comes with the macOS release
archive, in share/litek8s/vm beside bin/litek8s (install.sh puts it in
~/.local/share/litek8s/vm); dev up looks there first, so a release
binary always boots the guest of the same release. From source, build the
guest once (needs a Docker API or any Linux box for the musl build; a running litek8s dev serves one, so litek8s can build its next
guest itself, see develop-without-docker.md):
dev/vm/build.sh # static litek8s, then kernel + root image + initrd from Alpine packagesMemory on macOS
Section titled “Memory on macOS”Virtualization.framework never gives a VM’s memory back to macOS: every
page the guest has touched once stays charged to the VM’s process (its
footprint, what Activity Monitor shows) until the VM stops, however much
the guest frees. Dropping 1.5 GB of guest page cache moved the footprint by
nothing; its balloon device does not help either (it offers no free page
reporting, and pages handed to it stay charged: inflating it by 3 GiB moved
the footprint by 0 MB, or by +540 MB with Alpine’s init_on_alloc), and
saving and restoring the VM brings all of its memory back in (4.2 GB
footprint for a guest using 300 MB). Only stopping the VM returns memory;
OrbStack and Docker Desktop got dynamic memory by leaving the framework for
their own VMMs on Hypervisor.framework.
What litek8s does instead is keep the guest off memory it does not need, so there is less to charge:
- A ceiling that grows on demand. The VM boots with 1 GiB (a quarter
of
--memoryif that is more) for the guest; the balloon takes the rest before the guest has touched it, which costs nothing (the guest kernel runs withinit_on_alloc=0, so it does not zero what it hands over). The guest reports its memory on the console every 2 s when it changes (every 250 ms when little is left) and at once when a PSI trigger fires (memory stalls of 100 ms in a second);litek8s dev-vmgrows the ceiling by half (at least 512 MiB) when MemAvailable drops under 256 MiB (1/16 of the VM) or memory stalls while free memory is short (stalls with plenty free come from elsewhere, a pod at its own memory limit for one), and to the whole VM when it is nearly out. It shrinks again, by half the excess at a time, once the guest has been calm for 2 minutes with more than twice that available: page cache then stays within memory the guest already has instead of spreading over all of it (an image pull no longer leaves the VM charged for gigabytes), and the guest drops its oldest cache first (MGLRU); hot cache shows up as stalls and grows the ceiling back. - No livelock under the ceiling. With the root filesystem on a disk,
the code everything runs is reclaimable page cache; a burst of
allocations used to make the guest evict and re-read it for a minute
(the node agent lost its connection) instead of running out of memory.
The guest protects its working set of the last second
(
lru_gen/min_ttl_ms=1000), so the kernel takes its OOM path at once, and there the balloon deflates (the framework’s balloon may deflate on OOM) before anything is killed; the host grows the ceiling meanwhile. - A smaller fixed cost. The root filesystem is a disk image the guest
reads as it needs it (an initramfs is unpacked into RAM and stays there:
56 MiB, plus the 25 MiB archive the framework loads at boot), and the
kernel skips its 64 MiB of bounce buffers (
swiotlb=noforce: virtio devices here reach all memory).
LITEK8S_DEV_DYNAMIC_MEMORY=0 litek8s dev up gives the guest all of
--memory from the start. The ceiling and what moved it are in the VM’s
log (litek8s dev-vm: memory ceiling 1024 -> 1536 MiB (little available; ...), env.log in the dev data directory), and litek8s ui shows it with
the memory breakdown. To hand back memory the VM once used (after a big
build, say), restart it: litek8s dev down && litek8s dev up (objects,
images and volumes are kept; pods restart).
What macOS charges the VM (--cpus 4 --memory 4096, fresh cluster each
time, bench/vm-memory.sh; before: the guest from before these changes,
an initramfs and no ceiling):
| before | now | |
|---|---|---|
| idle, nothing deployed | 384 MB | 295 MB |
| idle, a pod | 461 MB | 389 MB |
| Argo CD installed (7 pods) | 1847 MB | 1140 MB (the ceiling stayed at 1 GiB) |
| then a 1 GB image pulled | 3296 MB | 1200 MB |
| then 5 minutes idle | 3296 MB | 1210 MB |
| then 1 GiB allocated at once in a pod | 0.39 s, 4083 MB | 0.36 s, 1596 MB (ceiling 1 → 4 GiB); no OOM kill either way |
| 1 GiB written, read and deleted in a container | +1058 MB, for good | +733 MB, for good |
After the 1 GiB burst the ceiling came back down within 4 minutes (4096 → 2471 → 1662 → 1257 → 1052 MiB) while a 600 MB file read every 10 s stayed in the guest’s cache throughout (125 ms per read, no refaults). For comparison, OrbStack’s VM (its own VMM) grew by 2.3 GB for the same 1 GiB container write and was back to where it started 30 s later: that, the framework does not allow. Details and the experiments behind this: bench/RESULTS.md.
Disk on macOS
Section titled “Disk on macOS”The VM’s data disk is a sparse file, vm-state/disk.img in the dev data
directory (64 GiB at most; only what the guest writes takes space). Unlike
its memory, the framework does give disk space back: the virtio disk
accepts discards (TRIM) and punches the discarded blocks out of the file.
So the guest trims its filesystem (FITRIM on /var/lib/litek8s) at boot
and then whenever the disk has been quiet for 30 s, at most once a minute;
ext4 only discards block groups freed since the last trim, so an idle
trim costs nothing. (Mounting with discard would discard on every
delete, in the middle of pulls and container churn.) docker rmi and
docker image prune free the image’s layers at once (layers written in
the last 10 minutes wait for the node’s image GC, in case a pull still
needs them), and the Mac has the space back within a minute or so:
disk.img on the Mac (du) |
before | now |
|---|---|---|
a container writes 1 GiB, then docker rm |
stays at 2.6 GB | back to 1.3 GB 55 s later |
docker rmi python:3.13 (1.1 GB unpacked) |
stays at 1.54 GB | 1.29 → 0.78 GB within 3 min; 123 MB after the node’s next image GC (9 min later) |
| boot with 362 MB deleted from an older guest | – | given back by the first trim |
du of disk.img is what counts; ls shows the image’s 64 GiB size.
Data directory
Section titled “Data directory”The dev home is dev/ in litek8s’s data directory:
~/Library/Application Support/dev.litek8s on macOS,
~/.local/share/litek8s on Linux ($LITEK8S_DEV_HOME overrides the dev
home). The guest assets built from source go in vm/ next to it
($LITEK8S_DEV_VM_ASSETS overrides).
LITEK8S_HOME=<dir> puts all of litek8s’s per-user files under <dir>
instead: the data directory becomes <dir>/data (so the VM, its disk and
the clusters are <dir>/data/dev, the guest <dir>/data/vm), the Docker
sockets and config.yaml are <dir>/... instead of ~/.litek8s/..., the
kubeconfig is <dir>/kubeconfig (unless KUBECONFIG is set) and contexts
are lkdev-<cluster> ($LITEK8S_CONTEXT_PREFIX). Two litek8s with
different homes run two VMs that know nothing of each other; that is how a
build from a checkout (dev/lk) runs next to an installed release
(develop-without-docker.md).
Builds before the rename used ~/Library/Application Support/dev.civojairo.litek8s
on macOS. litek8s keeps using that directory as long as it exists and
dev.litek8s does not, so an existing VM, its disk and its clusters are
never moved behind your back. To move to the new name:
litek8s dev list # note the clusters that are uplitek8s dev down --all # every cluster; the VM powers off with the lastpgrep -fl 'litek8s dev-vm' # nothing leftmv ~/Library/Application\ Support/dev.civojairo.litek8s ~/Library/Application\ Support/dev.litek8slitek8s dev up # same clusters, objects and imagesSettings
Section titled “Settings”dev up takes its defaults from ~/.litek8s/config.yaml ($LITEK8S_CONFIG),
which litek8s ui edits (ui.md); flags win over it:
| setting | flag | macOS | Linux |
|---|---|---|---|
cpus |
--cpus |
the VM’s CPUs (default: half the Mac’s, at most 8) | a CPU limit on the sandbox (default: none) |
memory (MiB) |
--memory |
the VM’s ceiling (default 4096) | a memory limit on the sandbox (default: none) |
shares |
--share (repeatable) |
directories shared at the same path (default $HOME) |
- (the sandbox sees the host’s files) |
port |
--port |
API server port of the default cluster (default 6443, a free one if taken; other clusters keep the one they got) | same |
publish |
--publish |
localhost (default) or all: where NodePorts, LoadBalancer ports and docker run -p ports listen |
same |
The file is read at up; env.json records what the VM or sandbox started
with, each cluster’s dev.json its own port and nodes. cpus, memory,
shares and publish belong to the VM or sandbox all clusters share: they
apply when the first cluster starts it.
Images
Section titled “Images”litek8s dev load <image>exports the image from the local Docker (or Podman’s Docker API, viaDOCKER_HOST) and imports it;litek8s dev load file.tartakesdocker save/podman savearchives in Docker or OCI layout, including Docker 25+’s multi-platform archives (only this host’s platform is used). Loaded images are never pulled over, whatever the pull policy, somyapp:latestworks withoutimagePullPolicy: IfNotPresent.- A registry on your machine’s
localhostworks as it is:image: localhost:5000/myapp:1reaches the host’s port 5000 from inside the sandbox (pasta maps the gateway to the host’s loopback) or the VM (the framework’s NAT gateway; the registry must listen on all interfaces, whichdocker run -p 5000:5000 registry:2does). Other plain-HTTP registries:LITEK8S_INSECURE_REGISTRIES=host:port.
Several clusters
Section titled “Several clusters”kind and k3d give every cluster its own node container (a whole systemd or
k3s with its own containerd, ~400-600 MB each); litek8s dev gives it a
few processes in the VM or sandbox that is already running:
litek8s dev up --name ci # context litek8s-ci, its own API port, data and Docker socketlitek8s dev up --name multi --nodes 3 # 3 nodes: litek8s-multi, -worker, -worker2litek8s dev listNAME STATE API NODES MEMORY AGE CONTEXTdev running 127.0.0.1:6443 1 4.2 MiB 7m litek8s-devci running 127.0.0.1:36097 1 3.9 MiB 1m litek8s-cimulti running 127.0.0.1:41011 3 17.7 MiB 0s litek8s-multi
shared sandbox (rootless, 62.3 MiB in use)litek8s dev down --name ci # stop one; the VM/sandbox stops with the last onelitek8s dev delete --name ci # and delete its data and contextlitek8s dev down --alldev up|down|status|delete|reset|load take --name (default dev, which
keeps the paths, port, context and socket litek8s always used, so an
existing cluster is not migrated). What each cluster gets:
default (dev) |
--name <n> |
|
|---|---|---|
| kubectl context | litek8s-dev |
litek8s-<n> (--no-switch adds it without making it current) |
| API port | 6443, a free one if taken | the one it had last, else a free one |
| Docker socket | ~/.litek8s/docker.sock |
~/.litek8s/<n>/docker.sock (docker context litek8s-<n>) |
| data (Linux) | ~/.local/share/litek8s/dev/cluster |
~/.local/share/litek8s/dev/clusters/<n>/data |
| data (macOS) | /var/lib/litek8s/dev in the VM’s disk |
/var/lib/litek8s/<n> in the VM’s disk |
| log | dev.log in the dev data directory |
clusters/<n>/dev.log there (the VM’s or sandbox’s own: env.log) |
| node names | litek8s-dev |
litek8s-<n>, litek8s-<n>-worker, -worker2, … |
| NodePorts from | 30000 | 30100-32700, by name (so two clusters rarely want the same localhost port) |
How. The first dev up starts the VM (macOS) or the sandbox (Linux);
each cluster is then a cell in it, made by the same code that makes the
Linux sandbox: its own network namespace (its litek8s0 pod bridge,
iptables rules, DNS, kubelet and NodePort ports: every cluster uses the
usual 10.42.0.0/16 pods and 10.43.0.0/16 Services without conflict), mount
namespace (/run, crun state), PID namespace (stopping it stops all of its
processes), cgroup namespace (/cells/<cluster>/<node>: a cluster’s memory
is that directory’s), and UTS and IPC namespaces. Cells reach each other and
the outside through a bridge in the VM or sandbox (lkcells0,
10.231.0.0/24, masqueraded). A small manager inside (litek8s dev __cells)
starts and stops cells; the VM process or supervisor outside binds each
cluster’s API port and Docker socket on the host (Linux: handed to the cell
as descriptors, so connections are not copied; macOS: relayed over vsock into
the cell’s network namespace) and forwards its NodePorts. When the last
cluster stops, the VM or sandbox goes too.
Several nodes. --nodes N (1-10, remembered) runs N-1 workers, each in
a cell of its own, joined with the real join flow (litek8s agent --join https://<control plane>:6443 --token ..., see
multi-node) and connected by the same
WireGuard pod overlay real nodes use: test pod spreading, affinity,
DaemonSets, draining, or a node failing on your laptop:
litek8s dev down --name multi --node litek8s-multi-worker2 # its pods die; NotReady after the grace period (40 s)litek8s dev up --name multi --node litek8s-multi-worker2 # back, Ready once it reconnectsOn Linux the overlay needs
the wireguard kernel module loaded (lsmod | grep wireguard; a rootless
sandbox cannot load it; most distributions load it on first use by root,
e.g. sudo modprobe wireguard once).
Isolation, honestly. Cells isolate names, networks, files under /run,
processes and cgroups, as containers do; they do not isolate the kernel. All
clusters share:
- the VM’s CPUs and memory (macOS) or the sandbox’s limits (Linux,
cpus/memoryin the settings): set for all of them, and one busy cluster slows the others (no per-cluster limits yet); - the VM’s disk: one sparse image for every cluster; what they delete goes back to the Mac within a minute or so (see Disk on macOS);
- on Linux, one user namespace: the same subordinate ids map to the same host ids in every cluster;
- the host’s ports: NodePorts, LoadBalancer ports and
docker run -pports of every cluster listen on the samelocalhost; when two clusters want one port, the first gets it and the other’s log says “not exposed”; - the image store is per cluster: an image pulled or
dev loaded in one is not in the others (dev load --name <n>).
A cluster deleted while the VM is down has its data deleted at the VM’s next boot.
Memory: 1, 3 and 5 clusters
Section titled “Memory: 1, 3 and 5 clusters”Each cluster one node, idle 60 s after dev up (or kind create cluster,
k3d cluster create), then with a 3-replica nginx:alpine deployment in each
(60 s after the rollout). A cluster’s memory is counted the way docker stats counts a container: its cgroup’s usage less the page cache the
kernel would drop first. Measured 2026-10-19 with the release build.
macOS (M-series MacBook, 14 cores, 36 GB; litek8s VM at --cpus 4 --memory 4096; kind v0.33.0 and k3d v5.8.3 with their default images on
OrbStack, as installed):
| clusters | litek8s: whole VM¹ | litek8s: the clusters² | kind: node containers³ | k3d: server + LB containers³ |
|---|---|---|---|---|
| 1, idle | 375 MiB | 2.4 MiB | 564 MiB | 614 MiB |
| 3, idle | 378 MiB | 6.1 MiB | 1574 MiB | 1707 MiB |
| 5, idle | 386 MiB | 9.2 MiB | 2857 MiB | 2818 MiB |
| 1, 3 pods each | 452 MiB | 28 MiB | 671 MiB | 649 MiB |
| 3, 3 pods each | 697 MiB | 82 MiB | 2033 MiB | 1925 MiB |
| 5, 3 pods each | 898 MiB | 135 MiB | 3080 MiB | 3208 MiB |
¹ The VM’s physical footprint as macOS counts it (what Activity Monitor
shows for it): the guest kernel, the initramfs (the litek8s binary, crun,
iptables) held in RAM, page cache, and every cluster. ² The sum of the
clusters’ cells. ³ docker stats, without OrbStack’s own VM, which the
kind and k3d columns do not include (it runs anyway for Docker). Creating
the clusters took 9/32/54 s with kind, 17/50/83 s with k3d, and 0.1-0.2 s
each with litek8s once the VM was up (its boot: about 1.5-2.5 s, once).
Linux (omarchy: x86_64, 16 threads, 32 GB, kernel 7.2, rootless; kind and k3d are not installed there, so litek8s only):
| clusters | litek8s: whole sandbox⁴ | litek8s: the clusters² |
|---|---|---|
| 1, idle | 38 MiB | 4 MiB |
| 3, idle | 52 MiB | 13 MiB |
| 5, idle | 63 MiB | 20 MiB |
| 1, 3 pods each | 91 MiB | 57 MiB |
| 3, 3 pods each | 205 MiB | 166 MiB |
| 5, 3 pods each | 317 MiB | 273 MiB |
⁴ The sandbox’s cgroup: the supervisor, pasta, the cell manager and every cluster (and, on Linux, the page cache of the binary, charged to whoever touched it first).
What it means: an idle cluster costs 2-4 MiB beyond the first and a kind or k3d cluster about 550 MiB, so five idle litek8s clusters fit in the memory of one kind node with room to spare. With pods the pods dominate: nginx starts a worker per CPU the cell sees (4 in the VM, 16 on the Linux machine). The VM’s footprint grows faster than its clusters’ sum with workloads: each cluster pulls and unpacks its own images (the image store is per cluster), and the guest’s page cache stays in the footprint until macOS asks for it back.
Limits
Section titled “Limits”- No authentication on the API (it listens on 127.0.0.1 only; inside the VM or sandbox, on the cluster’s cell, and for multi-node clusters also on its cell address, which only the other cells reach).
- hostPorts are not exposed on the host’s localhost (NodePorts are); rootless cannot bind host ports below 1024.
- macOS: the first boot formats the data disk (~5 s); DHCP from the framework’s NAT occasionally takes 2–3 s when no other VM has run for a while.