Skip to content
pilots
Dashboard

← All notes


Vivek

  • builds
  • buildkit
  • security
  • multi-tenancy

No Host Runs a Build Daemon

A RUN step in a Dockerfile is arbitrary customer code. On Pilots it executes inside a Firecracker microVM like every other workload, driven by buildctl from the host, with no build tier and no daemon sharing the host kernel.


A RUN line in a Dockerfile is somebody else's code, running with network access, on your hardware. That is the whole job description of a build.

Which makes it strange how often builds are treated as infrastructure rather than as workload. A rootless container running a build daemon shares the host kernel with every other tenant's machines. It is a smaller target than a privileged one, and it is still a kernel-sharing execution path for code you have not read, sitting next to the isolation boundary you spent all your effort on.

On Pilots a build runs inside a Firecracker microVM, because everything runs inside a Firecracker microVM. No host runs a build daemon.

How a build actually executes

POST /v1/builds with a context. The host that received the request creates or wakes that org's builder machine on itself. Then buildctl on the host drives the BuildKit daemon inside that guest, over the network slot's tap address.

The builder is not a special tier. It is an ordinary machine, created from a builder-<vendor> template, idle-suspended after 300 seconds and destroyed after a day of being suspended. It has the same lifecycle knobs as anything else, because it is the same primitive as everything else.

No host looks up another host's builder. No request is forwarded to find one. The GitHub push path picks its host with the same hash(repo) mod live_hosts the rest of the system uses for deterministic ownership, and then acts locally.

Fly arrived at the same conclusion from the other direction: their builder is a Machine in the customer's own org, and the Depot builders that replaced it run as Fly machines too. What we deliberately did not copy is a build tier. There is no fleet of builders to size, scale, or pay for while idle.

The guest never gets a credential

buildctl streams the context in and the resulting tarball back out over its own session. That one detail decides a lot of the security story.

The guest never receives object storage credentials. It never reaches the bucket. It needs no push path of its own, no token, and no route to anything but the host that is driving it. Everything after the exporter happens on the host, which is the party that already had those credentials.

So the blast radius of a malicious Dockerfile is the builder VM it ran in, which is disposable, which is destroyed after the build. The fleet battery includes a section that kills a builder machine mid-build precisely to assert that: the wreckage belongs to the guest, and nothing of it survives on the host.

From a tarball to a bootable disk

The step after the build is the part that is unusual, because we do not want an image. We want a filesystem.

BuildKit's tar exporter already emits the flattened filesystem, so there is no layered image to unpack. mke2fs -d turns that tarball directly into an ext4 disk, which is then chunked as a generation-0 template build and uploaded.

Taking the tarball directly, rather than unpacking it to a directory first, is not a shortcut. mke2fs -d reads uid, gid and setuid bits straight out of the tar headers. An unprivileged unpack loses all three, and you end up with a root filesystem where nothing is owned by root, which fails in a hundred small ways starting with the first thing that tries to be a system service.

That path needs e2fsprogs built with libarchive, which is probed at startup. Where it is missing, the fallback is a fakeroot extract-and-pack in one session, which preserves the same three things through a different mechanism.

The fixups Docker does and a kernel does not

A container gets things from its runtime that a VM has to be given.

/etc/resolv.conf is the clearest one. It cannot be written from a Dockerfile at all, because BuildKit bind-mounts over it during the build. So the fixups are appended to the tarball after the export: resolv.conf, the guest agent plus its placeholder token, and an init.

That last one is the interesting case. Most real base images carry no init at all. node:alpine, python:slim, anything distroless. A container does not need one, because the container runtime is PID 1's supervisor. A VM does need one: something has to mount the pseudo-filesystems, remount the root read-write, and reap orphans.

So for those images /sbin/init becomes the guest agent itself, which does exactly those three things before it starts serving. Images that do ship systemd keep systemd, and the agent runs as a unit with systemd-networkd-wait-online masked.

The layer cache is per org, and that was a fix

The build cache is keyed server-side on the Dockerfile's content hash, so a client passes no cache name at all and cannot address another tenant's entries by guessing.

It is per org. The daemon exports and imports it through the buildctl session into a per-org directory on the host, and hostd mirrors that directory to object storage under the org's own prefix, with hostd's credentials rather than the guest's.

The version this replaced was a fleet-wide cache written by the host daemon with bucket credentials, and it had a real hole in it: with those credentials an org could write any manifest under any key, and the next org whose Dockerfile hashed to the same value would import it. A build cache is an execution primitive, not a read-only convenience, so cross-tenant writes into one are cross-tenant code execution with extra steps.

What survives from the shared version is a read-only platform-owned seed that only hostd ever writes. That is what keeps the first deploy of a new webjs app warm on any host, since every app built from the same scaffold Dockerfile shares that partition. Object storage is the truth and the host's copy is a cache, so wiping a host's cache directory costs one download and never a rebuild.

What you get out of the end

A running service behind a health gate, with the old release still alive until the new one answers, and structured NDJSON build logs the whole way through so that a failing step is machine-readable rather than a wall of terminal output.

That log format exists because of who is reading it. An agent that can parse {step, stream, line, ts} can find the step that failed, patch the Dockerfile and retry, which is the loop the whole deploy path is designed around. A human reads the same lines fine. An agent reads them without regular expressions over console colour codes.


Every note, or install the CLI and try the thing this one is about.