cmd / uni
A unikernel is a single program that boots without an operating system. The program links the system drivers it needs, and a hypervisor boots the binary as a virtual machine (VM) guest.
uni boots Go web services this way. The guest serves HTTP over a
TCP/IP stack. It has no Linux kernel, no shell, no package manager,
and no SSH.
Why
I write web services in Go. A Go service compiles to a static binary, but a standard deploy runs a full Linux OS under it. My services need almost none of that operating system.
A standard VM requires hardening: key-only SSH, a firewall, and regular security updates. A unikernel removes that attack surface. The guest runs one binary and exposes one port. If an attacker exploits an application bug, the VM provides no shell and no other process to move to.
Upstream Go patch
uni compiles with the official Go source release (go1.27.3) plus a patch in the uni repository. The patch provides system call stubs and CPU initialization for bare metal execution. This allows uni to follow upstream Go releases without maintaining a runtime fork.
The patch adds a uni_notls build tag. With the tag, crypto/tls is a
stub, so a guest that makes no TLS connections links no handshake or
X.509 code. The blog guest builds with it. Its symbol table fell from
5.1 MB to 3.8 MB, and the stripped ELF fell from 9.4 MB to 7.4 MB.
What the guest links
The guest links gVisor's TCP/IP stack (790 KB) and the Go runtime (580 KB) into the binary.
The uni_notls tag reduces crypto/tls from 250 KB to 1 KB, drops
crypto/x509, and reduces internal FIPS code. A test in uni fails if
go list -deps -tags uni_notls net/http lists crypto/x509 or
crypto/ecdsa. About 130 KB of HTTP/2 code remains in net/http
because Protocols disables HTTP/2 at runtime without unlinking it.
How I run it
One Linux VM runs Cloud Hypervisor and several small guests. Each guest needs 512 MiB of RAM. A gateway on the host terminates TLS and forwards plain HTTP to the guests over a private network. An app can also boot alone on its own VM for isolation.
The host runs Linux for the gateway and the hypervisor. I harden that host machine like any standard VM.
What it does
The guest entry point looks like an ordinary Go HTTP server:
func main() {
l, err := listen()
if err != nil {
slog.Error("listen", "err", err)
os.Exit(1)
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok\n"))
})
slog.Info("listening", "addr", l.Addr().String())
if err := newServer(mux).Serve(l); err != nil {
slog.Error("server exited", "err", err)
os.Exit(1)
}
}
newServer sets three limits before any handler runs: a 10 second
ReadHeaderTimeout, a 64 KiB MaxHeaderBytes, and HTTP/1 only. See
web / server limits.
The build script compiles uni.elf, a 14 MB static binary:
./build.sh # writes uni.elf
./run.sh # boots under cloud-hypervisor in <1s
/healthz answers in about 3 ms.
For cloud providers like Ubicloud, ./build.sh uefi generates a 64 MiB
GPT disk image (uni.img). The VM firmware boots the disk under UEFI,
and /healthz answers in about 3 seconds.
A guest can also embed a static site at build time. This blog runs as a static uni guest. An app guest queries Postgres with its own client, such as pgx.
The guest configures network settings automatically at boot via DHCP and IPv6 router advertisements.
To avoid baking credentials into disk images, the guest fetches secrets
at boot from a metadata service on the gateway host (GET http://10.0.0.2:8080/env). The service identifies the guest by its
source IP on the host tap device.
How it works
uni provides the drivers required to run on virtualized x86-64 hardware: CPU startup, timers, interrupts, and virtio-net.
The network stack integrates gVisor's TCP/IP implementation with Go's
net package:
iface.Stack.EnableICMP()
net.SocketFunc = iface.Stack.Socket
Assigning net.SocketFunc routes standard library network calls into
the in-guest gVisor stack.
Go runs on one virtual CPU. On a multi-vCPU VM, the hypervisor distributes network packets across multiple receive queues. A single-queue driver drops packets sent to other queues.
uni's virtio-net driver configures interrupts across every receive queue:
for _, q := range dev.ReceiveQueues() {
if err := transport.EnableInterrupt(netIRQ, q); err != nil {
return nil, fmt.Errorf("routing receive queue %d: %w", q, err)
}
}
go serviceInterrupts(cpu, dev, iface)
Configuring all receive queues prevents packet loss under concurrent requests on multi-vCPU VMs.
When idle, the Go runtime halts the CPU until the next interrupt instead of spinning:
goos.Idle = func(pollUntil int64) {
if pollUntil > 0 {
cpu.SetAlarm(pollUntil)
}
cpu.WaitInterrupt()
cpu.SetAlarm(0)
}
This hook keeps idle CPU usage near zero.
The guest reads its clock rate from KVM and prints it at boot. The toolchain and the firmware are pinned and verified by SHA-256 hashes.
Constraints
- The guest has no writable disk filesystem. Applications store state in an external database like Postgres.
- A guest on an isolated Ubicloud VM requires an API token to fetch secrets because host tap isolation is unavailable.