Colima runs Docker on a Mac inside a Linux virtual machine managed by Lima on Apple's Virtualization framework. The VM gets a fixed memory size, here 6 GiB, and from the host's point of view that memory is only paid for as the guest touches it. A fresh guest costs a few hundred megabytes. The problem is that Linux treats free memory as wasted memory, fills it with the file cache from every image pull and build, and never hands the pages back. After one working session the guest has touched nearly all of its 6 GiB, and macOS carries that for as long as the VM runs, compressing and swapping it whenever something else needs the RAM.
Summary. Inside the 6 GiB guest, processes used 373 MB and the page cache 4,089 MB, with no container running. On the host the VM held 5.1 GB on the compressor and the swap file and 161 MB resident, which was 53% of everything the Mac had out of RAM, and the swap file had grown from 2 to 3 GB. The guest has a virtio balloon device and the driver is loaded, but Lima 2.2.1 attaches the device and never resizes it, there is no configuration key for it, and the pull request that would add one is unmerged. Dropping caches inside the guest changes nothing the host can see, and no outside process can reach the balloon object, which lives inside Lima's own process. The only lever is stopping the VM, and a 300-line Swift tool named siesta now does that from an hourly launchd agent when the profile has had no containers, no Docker clients and no ssh sessions for two consecutive checks. Nothing stays resident between checks.
What the guest was holding
The profile is called pr-qa and exists to run a project's containers for pull request checks. It had been started at 19:47 and the last container had exited well before 21:10, when the measurements were taken. Inside the guest, free -m and /proc/meminfo gave the following split.
| Inside the 6 GiB guest | MB |
|---|---|
| Processes: dockerd, containerd, the Lima guest agent, systemd | 373 |
| Page cache from image pulls and builds | 4,089 |
| Never touched since boot | 1,640 |
| Containers running | 0 of 5 |
The host saw the same VM differently. The Virtualization framework runs each VM as a helper process named com.apple.Virtualization.VirtualMachine, and a tool that walks the process list and attributes compressed and swapped pages per process put that helper at the top by a wide margin.
| On the host, 21:10 | Value |
|---|---|
| Resident in RAM | 161 MB |
| Compressed or on the swap file | 5.1 GB |
| Share of everything the Mac had out of RAM | 53% |
| Swap file, before and after the VM started | 2 GB, then 3 GB |
| Next largest holder, Safari with 33 tabs | 450 MB |
Nothing in the guest was using that memory in any ordinary sense. The 4 GB of page cache is the remains of files read once. The guest's load average was 0.00. The machine was simply carrying a copy of a Docker image library that nobody was reading, and paying for it in the currency that matters on a 16 GB laptop: free memory was 104 MB, the compressor held 5 GB, and the one memory-pressure kill of the day happened while this profile was up.
Why the host never gets it back
Guest memory in a Virtualization framework VM is a large allocation in the helper process. The host does not commit physical pages to it until the guest writes to them. That is why a freshly booted guest is cheap. Once a page has been written, it stays committed: the host has no idea whether the guest still cares about it, because the guest's idea of free and the host's idea of free are not connected. When the guest later frees a page, it only updates its own accounting. The host still holds the bytes, and when memory gets tight it compresses them or writes them to the swap file like any other page of any other process.
+----------------------------------------+
| Image pulls and builds write files |
| inside the guest |
+----------------------------------------+
|
v
+----------------------------------------+
| Linux keeps them as page cache, up to |
| the 6 GiB cap |
+----------------------------------------+
|
v
+----------------------------------------+
| macOS keeps every guest page that was |
| ever touched |
+----------------------------------------+
|
v
+----------------------------------------+
| Idle pages move to the compressor, |
| then the swap file |
+----------------------------------------+
|
v
+----------------------------------------+
| Lima never resizes the balloon, so |
| nothing returns |
+----------------------------------------+
Linux makes this worse on purpose. Its page cache grows until it fills every free page, because an unused page gives no benefit and a cached file saves a read later. On a bare-metal server that is the right policy. Inside a VM whose host is itself short of memory, it means the guest marches through its whole allocation once and then sits there owning it. The cap is the only thing that limits the damage, which is why a 6 GiB guest ends up costing close to 6 GiB whatever it does.
The balloon that is attached and never pulled
The mechanism designed for exactly this is the virtio balloon. The host asks the guest to inflate a balloon of a given size; the guest's balloon driver allocates that many pages, which forces Linux to drop cache to find them, and reports the pages to the host, which can then take them back. Deflating returns them. Apple's framework exposes this as VZVirtioTraditionalMemoryBalloonDevice, and the host changes the guest's effective memory by setting targetVirtualMachineMemorySize on that device while the VM runs.
The guest here has the device. Its virtio bus lists it as device 8 with the virtio_balloon driver bound, next to the block, network, console, entropy, vsock and virtiofs devices Lima sets up. Lima's binary contains the Go bindings for the balloon configuration, because the library it uses to drive the framework includes them. What Lima does not contain is any code that resizes the balloon after boot, and limactl info shows no balloon-related configuration key under the vz options. The device is attached at its full size and left alone.
This is not an oversight waiting for a one-line fix. Lima's issue tracker has had a request for memory ballooning open since early 2026, and a pull request that adds adaptive ballooning for the vz driver, resizing the balloon from guest pressure and host memory pressure, was still open at the time of writing. A maintainer's comment on it questioned whether the complexity belongs in Lima at all. Separately, at least one project that uses the framework directly reports that on recent macOS versions the host does not actually release the pages a guest gives up through the balloon, which would make even a working Lima implementation less useful than the API suggests. That report is a single secondary source and was not reproduced here.
One more thing closes the door for an outside tool. The balloon device is an object inside the process that owns the VM, which for Colima is Lima's host agent. The framework has no cross-process handle to it. A separate program, however well written, cannot ask that VM to shrink. The only program that could is Lima, and Lima does not.
What does not work
Three obvious moves were considered and dropped before writing any code.
- Dropping caches inside the guest. Writing to
/proc/sys/vm/drop_cachesfrees the 4 GB in the guest's accounting and nothing else. The host is never told, so its 5.1 GB stays exactly where it was. The freed guest pages may be reused for the next cache fill, so a periodic drop slows the growth slightly, but it never reverses it. - A tool that drives the balloon from outside. Not possible, for the reason above. A Swift program that hosts the VM itself, with its own balloon policy and a control socket, was sketched and would work, but it would replace Lima's host agent and lose Colima's port forwarding, the Docker socket forwarding and the ssh integration in the process. That is a different project, not a fix.
- Waiting for the framework to reclaim idle pages. It does not. The pages belong to a live process. Compression and swapping are what the host does with them, and that is the cost being measured, not a solution.
The options that remain
| Option | What it buys | What it costs |
|---|---|---|
| Stop the VM when nothing is using it | All 5.1 GB, within seconds | About 20 seconds to start it again; the Docker socket is gone until then |
| Cap the guest at 3 GiB instead of 6 | Halves the ceiling the cache can grow to | A restart to apply; builds that need more memory fail inside the guest |
| OrbStack | Releases idle memory to macOS by design | A different tool, with a licence for business use |
| Docker Desktop's Resource Saver | Stops its VM after a quiet period | A different tool, and a heavier one |
| Apple's container command | One small VM per container that exits with it, so nothing idles | No Docker Compose compatibility to speak of yet |
| Patch Lima | The proper fix, in Go, with the open pull request as a start | Maintainers unconvinced; a fork to carry |
Stopping is the simplest option and the only one that needs no change to the tools already in use. The missing piece was making it happen on its own, because the VM is started for a task and forgotten when the task ends, and nobody remembers to stop it before the next memory pressure event.
An hourly agent that stops the VM
The tool is named siesta. It is a Swift 6 package with no dependencies, about 300 lines of library code and a command-line interface. There is no daemon. launchd runs siesta check once an hour; the binary looks at each Colima profile, stops the ones that have been idle long enough, and exits. The same binary is the command you type for status and logs. A check costs well under a second: docker ps over the profile's own socket answers in 20 ms, and the client scan in 30 ms.
brew tap marianposaceanu/tap
brew install siesta
siesta install # writes the plist and loads the hourly agent
siesta status # profiles, live counts, the idle clock, agent state
Stopping a VM that someone is about to use would be worse than the memory, so the definition of idle is deliberately conservative, and a check that cannot see clearly never stops anything.
What counts as idle
A running profile is busy if any of the following holds. Each probe can be switched off in the configuration file.
- A container is running, as reported by
docker psover the profile's socket. - Any process other than Lima's own holds the profile's Docker or containerd socket open: a
dockercommand in flight, Compose, an IDE, a build. The scan useslsof -U, which lists only Unix sockets and stays fast. Lima's ssh multiplex master always holds the socket to forward it, so it is excluded by its command line, along with thelimactlprocesses. - An interactive
colima sshsession is open. - Optionally, the guest's one-minute load average is above a threshold. This costs one ssh round trip per check and is off by default.
Otherwise the profile is idle. The first idle observation starts a clock, written to a state file because the binary is not resident. When a later check finds the profile still idle and the clock has passed the configured sixty minutes, the profile is stopped with colima stop. Any busy observation resets the clock. With hourly checks this means a profile stops after one to two hours of real idleness, which is a deliberate trade: a shorter interval would wake the machine more often for a tool whose whole purpose is to cost nothing.
Two details came out of the first dry runs against the real profile. A probe that fails, for example because docker does not answer or the launchd environment lacks a path, must not read as "zero containers". Each probe returns an unknown rather than a count on failure, the policy reports the profile as unknown and resets the clock, and nothing is stopped. The second detail is the idle clock surviving a restart: if the VM is stopped and started by hand between two checks, an old clock would stop the new VM at the next check. Lima rewrites a small pid file on every start, so the agent records that file's contents as a boot marker and begins a new clock whenever it changes.
Running it the way macOS expects
A user agent on macOS has a small set of conventions, and following them is what keeps a tool like this from becoming a mystery process six months later.
| Convention | What siesta does |
|---|---|
| Reverse-DNS label | com.marianposaceanu.siesta |
| Plist location | ~/Library/LaunchAgents/, loaded into the user's gui domain with launchctl bootstrap, no sudo anywhere |
| Program path | Absolute, pointing at the Homebrew binary, or at a copy in ~/.local/bin for a package build |
| Environment | An explicit PATH in the plist, because launchd agents get almost none, and colima and docker live under /opt/homebrew/bin |
| Scheduling | StartInterval of 3,600 seconds and RunAtLoad, so the first check happens at login |
| Priority | ProcessType Background, Nice 10, low-priority I/O |
| Output | stdout and stderr to ~/Library/Logs/siesta/launchd.log; the tool's own log next to it, rotated at 1 MiB, with every event mirrored to the unified log under the same label |
| State | ~/Library/Application Support/siesta/state.json; the configuration under ~/.config/siesta/ so it can live in a dotfiles repository |
| Overlap | A file lock around each check, so two launchd runs can never stop the same profile twice |
One consequence of using the gui domain is that the agent appears in System Settings under Login Items and Extensions, in the list that allows background items. If it is switched off there, launchctl enable cannot switch it back on; only the Settings pane can. The status command reads the disabled list and says so, which saves a confused half hour.
Checking that it works
The pure parts of the tool, the idle policy, the state file, the plist and the subprocess runner, are covered by a self-test executable rather than a test framework, because the bare Command Line Tools on this Mac ship neither XCTest nor the swift-testing macros. The checker is exercised end to end against a fake backend: the clock arms, resets on a client, stops at the threshold, starts over after a restart, refuses to act on a failed probe, and a dry run persists nothing. Against the real profile, a dry run reported the profile idle with the right counts once Lima's own socket holder was excluded, and the status output looked like this before the agent was installed.
profile pr-qa running 6.0 GiB, 2 cpus
live containers 0, clients 0, ssh 0
idle no, or not observed yet
last stop never, 0 total
agent not installed (run `siesta install`)
config ~/.config/siesta/config.json (defaults)
log ~/Library/Logs/siesta/siesta.log
What the tool does not do is as important as what it does. It never starts a profile; the next colima start is yours. It does not touch Docker Desktop, OrbStack or Lima instances outside Colima. And it does not shrink a running VM, because, as the first half of this note set out, nothing can.
Sources
- siesta, the launchd agent and command-line tool described here
- Lima, issue 4220: memory ballooning is not supported
- Lima, pull request 4828: adaptive memory ballooning for Virtualization framework VMs, open at the time of writing
- Apple, VZVirtioTraditionalMemoryBalloonDevice: changing a guest's memory size at run time
- Limitations of memory ballooning in container VMs on macOS, a secondary source on pages not being released to the host
- Colima, container runtimes on macOS with minimal setup
- Lima, Linux virtual machines on macOS, including the vz driver
- Apple, Creating launchd daemons and agents: plist keys, domains and conventions
- Linux kernel, vm sysctl documentation: drop_caches and related knobs