Understanding Memory Management and Ballooning Limitations in Container VMs
Container VMs allocate memory statically up to a user-defined limit, but macOS Virtualization framework only supports partial ballooning—freed guest memory is not returned to the host until the VM restarts.
The apple/container project runs each container inside its own lightweight Linux virtual machine built on Apple’s Virtualization framework. While this provides strong isolation and a native Linux environment, the memory management model differs significantly from traditional containers or full virtualization. Understanding how container translates CLI flags into VM configuration—and why ballooning limitations prevent automatic host memory reclamation—is essential for managing resource-intensive workloads on macOS.
How Container VMs Allocate Memory
Static VM Memory Limits
When you create a container, the --memory flag sets a hard upper bound for the entire VM. For example, container run --memory 8g configures the VM to never exceed 8 GiB regardless of host availability. This value is parsed by Parser.memoryStringAsMiB in Sources/Services/ContainerAPIService/Client/Parser.swift and stored as resources.memoryInBytes in the container configuration. The MachinesService.swift file then passes this value to the VM launch command as bootConfig.memory.
Dynamic Usage Inside the Guest
The Linux kernel inside the VM commits only the pages that processes actually touch. Consequently, a container started with --memory 16g may show only a few gigabytes of RAM in macOS Activity Monitor if the workload is idle. However, this "unused" portion remains reserved by the VM and is not available to other host processes until the VM stops.
The Ballooning Limitation Explained
Partial Ballooning Support
According to docs/technical-overview.md, macOS Virtualization framework implements only a partial memory-ballooning mechanism. While the VM can shrink its footprint under certain conditions, pages that the guest frees are not returned to macOS. The host observes the memory high-water mark until the VM itself is stopped and restarted. This behavior is explicitly documented in the "Releasing container memory to macOS" section of the technical overview.
Why Memory Stays Committed
This limitation means that even if a container's processes free memory aggressively using free() or drop caches, the underlying VM retains those pages from the host perspective. The docs/technical-overview.md file notes that the only reliable way to reclaim that memory from the host is to stop the affected container(s) or the entire builder VM and start them again.
Practical Impact on Host Resources
Cumulative Memory Pressure
Running multiple memory-intensive containers can cause the host's RAM usage to grow continuously, even when individual containers have freed most of their memory internally. Users monitoring container stats will see low memoryUsageBytes compared to memoryLimitBytes (as reported by ContainerStats.swift), but Activity Monitor still shows high VM memory consumption because the freed pages remain assigned to the VM.
The Restart Requirement
To actually release physical pages back to macOS, you must stop the VM. For persistent development machines, update the configuration and restart:
container machine set -n dev memory=12G
container machine stop dev
container machine run -n dev -- nproc
Configuring Memory Limits and Defaults
Default Memory Allocation
If you omit the --memory flag, container defaults to half of the host's RAM. This calculation resides in Sources/ContainerPersistence/ContainerSystemConfig.swift where defaultMemory is derived from the host's total physical memory. The builder VM used for container build defaults to 2 GiB unless explicitly configured via container builder start --memory.
Parsing Memory Strings
The CLI accepts human-readable sizes like "8g" or "512M" through the memoryStringAsMiB method:
// Sources/Services/ContainerAPIService/Client/Parser.swift
public static func memoryStringAsMiB(_ memory: String) throws -> Int64 {
let ram = try Measurement.parse(parsing: memory)
return Int64(ram.converted(to: .mebibytes).value)
}
Builder VM Configuration
For build operations that require more RAM, increase the builder memory before starting:
container builder start --memory 16g
container build .
Monitoring Memory Usage at Runtime
Runtime Statistics
The Sources/ContainerCommands/Container/ContainerStats.swift file reports both memoryUsageBytes and memoryLimitBytes to the CLI. The container stats command displays current utilization against the configured limit, formatted as:
let memUsageStr = stats2.memoryUsageBytes.map { Self.formatBytes($0) } ?? notAvailable
let memLimitStr = stats2.memoryLimitBytes.map { Self.formatBytes($0) } ?? notAvailable
print("Memory: \(memUsageStr) / \(memLimitStr)")
Inspecting Configuration
To view the exact memory limit for a running container in bytes:
container inspect my-container --format json | jq '.configuration.resources.memoryInBytes'
Summary
- Static allocation: The
--memoryflag sets a hard VM limit at creation time, parsed byParser.memoryStringAsMiBand stored inresources.memoryInBytes. - Lazy commit: The Linux guest only touches pages it needs, but unused capacity remains reserved from the host perspective.
- Ballooning limits: macOS Virtualization framework does not return freed guest pages to the host; the high-water mark persists until VM restart.
- Default behavior: Without explicit flags, containers use ½ host RAM (configured in
ContainerSystemConfig.swift), while builder VMs default to 2 GiB. - Reclamation: Stop and restart containers or the builder VM to free memory back to macOS.
Frequently Asked Questions
Why does Activity Monitor show high memory usage after my container freed memory inside the VM?
This occurs because of the partial ballooning limitation in macOS Virtualization framework. While the Linux guest may have freed pages internally, those pages are not returned to the host operating system. The host sees the memory high-water mark until you stop and restart the container VM, as documented in docs/technical-overview.md.
How do I reclaim memory from a running container without stopping it?
You cannot reclaim memory from the host without stopping the VM. According to the apple/container source code, there is no mechanism to return freed guest pages to macOS while the VM is running. You must execute container machine stop for persistent machines or stop the ephemeral container, then restart to release the physical RAM.
What is the default memory limit if I do not specify the --memory flag?
The default is half of the host's physical RAM. This calculation occurs in Sources/ContainerPersistence/ContainerSystemConfig.swift where the defaultMemory property divides the host's total memory by two. For builder VMs used with container build, the default is specifically 2 GiB unless overridden with container builder start --memory.
How do I permanently change the memory allocation for a development machine?
Use the container machine set command to update the configuration, then restart the machine. For example, container machine set -n dev memory=12G updates the persistent configuration, but the change only takes effect after you run container machine stop dev followed by container machine run -n dev. This process is detailed in docs/container-machine.md.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →