carrick
Run unmodified Linux binaries on macOS. No VM, no guest kernel, no daemon.
Carrick loads a Linux ELF binary, runs its ARM64 instructions directly on the CPU
via Apple's Hypervisor.framework, and traps each svc #0 (Linux syscall)
at the hardware boundary. A Rust runtime translates the trapped call to a Darwin
equivalent and resumes the guest. The Linux process becomes a native macOS process —
visible to ps, lsof, kill, and dtrace
on your host.
Install
$ brew tap carrick-sh/carrick
$ brew install --HEAD carrick Apple Silicon macOS only. Built from source and codesigned with the Hypervisor.framework entitlement on install.
See it run
These are real recordings of the carrick binary —
captured straight from the terminal (a pty typing the actual carrick
command into a shell, with carrick's real output and timing), not hand-written mock-ups.
A Linux container is just macOS processes
Run a container detached, then look at the macOS process table. The guest's
processes are right there — real macOS PIDs, real %CPU and RSS,
labelled carrick:web: python3 and signalable with kill(1).
Inside, the container has its own PID namespace (its ps shows PID 1);
on the host those same processes are ordinary native processes — no VM hiding them.
Networking is host-level, so a curl from macOS reaches the server directly.
| On your Mac | carrick | Docker Desktop |
|---|---|---|
ps / lsof shows | every guest process, as carrick:<id>: <cmd>, with a real PID, %CPU and RSS | only Docker's VM helpers (e.g. com.docker.virtualization) — never the container's own processes |
| The process really is | a native macOS process running guest ARM64 at EL0 | a Linux process inside the LinuxKit VM, invisible to the host kernel |
| Signal / trace it | kill, dtrace, sample and lsof work directly from macOS | docker kill only; host tools can't reach inside the VM |
| Activity Monitor | lists each container process by name | shows a single Docker VM process |
More recordings
Usage
A quick command reference — see the recordings above for real output.
# pull an OCI image and run a command
$ carrick run ubuntu:24.04 /bin/bash -c 'apt-get update && apt-get install -y hello && hello'
# interactive shell with a real PTY (job control, Ctrl-Z/fg, ps)
$ carrick run -it ubuntu:24.04 bash
# run an x86_64 image via Apple Rosetta 2
$ carrick run --platform linux/amd64 ubuntu:24.04 uname -m
# run a prebuilt Linux ELF directly — no image pull
$ carrick run-elf ./my-linux-binary --flag value
# bind-mount a host directory into the guest
$ carrick run -v /Users/me/data:/mnt:ro ubuntu:24.04 ls /mnt
# build an image from a Dockerfile (runs kaniko as a guest) — like docker build
$ carrick build -t myapp:latest .
# live syscall trace via DTrace USDT probes
$ sudo carrick trace run alpine:latest /bin/echo hi What happens under the hood
Take the Python http.server recording above: the guest
binds port 8765 and a curl from macOS gets a real HTTP 200. That
works because the guest socket binds directly to a host interface — no port forwarding.
No VM booted. No container runtime started. The Python binary's ARM64 instructions
executed on your CPU at EL0 (unprivileged). When Python called bind(),
carrick trapped the svc #0, decoded it as Linux sys_bind,
and called macOS bind() on a real host socket. The server is listening on
your actual network interface.
How it works
Carrick uses Apple's Hypervisor.framework to create a lightweight execution context
— not a virtual machine. There is no guest kernel, no virtual disk, no BIOS. One
host pthread and one HVF vCPU per guest thread.
Linux Binary (ARM64 EL0) Carrick Runtime (Rust) macOS Kernel
┌──────────────────────┐ ┌──────────────────────────┐ ┌──────────────┐
│ │ trap │ │ │ │
│ guest executes │───────>│ VBAR_EL1 vector catches │ │ │
│ svc #0 (syscall) │ │ hvc #2 exits to host │ │ │
│ │ │ │ │ │
│ │ │ decode x8 (syscall nr) │ │ │
│ │ │ decode x0-x5 (args) │───>│ Darwin API │
│ │<───────│ write result to x0 │<───│ (native) │
│ resumes execution │ │ resume vCPU │ │ │
└──────────────────────┘ └──────────────────────────┘ └──────────────┘ - Trap boundary. Guest executes
svc #0→ synchronous exception toVBAR_EL1→hvc #2exits to the runtime → Rust handler translates and dispatches → result written tox0→ vCPU resumed. - Clean-room. Syscall handlers are written from specs and observed behavior, not from Linux kernel source.
- Memory. Stage-1 identity-mapped page tables with MMU enabled
(
SCTLR_EL1.M=1), so ARM exclusive loads/stores (ldaxr/stlxr) work and mutexes behave correctly. - Concurrency. Per-subsystem locks (fs, creds, proc, signal, mem) — no big kernel lock. Unrelated syscalls run in parallel across threads.
- Fork.
fork(2)→ real macOSfork()+ an explicit copy of the child's resident guest pages (guest RAM isMAP_SHAREDfor HVF coherence, so it can't be copy-on-write) + rebuilt HVF context.execve(2)→ tear down, reload ELF, resume. - Translation examples.
epoll→kqueue, Linux sockets → Darwin sockets,AF_NETLINKsynthesized, synthetic/procand/sys.
What works today
Carrick runs real workloads end-to-end today — apt-get, Python servers,
Node.js, the Go runtime suite, and the full libuv async I/O surface.
~127,000 lines of Rust across 12 crates. We publish LTP and ecosystem
conformance baselines so you can assess fit.
| Workload | Status | Detail |
|---|---|---|
apt-get install | verified | Runs end-to-end including dpkg post-install scripts. |
Python http.server | verified | ThreadingHTTPServer serves concurrent requests from the host. Single-digit ms response times. |
| Go runtime suite | verified | ~876/880 standard-library test binaries pass (sync, atomic, context, time, runtime, net, cgo). At parity with Docker. |
| Node.js & V8 | verified | node-core full plan: 5301/5304 (99.9%). The 3 fails are cosmetic stderr snapshots the Docker oracle also fails. |
| libuv test suite | verified | 498/507 tests pass (98.2%). Full async I/O, pipe, IPC, and event-loop surface. |
| LTP syscall conformance | verified | 568/896 valid tests match (63%). Strong: sched 76%, timers 74%, signals 73%, fs 68%. Weaker: mm 34%, ipc 38%. |
| CPython module parity | verified | 425/492 regrtest modules match (86.4%). test_subprocess/test_multiprocessing now run (nested-fork bug fixed). |
Interactive shell (-t) | verified | Real PTY via pty_relay.rs. Ctrl-C, Ctrl-Z, job control work. |
| Docker-style CLI | verified | OCI pull, layer composition, -e/-v/-w/--entrypoint flags. |
x86_64 via Rosetta 2 (--platform linux/amd64) | verified | amd64 images JIT-translate through Apple Rosetta 2 and run — glibc (Debian/Ubuntu, incl. multi-call coreutils) and static-musl (Alpine: busybox, apk) alike. Translated, so slower than native ARM64. |
Full baseline data: compatibility page.
Performance
Measured via DTrace USDT probes on carrick run … /bin/true:
| Phase | Cost | Note |
|---|---|---|
| First boot | ~90 ms | OCI load + hv_vm_create + page tables + ELF load |
| Fork | ~5.7 ms | HVF context rebuild — ~16× cheaper than boot, no global lock |
| Fork + exec | ~7.8 ms | vs ~1 ms native Linux fork+exec |
| Child teardown | ~7 µs | Effectively free |
| Process teardown | ~175 ms | Kernel reclaiming the large VM mapping |
Known limitations
Carrick is syscall emulation, and that cuts both ways. Not every binary will work. We'd rather be upfront about this than have you find out the hard way.
- Partial syscall coverage (~62%). About 210 of the 339 aarch64 syscalls are implemented; LTP differential conformance is 63% (568/896). Memory management (34%) and IPC (38%) are the weakest subsystems.
- Rare Go runtime coherence races. Under heavy concurrent memory operations, the Go runtime occasionally hits HVF coherence exceptions.
- No
ptracesupport beyond Phase-1.gdbanddelvedo not work on guest binaries yet. - PTY edge cases.
ttyname//dev/ttydon't fully resolve. Intermittent newline race withlson wide terminals. - x86_64 via Rosetta 2 is the experimental path. amd64 images run under
Apple Rosetta 2 — both glibc (Debian/Ubuntu) and static-musl (Alpine/busybox), see the
recording above — but it's JIT-translated (slower than native ARM64), needs Rosetta for
Linux installed (
softwareupdate --install-rosetta), has lighter automated test coverage than the arm64 path, and a few specific workloads still have edge-case bugs. Native ARM64 remains the primary, fully-supported target. - Host networking only. A guest's sockets are real host sockets, so a
container shares your Mac's network with no isolation: there's no
-pport mapping (a real remap is rejected), two containers can't bind the same port, and raw/ICMP sockets need root. Ideal for reaching a guest server from the host; not a network sandbox. - Filesystem wants a case-sensitive volume. Linux paths are
case-sensitive; macOS volumes usually aren't. Run
carrick volume createonce for a persistent on-disk root — otherwise carrick falls back to an in-memory filesystem and writes don't survive the run. Bind mounts (-v) map straight to host paths; the container root lives on that volume. - Not a security sandbox. Guest code runs with the privileges of the invoking user — there is no cgroup/capability/resource enforcement, and the guest seccomp surface is modeled, not enforced. Run code you trust.
Compared to VMs
Carrick complements containers — and increasingly stands in for them: build images
with carrick build, and drive carrick through a Docker-compatible API
with carrick serve, all without a VM. The Docker API is early (SDK-level
container lifecycle, not the interactive docker CLI yet) — see
using carrick with Docker.
| Docker Desktop | Lima / Colima | Full VM | Carrick | |
|---|---|---|---|---|
| Model | Linux VM → containers | Linux VM | Full guest OS | Binary as macOS process |
| Startup | 30–60 s | 10–30 s | Minutes | ~90 ms / ~5.7 ms fork |
| Filesystem | FUSE sync | Mounted share | Virtual disk | Direct host paths |
| Networking | Port forwarding | Shared IP | NAT/bridge | Direct host sockets |
| Host integration | Opaque | Inside VM | Inside VM | ps/lsof/kill/dtrace |
Links
- Documentation — install, CLI reference, tracing
- Compatibility — LTP, Go, CPython conformance baselines
- Blog — engineering notes and deep dives
- GitHub