Unikernel Target: Serving HTTP Without an Operating System

· 8 min read

Orisha — a real HTTP framework, with routing, static assets and a request parser, not a demo written for the occasion — answers requests inside a Unikraft unikernel. No Linux underneath, no syscall shim, no binary-compatibility layer: a 559,656-byte image that boots straight into the program and serves in 6 MB of RAM. Three requests, three responses, keep-alive held.

The interesting part is not the port. It is what the port found — ten defects in the compiler, and then two places where the framework itself turned out to be asking an operating system for something.

A portability layer is the thing that does not port

Orisha’s server was written on Zig’s std.net, which exists precisely so a program need not care which operating system it runs on. That is exactly why it cannot follow a program off the operating systems it knows.

On x86_64-freestanding it is not a missing feature but a hard compile error: std.posix has no sockaddr there, so nothing above it can even be named. The sockets themselves are present the whole time — posix-socket and lwip put socket, bind, listen and accept in the image as ordinary C symbols in the same address space. The capability was never absent. Only the abstraction over it was.

So the layer that promises to hide the platform is the layer that must be written per platform. That inverts the usual instinct — write against the portable API and the port is free — and it produces a specific shape: name the seam, let each platform supply its own body, and let everything above the seam be the code written once.

One declaration, three bodies

Orisha’s loop is a single event with an effect arm that fires once per request:

pub tor run { port: u16 }
! arrived *Exchange
| stopped string
| failed string

Three implementations satisfy it — run|zig on kqueue for macOS, run|epoll for Linux, run|unikraft for a machine with one core, one loop and nothing to multiplex onto. The unikernel body is the shortest of the three, because a readiness mechanism buys nothing where a single exchange is ever in flight.

Everything above the seam is written once, and never mentions a platform:

pub tor serve { port: u16 }
| shutdown string
| failed string

serve =
    orisha/pump:run(port)
    ! arrived x |> orisha/pump:raw(x): raw |> orisha:answer(raw): a |> orisha/pump:reply(x, a.head, a.body)
        | sent |> _
        | broken _ |> _
    | stopped s -> shutdown s
    | failed e -> failed e

Parsing, routing and response shaping sit in answer, which takes bytes and returns bytes and never names a file descriptor. Choosing a platform is one block:

[build(unikraft)]std/build:variants {
    "orisha/pump:run": "unikraft",
    "orisha/pump:reply": "unikraft"
}
| configured _ |> _
| skipped _ |> _
| invalid-event _ |> _

What the port found

Ten compiler defects, and not one was found by looking for bugs. Each surfaced because a real program did something no test had asked for — and the useful pattern is that most of them failed silently or blamed the wrong thing.

An effect arm and a platform variant could not coexist. Each variant is emitted as its own function, and only one of them inherited the arm — so the first program to want both got a Zig error naming an identifier the author never wrote.

A selected platform never ran. When an event’s body is spliced into its caller rather than called, the splice asked for the default body and never consulted the registry. No error, no warning: the wrong platform’s loop simply executed. It became visible only on a target where the default body cannot compile, and even then the message named a macOS-only call the program does not make.

A platform choice for anything in a sub-folder had never worked at all. The selection key is written the way the program imports the module — orisha/pump:run — while the compiler had already canonicalised that path to dots. Compared byte for byte, they never matched. The registration reported its failure through an outcome branch that every documented example discards, so the line selecting Orisha’s Linux loop had been dead since it was written.

Two bugs wore one error message. A package reached as a directory and as its own index file was loaded twice, so everything it declared was emitted twice. Underneath that, a module nested inside another could not write the const std = @import("std") that every host-touching Koru file writes, because Zig containers do not shadow. Both produced the same sentence, and fixing either alone leaves it on screen — which is why it read as unfixable for a night.

And two in the emitter. A value produced from inside a branch arm was wrapped in the name of an outcome that did not exist, giving a field access with an empty name — and, in the same line, a Koru record copied through untranslated, because the wrapper had swallowed the one path that lowers it. Separately, a route table that replaces a handler left behind a cleanup line referring to a variable that was never created.

The pattern worth naming

Four of the ten are the same shape: several places in the compiler answer one question, and only one of them was kept current.

Five separate emitters build a returned value. Roughly fifteen write a function call, and exactly one consults the variant registry. A guard against selecting the wrong body already existed — for the older of the two ways a variant can be chosen, written before the second way existed, and never revisited.

Teaching one copy is indistinguishable from teaching none. It looks like progress, the tests you happen to run still pass, and the defect waits for the first program that reaches a different copy.

What the floor is made of

Six megabytes, and none of it is Koru. Below that the virtio driver cannot allocate its virtqueue — -12, out of memory — lwIP then fails to attach the device, and the program’s own failed arm reports that it could not open a socket. Which is exactly true: with no interface there is no socket.

Nothing crashes and nothing lies. The image reports the first call it could not satisfy, in the vocabulary the program was written in. The same image printing instead of serving floors at 2 MB, so the network stack costs about 4 MB of RAM on top of its ~391 KB of binary.

What the framework itself assumed

For a day this post described a port that had not finished. The compiler was done — koruc main.kz --build=unikraft emitted clean — and the freestanding link failed on two things, both of them Orisha’s own, neither the compiler’s and neither Unikraft’s.

It asked what time it was, through a header. The Date header every response carries came from @cImport(@cInclude("time.h")), and a freestanding target has no libc headerserror: 'time.h' file not found. It has the symbol, in the image, at link time. So the include became one extern fn time, and gmtime_r + strftime became the calendar arithmetic written out. Three symbols to find became one, on a target whose libc may have a clock and little else. The format is fixed by RFC 9110 anyway; there was never anything for strftime to decide.

It asked the kernel for memory. Every request carried std.heap.page_allocator, which reaches std.posix for mmap — and on x86_64-freestanding, std.posix has no MAP and no MREMAP to reach for. That one is worth pausing on, because it is not a missing symbol. It is a missing machine. There is nothing to ask.

So the allocator became a seam, the same shape as the loop: a hosted build keeps the page allocator, a freestanding build gets a fixed arena carved out of the image and reset once per exchange — sound for exactly the reason the response header buffer beside it is sound, because a pump delivers one exchange at a time. Nothing above the seam changed. answer still never names a platform.

Which is the same lesson a third time. The parts of a program that assume a host are never where you expect them — they are in the date header and the allocator, not in the networking you went looking at — and they do not announce themselves until something refuses to give them one.

What it does now

$ curl -i http://127.0.0.1:18335/ HTTP/1.1 200 OK Content-Length: 39 Content-Type: text/plain Date: Sun, 09 Aug 2026 14:52:40 GMT Connection: keep-alive

Hello from Orisha, inside a unikernel.

Three requests, three responses, keep-alive held, and the date computed by the arithmetic above rather than asked of an operating system that is not there.

The image is 559,656 bytes. It serves at 64 MB, 16 MB, 8 MB and 6 MB; at 5 MB it does not come up, and the reason is the same one the print-only image had — the virtio driver cannot allocate its virtqueue (-12, out of memory), lwIP then cannot attach the device, and the program’s own failed arm reports that it could not open a socket. Which is exactly true. With no interface there is no socket. Nothing crashes and nothing lies: the machine names the first call it could not satisfy, in the vocabulary the program was written in.

One thing that does not work, found while doing this and not yet fixed: implementing the handler as a ~proc orisha:handler|zig compiles, links, boots — and silently does not attach, so the server answers with Orisha’s built-in 404 instead of your body. On both targets. Written as a record return it works first time.