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