A Container Stack, All the Way Down: What It Took to Replace Colima in a Homemade Language

A VMM on Hypervisor.framework, a daemon that speaks the Docker Engine API, an OCI runtime, a registry, tar and gzip — written in a small ML-family language of my own, until the real docker CLI connected to it unchanged. What that maps to in published specifications, what it deliberately does not do, and the bugs that only showed up when something real was asked of it.

merecontainersdockerocivirtualizationsystemsdogfood

On a Mac, docker is a client talking to a daemon that is not on your machine. Something has to run a Linux kernel for it: Docker Desktop, or Colima, or Lima underneath them. I replaced that whole column — the virtual machine monitor, the engine, the runtime, the registry, the archive and compression formats — with programs written in Mere, a small ML-family language I am building.

The measure of done was fixed from the start and never moved: the real docker and docker compose, unmodified, have to be the client. Not a client I wrote. Not a compatibility shim. The binary Docker ships, pointed at a socket, getting the answers it expects.

It does that now. docker compose up brings up a two-service application inside the VM, the services find each other by name, a published port answers from macOS, docker exec -i pipes a script into a shell, docker push sends an image to a private registry and the real docker pulls it back out and runs it.

“I wrote Docker” is wrong in both directions

It overclaims. Docker is a CLI, BuildKit, containerd, Desktop, and an ecosystem. I did not write the CLI — and not writing it is the point. The client is the oracle. A client I wrote would only prove that my reading of the protocol agrees with my writing of it.

It also underclaims. Docker does not contain a VMM. On macOS something else supplies the Linux machine, and that is in here too — as is the language it is all written in.

The sentence that lands is: I set out to replace Colima, and wrote everything underneath it.

The stack, in published specifications

The interesting property of this project is not that it works. It is that almost every layer of it is a published specification, and there is exactly one vendor API in the whole column.

Layer What I wrote The specification Scope, and what is deliberately absent
Client (nothing) The real docker / docker compose is the customer and the oracle
Engine API mengd Docker Engine API (announces v1.54, min 1.40); HTTP/1.1 with chunked bodies and Connection: Upgrade hijacking images, containers, exec, networks, volumes, build, events, auth. No BuildKit — the classic builder only
Images mengd + mtar OCI Image Spec v1.1.1 (vnd.oci.image.{index,config,layer}) and Docker manifest v2; the whiteout convention (.wh., .wh..wh..opq) Manifests, configs, layers and whiteouts in both directions. The manifest type follows the layers
Registry mreg (server) + mengd (client) OCI Distribution Spec v1.1.1, bearer-token auth, Basic (RFC 7617), TLS Pull and push. Blob upload is the two-step form
Runtime mrun OCI Runtime Spec (config.json) namespaces (including joining one by path), mounts, pivot_root, process/env/cwd, capabilities, masked and read-only paths, cgroupsPath and resources. No hooks, no seccomp, no LSM, no uid mapping
OS facilities Linux: cgroup v2, overlayfs, namespaces, rtnetlink, bridge ioctls, RFC 4193 unique-local addresses Used, not implemented. Claiming otherwise would be a lie
Devices mvm VIRTIO v1.3 (mmio transport, VIRTIO_F_VERSION_1 only, block with one queue, vsock with three) Indirect descriptors are not offered, and neither is vsock SEQPACKET — what you do not offer, a driver cannot ask for
Boot mvm + mkdtb Booting AArch64 Linux, the Devicetree Specification (generated, not compiled), ARM PSCI GIC, PL011, PL031, and the two PSCI calls that end a machine
CPU mvm Apple Hypervisor.framework — the only vendor API in the column
Formats mtar / mgz POSIX ustar (IEEE 1003.1); RFC 1951 DEFLATE and RFC 1952 gzip tar reads and writes; PAX, GNU long names and base-256 sizes are refused by name. gzip goes both ways — the compressor is mine too

Two things in that table are not open standards: Apple’s Hypervisor.framework, and the Docker Engine API. The second is Docker’s own, published and versioned — and note where it sits. I am not implementing a client for it. I am answering a real one.

What it is made of

Component Equivalent Mere C shim Checks
mvm the VM part of Lima/Desktop 1,945 909 1,513 lines, 10 gates
mengd dockerd 4,933 1,502 1,256 lines, 5 gates
mrun runc 767 390 a runc oracle
mreg distribution 634 138
mtar / mgz tar / gzip 1,739 254 962 lines, 11 gates

The C is only what the FFI boundary demands: sockets, netlink, ioctls, setns, waitpid, OpenSSL. Every algorithm — inflate, deflate, tar, HTTP, JSON, the virtio rings, the vsock state machine — is in Mere.

Four decisions that shaped it

The oracle is always somebody else’s program. mrun is compared against runc field by field: the same bundle handed to both, 130 fields across five cases, differences reported individually rather than as a pass or a fail. The layer writer is checked against the kernel itself — mount a real overlay, run a step, and require that merged == apply(lower) then apply-as-layer(upper). The push path is checked by having the real docker pull back what mine sent. None of these ask my code whether my code is right.

A layer is the difference a step made, and I cannot compute a difference — but the kernel can. Build steps run on an overlay mount, and the upper directory is the layer. Nothing had to be invented to store a build cache either: a step that has run before is one whose upper directory is already sitting there. The thing was already on disk under a name nobody had thought to use.

