This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / blog / posts / spindle-microvm.md
17 kB 369 lines
1--- 2atroot: true 3template: 4slug: spindle-microvm 5title: Spindle's new microVM engine 6subtitle: How we built the new QEMU-based microVM engine 7date: 2026-06-16 8image: https://assets.tangled.network/blog/microvm.png 9authors: 10 - name: dawn 11 email: dawn@tangled.org 12 handle: ptr.pet 13--- 14 15Spindle gains a second engine: `microvm`. Each workflow gets its own little 16virtual machine, a whole real environment you can do anything inside. It's an 17upgrade from the Nixery engine while staying fully compatible with it, so if you 18already have a working Nixery workflow, just change `nixery` to `microvm` and it 19will work! 20 21The interesting part is NixOS images: you configure the machine directly from 22the workflow file. A few things you can do: 23 24You can bring services up: 25 26```yaml 27services: 28 postgresql: 29 enable: true 30 ensureDatabases: ["spindle-workflow"] 31 ensureUsers: 32 - name: spindle-workflow 33 ensureDBOwnership: true 34``` 35 36You can build Docker containers: 37 38```yaml 39virtualisation: 40 docker: true 41steps: 42 - name: "do the thing!" 43 command: docker build ... 44``` 45 46And you can use non-NixOS images too: 47 48```yaml 49image: alpine 50steps: 51 - name: install golang 52 command: apk add go 53``` 54 55It's quick on the second run, too, because it caches aggressively: your 56dependencies, your services, and any other Nix derivation built inside the 57microVM get pushed to spindle's Nix cache, so the next workflow that needs them 58doesn't rebuild those. More on that [below](#the-nix-cache-both-ways). 59 60And like everything else in tangled, the whole thing is self-hostable, so you 61can run your own spindle with the microVM engine on your own hardware (see the 62[self-hosting 63guide](https://docs.tangled.org/spindles.html#self-hosting-guide)). If you want 64fuller examples, there are [recipes in the 65docs](https://docs.tangled.org/spindles.html#recipes) too. 66 67## What's in a microVM 68 69A microVM is just a VM with most of the boring parts removed. There's no BIOS, 70no PCI bus to probe, no emulated graphics card, none of the slow legacy stuff a 71normal QEMU machine drags along. You get virtio devices and not much 72else, which means it boots very quickly and uses very little memory. Right now 73QEMU is the only runner we support, but the engine is written so that other 74runners (firecracker for example) can slot in later. 75 76Inside the guest there's a small piece of software we call the agent. Spindle 77never SSHes in or runs commands "from the outside"; instead the agent dials back 78to spindle over vsock the moment it boots, says hello, and from then on every 79step of your workflow is sent to it as a message. The agent runs the command as 80an unprivileged user, streams stdout and stderr back, and reports the exit code. 81The host side of this lives in 82[`spindle`](https://tangled.org/tangled.org/core/tree/master/spindle/engines/microvm/agent.go) 83and the guest side is a little Rust binary called 84[`shuttle`](https://tangled.org/tangled.org/core/tree/master/shuttle). 85(`shuttle` implements 86[`agentproto`](https://tangled.org/tangled.org/core/tree/master/spindle/) which 87is the protocol used by `spindle`. Technically speaking anyone could implement 88this and, assuming side effects hold, you could have your own agent!) 89 90![](https://assets.tangled.network/blog/microvm/diagram1.png) 91 92## Two kinds of images 93 94There are two "flavours" of image you can boot, and they're aimed at fairly 95different people. 96 97The first is **NixOS images**. These are the interesting ones: because the whole 98guest is built with Nix, you can configure it from your workflow file directly. 99Things like `dependencies`, `services`, `virtualisation` (e.g. Docker), 100`registry` and `caches` are all written right there in the YAML, and the guest 101agent builds and activates that config before any of your steps run. If we've 102built that exact base plus config before, spindle can just hand the guest a 103store path to realize (fetching from whatever cache `spindle` has configured) 104instead of rebuilding it, so the second run is quick. 105 106The second is **non-NixOS images**, which today just means Alpine, but can be 107anything. You don't get the workflow-level NixOS config here (there's no NixOS 108to configure), but if Nix happens to exist inside the image, like it does in our 109Alpine one, it can still talk to the spindle Nix cache just fine. 110 111## An example NixOS workflow 112 113If you've used spindle before, this will look familiar: it's the same manifest 114you already know, just with a few extra keys that the NixOS image understands. 115Here's a workflow that needs Postgres to test against and Docker to build an 116image: 117 118```yaml 119# .tangled/workflows/test.yaml 120engine: microvm 121 122when: 123 - event: ["push", "pull_request"] 124 branch: ["master"] 125 126image: nixos 127 128dependencies: 129 - go 130 - github:nixos/nixpkgs#hello 131 132registry: 133 nixpkgs: github:nixos/nixpkgs/nixos-unstable 134 135caches: 136 https://nix-community.cachix.org: "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=" 137 138services: 139 postgresql: 140 enable: true 141 ensureDatabases: ["spindle-workflow"] 142 ensureUsers: 143 - name: spindle-workflow 144 ensureDBOwnership: true 145 146virtualisation: 147 docker: true 148 149steps: 150 - name: run tests 151 environment: 152 PGHOST: /run/postgresql 153 command: | 154 docker build -t app . 155 psql -c "select 1" 156 go test ./... 157``` 158 159The new keys each do one job: 160 161- **`dependencies`** are the packages your steps get to use. They go into a 162 `mkShellNoCC` devshell that every step sources before it runs, so you get the 163 whole stdenv environment (setup hooks like `pkg-config` wiring up 164 `PKG_CONFIG_PATH`, etc.) and not just the bare binaries. That means you can 165 use a dependency like `openssl` and compile the `openssl-sys` Rust crate 166 without pain! A bare name like `go` is looked up in nixpkgs (same as Nixery), 167 but you can also point at any flake with the `flakeref#attr` syntax, so 168 `github:nixos/nixpkgs#hello` pulls `hello` straight out of that flake. 169- **`registry`** is how you remap the global refs. Here we pin `nixpkgs` to 170 `nixos-unstable`, so now the bare `go` above resolves from unstable. You can 171 alias your own flakes the same way (`myflake: github:me/x`, then 172 `myflake#tool` in `dependencies`). 173- **`caches`** is a map of binary cache URL to its trusted public key. They get 174 wired into the read proxy (more on that just below), so the guest can 175 substitute prebuilt paths from them instead of building everything from 176 scratch. 177 178`services` and `virtualisation` are the interesting parts: they're passed 179straight through to NixOS, so anything you could write in a NixOS config you can 180write here. `services.postgresql.enable` brings Postgres up before any of your 181steps run. 182 183Since steps run as the `spindle-workflow` user, naming a database after that 184user with `ensureDBOwnership` is the easy path to a working DB -- Postgres peer 185auth maps the unix user straight to the matching role, so `psql` connects over 186the socket with no password and no extra setup (this name-matching is a NixOS 187requirement for `ensureDBOwnership`, if you want a differently named DB you'd 188grant access yourself). 189 190`virtualisation.docker: true` is shorthand for `virtualisation.docker.enable = 191true`, which gets you a real Docker daemon inside the VM. By the time your first 192step runs, Postgres is listening and the Docker socket is there, no sidecar 193dance, it's just part of the machine. 194 195(`true` works as shorthand for `.enable = true` anywhere an `enable` option 196exists, so most "just turn this on" services are a one-liner!) 197 198## The architecture 199 200![](https://assets.tangled.network/blog/microvm/diagram2.png) 201 202### Nix cache, both ways 203 204Spindle talks to its Nix cache through two proxies that run on the host, so the 205guest never needs credentials or direct network access to reach it. Like the 206agent, they use vsock to talk to spindle. 207 208The read proxy fans out to the configured substituters plus any caches you 209listed in your workflow, so when the guest needs to realize a store path it asks 210the proxy and the proxy fetches it. The request is sent concurrently to the read 211caches, so the one that answers it first wins. 212 213The upload proxy goes the other way: any path built inside the guest gets pushed 214back out to spindle's Nix cache (if one is configured), so the next workflow 215that needs it doesn't have to build it again. Any paths that already exist on 216any of the configured read caches won't be uploaded. As the agent reports 217built paths, they're queued and uploaded in the background while the rest of the 218workflow keeps running, so uploads overlap with work instead of blocking it. If 219any are still in flight when we reach VM teardown, the workflow waits until 220everything has drained. 221 222Spindle can be configured to use `http`, `ssh-ng` or `ssh` URLs as a binary 223cache to upload to, so for example, `ssh-ng://localhost` would just upload to 224the local Nix store on the machine that the spindle runs on! `ssh-ng` and `ssh` 225require Nix to be present in PATH so that the spindle can use `nix copy` to 226upload to them, but if you are using a binary cache that supports `http` (for 227example, [ncps](https://github.com/kalbasit/ncps)) Nix does not need to be 228present. 229 230### Building the images 231 232Image builds are done with Nix. For NixOS we lean on 233[microvm.nix](https://github.com/microvm-nix/microvm.nix) and layer our own bits 234on top (stripping down kernel modules, configuring users, etc.). For Alpine 235there's a smallish Nix definition that fetches the kernel, the initrd and the 236kernel modules, sets up an init script that configures the machine on boot, 237copies in the dependencies we want (`nix`, `git`, etc.) and compresses the whole 238rootfs into a squashfs. 239 240None of this *has* to be Nix, though. As far as spindle is concerned an image is 241valid as long as a few things hold: a guest agent (that implements `agentproto`) 242is present and gets started on boot, a `spindle-workflow` user exists, and the 243work directory is set up at `/workspace`. That can be built however you like. 244 245### Finding an image 246 247Every built image ships a `spec.json` next to its artifacts. The spec is the 248whole contract: where the kernel and initrd and read-only store disk live, the 249boot args, how much memory and how many vCPUs to give it, the shell to run steps 250in, the writable volumes, the network interfaces, and the runner-specific knobs 251(machine type, CPU, extra QEMU args). NixOS images also carry a `baseConfigHash` 252identifying the base config baked in (this is the hash of 253`nixosSystem.config.system.build.toplevel.outPath`). 254 255A workflow picks an image with the `image` key at the top level. The name is 256matched literally against what's on disk, we look for a directory called 257`<name>` with a `spec.json` in it, then fall back to a flat `<name>.json`. The 258nice property here is that resolution depends *only* on the name and what's on 259disk, never on the host doing the resolving, so the same workflow resolves to 260the same image on every spindle. If an operator keeps multiple arches side by 261side they can name them `nixos-x86_64`, `alpine-aarch64` and so on (that suffix 262is just part of the name, it's not handled specially). If you want, for example, 263`nixos` to work, you can just symlink `nixos` to `nixos-x86_64`. 264 265Right before launch we double-check the referenced files actually exist 266and that the host has the tools we need: `mkfs.ext4` for the volumes, the 267QEMU binary for the spec's arch, `/dev/kvm` and `/dev/vhost-vsock`, plus 268the `ip` / `mount` / `slirp4netns` / `unshare` toolchain if the image 269wants networking. 270 271### The life of a workflow 272 273A workflow moves through a handful of stages: it gets parsed and its 274image resolved, it waits for a slot, it gets set up, its steps run, and 275then everything is torn down. 276 277The waiting bit matters a lot. Each image declares how much memory, how many 278vCPUs and how much disk it needs, and a workflow has to acquire a slot from a 279resource scheduler before anything boots. The scheduler is work-conserving with 280aging and per-user fairness, so one person submitting a hundred jobs won't 281starve everyone else, and slots don't sit idle if there's work that fits in the 282budget. 283 284Once a slot is acquired, we do the setup. Spindle allocates a random vsock CID 285for the guest and registers it with the agent hub. It creates the per-workflow 286work directory, starts the two cache proxies (described earlier), a DNS proxy 287that resolves through the host and filters out private/special-use addresses, 288then creates the VM: writable volumes become sparse files formatted ext4, the 289store disk is attached read-only, and QEMU is started with `-sandbox on`, 290`-nodefaults`, no display, no monitor, etc. with serial (on boot) / 291`virtio_console` output to a log file and a QMP socket for control. 292 293Then we wait for the machine. We poll QMP until QEMU says the guest is running, 294then wait for the agent's handshake to arrive over vsock from the CID we expect. 295The agent tells us its protocol and versions, and spindle sends back the job id, 296the trusted cache public keys, and the cache and DNS proxy ports. From there 297steps run one at a time as `$shell -lc <command>`, as the unprivileged workflow 298user in `/workspace/repo`, with the right environment and any unlocked secrets. 299If the workflow activates a NixOS config and we've already built that exact base 300plus config, the activation step can realize a cached toplevel store path instead 301of rebuilding. Either way, whether it's building the config fresh or pulling a 302cached toplevel down, that output streams straight into the activation step's log 303as it happens, so you can watch the closure come in instead of staring at a blank 304screen wondering if anything's happening. 305 306Timeouts are cooperative: we work out a deadline from the workflow timeout 307and send it to the guest, with a little grace on our side so the guest 308gets a chance to report the timeout itself rather than us just yanking the 309machine out from under it. And if the VM crashes mid-step we tail the 310serial and QEMU logs into the step's stderr, because "guest agent 311connection lost: EOF" is a genuinely useless thing to read at 2am... 312 313Teardown is the same whether the workflow passed, failed or timed out: 314drain any pending Nix cache uploads, ask the agent to power off, wait for 315QEMU to exit (falling back to a QMP `system_powerdown`, and finally a 316kill if it's being stubborn), then close the proxies and remove the work 317directory. 318 319### Locking down the network 320 321A VM that can reach the host's local network is a VM that can reach things it 322has no business reaching. So QEMU doesn't run in the host's network namespace at 323all. We `unshare` into fresh user, net and mount namespaces first. Inside that 324namespace a small wrapper bind-mounts a resolv.conf pointing at `127.0.0.1` so 325that QEMU's built-in slirp DNS isn't used, then installs blackhole routes for 326every special-use IP range (RFC 6890, so private networks, link-local, loopback, 327etc.) before it execs QEMU. `slirp4netns` then provides the namespace's outbound 328internet connection, with `--disable-host-loopback`, sandbox and seccomp all on. 329QEMU runs *inside* that namespace, and the guest's network card is attached to 330QEMU's own built-in user-mode networking. So every packet from the guest takes 331two hops: guest → QEMU's slirp → the namespace's `slirp4netns` → the internet. 332The guest never sees the host's network and the host's network never sees the 333guest. All of this is done without needing any privileges! 334 335Guest DNS doesn't use either slirp layer. The guest's `/etc/resolv.conf` points 336at shuttle on `127.0.0.1:53`, and shuttle forwards DNS packets over vsock to 337the host-side DNS proxy. That proxy resolves through the host's real resolver 338and strips any answers that point at private or special-use addresses, so guest 339traffic can only ever reach the outside world, never the host or anything on its 340local networks. 341 342### Budgets and cgroups 343 344The scheduler's budget is bookkeeping on its own, it tracks what it's handed 345out, and the runner (QEMU) will ensure that a workflow only gets those. But 346optionally the whole thing (QEMU and slirp4netns both) gets placed in a 347per-workflow cgroup with memory, swap etc. limits, which is an extra enforcement 348layer on top, considering QEMU and slirp4netns themselves also use resources. A 349nice side effect is that when the cgroup OOM-kills the VM we can see that it was 350an OOM and report it as such, instead of surfacing it as a generic crash and 351leaving you guessing. 352 353The spindle itself also gets a cgroup with `memory.min` set, which means that in 354a host OOM situation, it should be the workflows that die first, not the spindle 355itself. 356 357## On the roadmap 358 359A few things that are coming next: 360 361- [firecracker](https://github.com/firecracker-microvm/firecracker) runner 362 support. QEMU microVMs are good and all, but firecracker VMs are more 363 efficient to run concurrently and are leaner overall. 364- ssh-on-fail: when a workflow fails, you should be able to ssh in to debug why. 365 This can be really useful in situations where you need just *a little* bit 366 more info if something unexpected fails so you don't sit around there running 367 the workflow 10 times over. 368 369Feel free to come and ask any questions you might have on https://chat.tangled.sh!