If you run containers on a Mac, you know the shape of this complaint: Docker (or OrbStack, or Colima, or container) slowly eats your RAM, and quitting every container doesn't give it back. The most-commented memory issue on docker/for-mac has been open since 2022. Lima has one. Colima has one. Apple's own container tool shipped with the same behavior, documented as a known limitation.
The usual instinct is to blame the runtime. We build one of these runtimes (ArcBox), so we had a professional interest in finding the actual culprit. We spent late July instrumenting both macOS hypervisor stacks — Apple's Virtualization.framework and our own VMM on the lower-level Hypervisor.framework — and calibrating every memory-release primitive Darwin offers.
This post builds the story from the ground up: what is actually running underneath your containers, who owns the memory it eats, what we measured, and why every serious runtime is quietly leaving the stack Apple recommends. Every claim is measurable, and everything is reproducible on your own Mac with two commands and no root — the companion repo is at the end.
What is actually running when you docker run on a Mac
Containers are not tiny virtual machines. On Linux, a container is just a walled-off process — the "walls" (namespaces, cgroups) are Linux kernel features. macOS has no Linux kernel, so every container runtime for the Mac ships one: it boots a real, mostly invisible Linux virtual machine and runs all of your containers inside it.
So "Docker is eating my RAM" is really "a Linux VM is eating my RAM." That reframes the question. When Linux inside the VM frees memory — your build finishes, your containers exit — does the Mac outside the VM ever get that memory back?
To answer it, you have to know who owns the VM's memory. And on macOS, that depends entirely on which of two APIs the runtime was built on.
Who owns a VM's memory on macOS
Apple ships two ways to run a virtual machine. Virtualization.framework (VZ) is the high-level, recommended one — hand it a kernel, get a VM object, done. It is what almost every runtime uses. Hypervisor.framework (HVF) is the low-level one: you bring your own virtual devices, your own boot protocol, your own everything. Almost nobody volunteers for that. The difference that matters for memory is where the guest's RAM lives:
Keep this picture in mind — everything below is downstream of which box the guest's RAM sits in. On VZ, guest RAM lives in Apple's XPC helper, which is also where Activity Monitor bills it. The APIs that give memory back to macOS — the madvise family — operate on your own address space. You cannot advise pages you do not map.
With the map established, here is the uncomfortable conclusion up front:
On Virtualization.framework, guest memory is a one-way ratchet. The host cost of a Linux VM is the high-water mark of pages the guest has ever touched, it never goes back down while the VM runs, and no application-level code — not Docker's, not ours — can fix it, because the memory belongs to a process Apple owns. The balloon device that is supposed to fix it measurably does nothing host-side. It is a placebo.
Everything below is measurable, and we cite either our own numbers or primary sources for each claim.
The ratchet, measured
Setup: macOS 26.4, Apple Silicon, a 16 GiB Linux VM on Virtualization.framework (VZ). We sampled the XPC helper's phys_footprint — the same ledger the macOS memory-pressure machinery uses.
| Stage | Host phys_footprint of the VM process |
|---|---|
| Fresh boot, idle 5+ minutes | 718 MB |
| Guest allocates 3 GB (tmpfs) | 3,717 MB |
| Guest frees all of it | 3,717 MB — unchanged, indefinitely |
Two things are true at once here, and most discussions conflate them:
- VZ commits memory lazily. A 16 GiB VM does not cost 16 GiB. An idle VM is genuinely cheap. Apple's marketing for its container stack leans entirely on this half: "if no containers are running, no resources will be allocated".
- VZ never uncommits. The moment the guest touches a page, that page is charged to your Mac forever — until the VM process exits.
MemAvailableinside the guest going back to 15 GiB changes nothing outside it.
The configured VM size is not what you pay. What you pay is the peak, and the peak is permanent.
The balloon that doesn't
There is a textbook fix for exactly this problem, and it has a distinguished pedigree: the memory balloon, introduced in Waldspurger's 2002 VMware ESX Server paper — an OSDI best-paper that has anchored hypervisor memory management ever since. The idea is a polite negotiation between host and guest. When the host wants memory back, a driver inside the guest "inflates" — it allocates pages from the guest kernel, which forces Linux to give up what it can spare — and hands the list to the host, which releases those pages back to the OS:
Virtualization.framework has the device — VZVirtioTraditionalMemoryBalloonDevice, an implementation of the VIRTIO standard's traditional memory balloon, whose guest half Linux has shipped since 2008 — and Apple's documentation says that when the guest returns pages, "the virtual machine releases those pages back to the host computer."
We tested it. We inflated the balloon by 15.35 GB on a 16 GiB VM — the guest visibly starved, MemAvailable collapsed, the guest kernel handed the pages over. The XPC helper's phys_footprint was byte-identical before and after:
Then we applied real host memory pressure to see whether the ballooned pages would at least be cheap to evict: the kernel compressed them as live data. Our control buffer in the same experiment — plain anonymous memory we'd marked MADV_FREE — was discarded by the same sustained pressure within seconds. (Reproduced end-to-end by the companion repo's vz-ratchet demo — ./run-vz.sh --pressure-gb 12 runs the whole sequence on a live VM, and its canary control also documents one nuance: the discard begins only once pressure is deep enough to drain the file cache. On a 16 GiB machine ~12 GiB of pressure discards the marked buffer totally — 65,536 of 65,536 pages — while the unmarked control survives compressed; on a 128 GiB machine, 48 GiB of pressure never even reaches these pages, absorbed entirely by the file-cache moat.)
That control matters, because it kills the "macOS just reclaims lazily" defense. Darwin is perfectly willing to throw away pages that are correctly marked as discardable. The balloon path never marks them. The pages the guest surrendered are indistinguishable, to the host kernel, from your working set.
We are not the only ones to measure this. A Lima contributor built adaptive ballooning for VZ this spring and hit the same wall — an independent tester found guest-side memory fully recovered while "the host com.apple.Virtualization.VirtualMachine process RSS never decreases," published a reproduction repo, and filed it with Apple (FB22614752, which we can't verify externally — but the repro is public and matches our numbers).
The documentation says pages are released to the host. Every measurement we have — ours and independent ones — says they are not. We cannot reconcile the two.
Apple knows. Read their container docs.
In June 2025 Apple announced Containerization and the container CLI — one lightweight VM per container, on Virtualization.framework. It hit 1.0 this June. Its technical overview contains the most honest paragraph Apple has published on this subject:
"Currently, memory pages freed to the Linux operating system by processes running in the container's VM are not relinquished to the host. If you run many memory-intensive containers, you may need to occasionally restart them to reduce memory utilization."
The prescribed mitigation for the memory model of Apple's container runtime, from Apple, is turning your containers off and on again.
The source tells the same story with more irony. The guest kernel Apple builds for Containerization has the balloon driver compiled in — CONFIG_VIRTIO_BALLOON=y sits right there in kernel/config-arm64. The host side never attaches a balloon device: VZVirtualMachineInstance.swift configures entropy, vsock, serial, network, directory sharing — and there is not a single reference to memoryBalloonDevices in the repository. The driver waits inside every container VM for a device that never comes. Given our measurements above, not wiring it was arguably the correct call — it just concedes the point.
To be fair, the VM-per-container design carries its own accidental mitigation: when a container stops, its VM process exits, and that returns everything. Reclaim works — at the granularity of an entire container, which is exactly what the "occasionally restart them" sentence admits. And Apple is hedging on the architecture itself: the framework recently grew an experimental LinuxPod API for running multiple containers in one shared VM, and on Linux hosts Containerization doesn't use VZ at all (it uses cloud-hypervisor on KVM).
The quiet exodus
Here is the part that convinced us this is a structural verdict and not a bug report. Look at what every macOS container runtime has actually done over the last two years, as opposed to said:
Docker moved Docker Desktop's Mac backend off Apple's framework to libkrun — an open-source VMM that runs on the lower-level Hypervisor.framework — for versions 4.35 through 4.85. A mid-cycle release note reads: "Fixed excessive memory usage on Apple Silicon Macs by improving the Linux VM's ability to return freed container memory back to the host OS." This month, 4.86 replaced libkrun with Docker's own closed-source hypervisor, and the first bullet in its benefits list is: "Returns idle memory to the host when containers aren't active, so Docker Desktop doesn't hold RAM it's not using." That is not a feature you advertise unless the incumbent framework can't do it.
Podman's macOS machine provider now defaults to libkrun, with Apple's framework (applehv) demoted to the alternative.
OrbStack ran on Virtualization.framework in its early days, and its founder was blunt about the consequence in an early support discussion: "After you use memory in your containers, it can't be released without restarting OrbStack due to a macOS bug that we're still waiting for Apple to fix." OrbStack's changelog then shipped a "new virtualization engine" in 1.6 and dynamic memory in 1.7 — "any extra memory is automatically released." The engine's internals are undisclosed, so we can't say how they fixed it; we can observe that the fix arrived together with leaving the stock framework behind, because as far as anyone has measured, it cannot be fixed on top of it.
Lima — the engine under Colima — grew a krunkit driver in v2.0, its escape hatch onto libkrun.
Four independent teams, four migrations off the framework Apple recommends. Meanwhile, the platforms macOS competes with treat this as a solved problem: WSL2 has shipped page reporting from the Linux guest to Hyper-V since 2019 and its autoMemoryReclaim now defaults to actively dropping and returning cache; on Linux/KVM, virtio-balloon free-page reporting has been upstream since kernel 5.7/QEMU 5.1 and is switched on by default by KubeVirt and OpenStack Nova; Firecracker — the VMM under AWS Lambda — documents the host-side contract of its balloon as routine engineering, reclaimed pages madvised away; and virtio-mem (VEE '21) goes beyond both for hotplug-scale resizing. In 2026, macOS is the only major desktop platform where a Linux VM's freed memory is unrecoverable by design.
Why this needs a different hypervisor, not a patch
The mechanism is boring and absolute — it is the left half of the ownership diagram above. On Virtualization.framework, guest RAM is allocated and owned by Apple's XPC helper process. The madvise family operates on your own address space. Every runtime built on VZ, including ours, is locked out of the fix by process boundaries.
Drop one level down to Hypervisor.framework and the situation inverts: you allocate guest RAM with mmap in your own process and hand it to the hypervisor with hv_vm_map. Now the fix is available, and we've calibrated exactly what each primitive does on Darwin:
| Primitive on guest RAM | Effect on Darwin |
|---|---|
madvise(MADV_DONTNEED) | Deactivation hint. Footprint unchanged, contents preserved. Inert. |
madvise(MADV_FREE) | Ledger unchanged, but pages become discardable — real pressure evicts them in seconds instead of compressing them. |
madvise(MADV_FREE_REUSABLE) | phys_footprint drops immediately; pages are marked clean and reclaimable. This is the real one. |
Pair that with the guest kernel's free-page reporting — the guest continuously tells the VMM which pages are free, no balloon inflation, no starvation — and "returning memory to macOS" stops being impossible and becomes an engineering task. This is, we believe, the actual technical content behind Docker VMM's "returns idle memory" bullet, and it is what libkrun already does in its own simpler form (it madvises reported ranges directly).
An engineering task, not a trivial one: while building the companion demos for this post we found that for pages the guest dirtied through the stage-2 mapping, the madvise is a silent no-op unless the hv_vm_map mapping is torn down around it. The working cycle, demonstrated on a live Hypervisor.framework VM with a real vCPU:
The footprint drops by the full guest RAM size, the VM keeps running, and the guest faults its pages back in on demand — paying only for what it actually uses again.
Try it on your own Mac
Everything above is reproducible on any Apple Silicon Mac — no SIP changes, no Developer ID, no root. The companion repo carries the whole measurement kit:
arcboxlabs/macos-vm-memory-ratchet10Reproductions and counter-demonstrations: the madvise calibration, the pressure experiments, the VZ ratchet + balloon placebo live on a real VM, and the working Hypervisor.framework reclaim cycle.Three entry points, in the order this post told the story:
./run-vz.sh— the Virtualization.framework side, live (vz-ratchet): boots a real Linux guest, has it touch 3 GiB (helper footprint +3,079 MiB), free every byte (+0.1 MiB — the ratchet), then inflates the balloon and shows the guest starving (MemAvailable3,705 → 639 MiB) while the footprint moves +0.2 MiB — the placebo, on your own machine, sampled withproc_pid_rusageand no privileges. Add--pressure-gb 12(on a 16 GiB Mac) for the discrimination run: the same pressure that discards a correctly-marked 1 GiB control buffer page-for-page leaves the ballooned memory compressed and fully charged../run.sh— the Hypervisor.framework counter-demonstration (hv-reclaim): the unmap → advise → remap cycle above, −3,073 MiB with the VM alive;--naiveshows the silent-no-op trap, and--hammerruns the safety probe — the guest read-modify-writes every page while the pageout scan runs under real pressure, 9,155 sweeps, 0 of 196,608 pages lost.cargo run --release -p calibrate-madvise— just the advice table, one minute, no VM.
Where we stand, honestly
ArcBox runs on Virtualization.framework by default today, and everything in this post applies to us too — our own docs have carried the memory warning for a while. We're not writing this from a position of having shipped the fix; we're writing it because we finally understand precisely why nobody on VZ can.
It's also why ArcBox maintains a second backend: our own open-source VMM on Hypervisor.framework, where guest RAM lives in our process and the table above is actionable. Free-page reporting wired to MADV_FREE_REUSABLE, with the footprint drop verified by measurement rather than by documentation, is on our roadmap — and unlike the closed alternatives, you'll be able to read the diff.
Two things would make this post obsolete, and we'd welcome either. Apple could make the balloon device do what its documentation already claims — the Feedback is filed, the reproduction is public. Or Apple could extend Virtualization.framework with a real memory-return path; the LinuxPod experiment shows the team is aware the current model has limits. Until one of those happens, the honest summary of running Linux on a Mac is: the RAM your containers touch is spent, the balloon is a placebo, and the only working reclaim mechanism on Virtualization.framework is kill(2).
Further reading, for the mechanism family this post takes for granted: Waldspurger's OSDI '02 paper introduced ballooning alongside content-based page sharing and the idle-memory tax; Hildenbrand & Tsirkin's KVM Forum 2020 talk "Virtio-(balloon|pmem|mem): Managing Guest Memory" (slides) is the best survey of the traditional balloon's accumulated problems and its successors; and VMware's "Understanding Memory Resource Management" whitepaper shows where ballooning sits in a working host's full reclaim ladder — page sharing, then balloon, then compression, then host swap. That ladder is what a functioning host side looks like; this post measured a rung that isn't attached to anything.
Methodology notes: measurements taken on macOS 26.4 (25E246), Apple Silicon, sampling phys_footprint via footprint/task_info on the VZ XPC helper process (com.apple.Virtualization.VirtualMachine) — not the embedding daemon, which is the mistake that makes VZ look eager-committed; the companion repo's vz-ratchet samples the helper with proc_pid_rusage, which needs no privileges at all. Balloon inflation driven through VZVirtioTraditionalMemoryBalloonDevice.targetVirtualMachineMemorySize. Calibration buffers were anonymous mmap regions in the VMM process. On regions mapped into a live VM with hv_vm_map, the advice takes effect for host-dirtied pages as-is, but for guest-dirtied pages only when the stage-2 mapping is torn down around the madvise — the working sequence and the trap are both demonstrated, on a real vCPU, in the companion repo. Sources for third-party claims are linked inline; where a vendor's mechanism is undisclosed (OrbStack's engine, Docker VMM's internals) we've labeled the inference as such.
