Blog

Containers without an operating system

Part 2 of 3 · Mateusz Mysiak ·

The same question from DHH's keynote, one layer down: why does a web server's container hold an entire Linux distribution? Ours held Debian because that's where the runtime came from. Once the server was a single Rust program, it didn't need one.

What was inside before

Counting only the base images the old containers were built on, before Bun, Tesseract and the app were added:

BeforeToday: web · proxy · photo reader
Files in the image2,857 or more99 · 1 · 60
Programs and libraries658 or more1 · 1 · 58
Shells50
Package managers20
Setuid programs80

In megabytes, image by image. The photo VIN reader used to live inside the web app image and now has one of its own, so the fair comparison is the whole set:

Container imageBun, beforeRust, today
Web app303 MB: Bun on Debian 166 MB, Tesseract with its language data about 97 MB, the app and its packages about 40 MB16.7 MB: the server binary 14.8 MB, the site's own files 1.9 MB
Proxy166 MB: the same Bun on Debian, running a small script4.2 MB: one binary
Photo VIN readerinside the web app image85 MB: Tesseract, the libraries it needs and its English language data
All images469 MB in two images (303 MB on disk, since they share the Bun base)106 MB in three images

Someone who managed to run code in the old container landed in a full Debian system with a shell and a package manager. Today the web image is one program plus the site's static files. There is nothing to run and nothing to install.

How a Rust program runs with nothing around it

  • Static linking. The server is built for musl, so it carries everything it needs, including SQLite compiled in from C.
  • No certificate bundle. TLS uses rustls with the trusted root certificates compiled into the binary.
  • No time zone files. I checked that nothing reads them rather than assuming it.
  • Read-only, no privileges. The container runs as a numeric user with a read-only filesystem, no Linux capabilities and no writable scratch space beyond its data folder.
  • The photo VIN reader moved out. Reading a VIN from a photo needs Tesseract, a large C library. It now runs in its own container on a network with no route to the internet, so the web image carries none of it.

System calls: allow what's used, refuse the rest

Containers normally get a generous default list of kernel system calls: 374 in Podman's default profile. I recorded what each of our programs actually calls and turned those recordings into allowlists. Everything else is refused before it reaches the kernel.

ContainerSystem calls allowed
Podman's default374
Web app90
Photo VIN reader78
Proxy73
Cloudflare tunnel71

The building blocks of most container escapes, such as ptrace, mount, unshare, keyctl, bpf, io_uring and userfaultfd, are refused in every one of them.

The price of going static

The first measurement on the live site showed something odd: a listing page answered 299 requests per second one at a time, but only 113 per second twenty at a time. Three locks were serializing the work. musl's memory allocator takes a global lock, so the server now uses mimalloc. SQLite's memory statistics take a global mutex on every allocation, so they're switched off at build time, with a test that fails if a build ever turns them back on. And the database schema check ran on every new connection, so it now runs once. After the fix the same page served 4,883 requests per second, twenty at a time. The cost is memory: about 300 MB in use instead of 166 MB.

How far can you go? No operating system at all

A container still shares the host's kernel. The logical next step is a unikernel: the program compiled together with a minimal kernel into one bootable image, with no other processes at all. I tried it. The same server binary and data ran under Nanos in a QEMU virtual machine on my own PC, compared with the same binary as a normal Linux process on the same CPU core.

Page, one request at a timeUnikernelLinux process
Home page0.56 ms0.36 ms
Specs catalog (850 KB)0.73 ms0.14 ms
Static file0.07 ms0.03 ms
Decode a VIN2.76 ms3.97 ms

It booted in about a tenth of a second and served every page correctly. VIN decoding, which reads a large database heavily, was even 1.4 times faster. Everything that sends a lot of data was slower, because each response crosses a virtual network card. It used about 50% more CPU per request and twice the memory. And with two CPUs it hung as soon as two large responses went out at once. Most rented cloud servers can't run virtual machines inside them either, so each part would need a server of its own.

So VINlist stays on containers with nothing inside them. If stronger isolation becomes worth paying for, it will come from dedicated hardware with a small virtual machine per component. Not from a unikernel, at least not until I've figured it out and it's worth it.

Which left one operating system still worth worrying about: the server's own. It ran Debian with 417 packages I had installed and set up one by one. After emptying every container, that felt like cleaning the whole house and leaving the garage full of junk. Part 3 clears out the garage: a system that can't be quietly changed, undoes its own bad updates, and moved to a new server in 61 seconds.

All articles

Update available

A newer version of VINlist is ready.

This tab was opened before the update, so it is still running the older version.

Refreshing now will discard anything unsaved — a listing you’re writing, or filters you’ve set.