Configure the namespace before the process exists. There are two ways to give a container a network: let the runtime make a namespace and wire it up afterwards, or make it first and hand it over. The first is a race the container can lose — and will usually win, which is worse, because a failure that is rare gets blamed on the network. The OCI spec allows a namespace to be named by path, which is how CNI does it, so the wiring is finished before anything can look at it.

NAT is not missing; it is meaningless here. The guest has no network interface at all — the VMM gives it a block device and a vsock device, and everything in and out goes through the latter. NAT means translating onto an upstream, and there is no upstream. Writing that down, with the three ways to change it and what each would cost, is more useful than a half-built version carrying the name.

Added 2026-09-20: that is true about NAT and wrong about the thing behind it. I was reasoning from a design I remembered instead of asking the guest’s kernel, which turned out to have the netfilter core and to be missing ip_tables, nf_nat and tun — “six modules short”, not “meaningless”. The capability actually wanted is arbitrary outbound TCP, and once the price was visible so was a cheaper way to buy most of it. The sequel has the measurement and what came of it.

Numbers

mvm + mengd Colima
docker run --rm alpine echo 418 / 432 ms 428 ms
docker ps 90 ms 105 ms
guest boot to daemon listening 1.2 s
vCPUs 1 6

One vCPU, and the ordinary operations are at parity. Added 2026-09-20: it is --cpus N now, because one vCPU is not slow — it is slow when something else is running, which is what a web service next to a worker is. With three busy neighbours the same service went from 100 ms to 530 on one vCPU and stayed at 100 on four. Elsewhere: one layer per build step took the last step’s layer from 8,940,544 bytes to 2,048 and then to 116 once it was compressed; the build cache took a two-step build from four seconds to zero; my gzip is 0.6% larger than the system gzip on a real image layer, at 1.3 seconds where my first attempt took 369.

The bugs that only real use could find

Every one of these passed every check I had at the time.

The compressor that compressed nothing. mgz had three checks and all of them asked whether the output was correct: gunzip accepts it, the CRC agrees, the bytes come back. A compressor that emits stored blocks passes all three. Pointed at a real image layer, it produced 10,023 bytes from 10,000 — bigger than the input — because one code-length code wanted eight bits and the whole block fell back to stored. The missing check compares size against gzip.

A key is not a substring. docker compose build sends both t= and target= in its query string, and my parameter lookup found the t= inside target=. The image came out untagged; the build said “Successfully built” with no “Successfully tagged” after it, and compose then failed with “No such image” about the image it had just built. Nothing in the build was wrong.

A daemon that looks dead because it is full. Mere’s spawn returns a handle, and throwing it away leaves a joinable thread behind — the runtime says so, in the source, where detach is defined. I threw one away per connection. Add a /wait that polled for five minutes after the client had gone, and at about thirty containers the runtime would not make another thread and the daemon stopped accepting anything: compose produced no output and the trace showed not one request arriving. Forty containers went from 304 seconds to 14.

Killing a parent orphans a child. The runtime is the parent; the container’s init is its child. docker stop signalled the runtime, and docker rm -f removed the directory — both left the container’s processes running with nobody who knew about them, one per removal, until the machine had more runtimes than containers.

A store write cannot be allowed to raise. In Mere a raise ends the process. write_file into a directory another request is removing ends the daemon, not the write. It died mid-removal with a path as its last word — twice, the second time straight through a file_exists guard that the race stepped over.

And once, the client was the broken thing. compose up --build stopped working, and the daemon was innocent: on this macOS Docker CLI, DOCKER_BUILDKIT=0 docker build hangs forever — against the real docker too. The hang looked exactly like a daemon that had stopped answering. What proved it was not was pointing the same command at a daemon nobody doubts. The gate asks that question first now, with a deadline, and skips by name: a check that cannot tell “the daemon is broken” from “the client is broken” would hang, and a hang says nothing at all.

What it does not do

Said in the vocabulary of the specifications, because a boundary you can name is a design decision and a boundary you cannot is a gap:

  • Outbound NAT — no upstream interface exists to translate onto. Added 2026-09-20: the proxy answers SOCKS5 as well as CONNECT now, so what is actually outside the line is narrower and can be named: anything speaking neither protocol — nc, ping, psql
  • OCI hooks, seccomp, LSM labels, uid mapping — not read from the bundle
  • VIRTIO indirect descriptors, vsock SEQPACKET — not offered
  • PAX, GNU long names, base-256 sizes — refused by name rather than guessed
  • BuildKit, swarm, plugins, TTYdocker exec -t wants a pty in the container, which is a different mechanism again

Why do this at all

It is dogfood. The question was never “can I have my own Docker”; it was whether a language I am designing can carry a real system. The most valuable output is not the stack — it is the list of defects it found in the language: that region reclamation is lexical, so wrapping a call in a region does not reclaim what the callee allocated; that the C backend’s lambda lifter resolves free variables by name, so an inner closure breaks when the importing program happens to bind the same name; that a discarded thread handle leaks; that the arena “pointer” a program holds is an offset, so a shim that treats it as an address writes from somewhere else entirely and reports success.

None of those would have shown up in a test suite. They showed up because something real was asked of it, by somebody else’s client, with somebody else’s program as the judge.

← Back to Notes