How Resource Limits (CPU, Memory, and Ulimits) Are Implemented in Apple Container

Apple Container enforces resource limits through a three‑layer architecture: CLI flags are parsed into typed Swift structures in Flags.swift and Parser.swift, serialized into JSON‑encoded ProcessConfiguration messages, and enforced inside the Linux VM via cgroups for CPU and memory while POSIX ulimits are applied through setrlimit system calls.

The apple/container repository implements container resource constraints by bridging macOS client code with a Linux‑based runtime. When you pass --cpus, --memory, or --ulimit flags to the container run command, the system translates these user‑friendly strings into kernel‑level restrictions through a well‑defined pipeline of validation, serialization, and system‑call execution.

The Three‑Layer Resource Limit Architecture

Resource enforcement operates across distinct architectural boundaries:

  • CLI and Client Layer: Parses --ulimit, --cpus, and --memory arguments in Flags.swift and validates them in Parser.swift
  • Configuration Serialization: Encodes limits into ProcessConfiguration.Rlimit and ContainerConfiguration.Resources structs that travel over XPC as JSON
  • VM Runtime Layer: The Linux‑based runtime inside the VM unpacks the JSON and applies cgroups for CPU/memory or invokes setrlimit for POSIX ulimits before exec‑ing the container process

This separation ensures that Swift code handles user input and validation while the VM‑side runtime performs privileged kernel operations.

Parsing Ulimits from the Command Line

Defining CLI Flags in Flags.swift

The entry point for resource limits begins in the command‑line interface definition. In Sources/Services/ContainerAPIService/Client/Flags.swift, the Flags struct declares the ulimits array that captures repeated --ulimit flags:

public var ulimits: [String] = []        // populated from "--ulimit" on the CLI

Mapping and Validation in Parser.swift

The heavy lifting of validation and type conversion happens in Sources/Services/ContainerAPIService/Client/Parser.swift. The parser maintains a strict mapping between human‑readable ulimit names and Linux RLIMIT constants:

private static let ulimitNameToRlimit: [String: String] = [
    "cpu":    "RLIMIT_CPU",
    "fsize":  "RLIMIT_FSIZE",
    "data":   "RLIMIT_DATA",
    "stack":  "RLIMIT_STACK",
    "core":   "RLIMIT_CORE",
    "rss":    "RLIMIT_RSS",
    "nproc":  "RLIMIT_NPROC",
    "nofile": "RLIMIT_NOFILE",
    "memlock":"RLIMIT_MEMLOCK",
    "as":     "RLIMIT_AS",
    "locks":  "RLIMIT_LOCKS",
    "sigpending": "RLIMIT_SIGPENDING",
    "msgqueue":   "RLIMIT_MSGQUEUE",
    "nice":       "RLIMIT_NICE",
    "rtprio":     "RLIMIT_RTPRIO",
    "rttime":     "RLIMIT_RTTIME"
]

The rlimit(_:) method enforces rigorous validation rules:

  1. Syntax checking: Ensures the input matches <type>=<soft>[:<hard>]
  2. Type validation: Verifies the type exists in ulimitNameToRlimit
  3. Value parsing: Accepts non‑negative integers or the literal unlimited
  4. Constraint validation: Guarantees soft limit ≤ hard limit
  5. Duplicate detection: Prevents specifying the same limit type twice

Upon successful validation, the method returns a ProcessConfiguration.Rlimit value:

public static func rlimit(_ ulimit: String) throws -> ProcessConfiguration.Rlimit {
    // Split "type=soft[:hard]"
    let parts = ulimit.split(separator: "=", maxSplits: 1)
    guard parts.count == 2 else {
        throw ContainerizationError(.invalidArgument,
            message: "invalid ulimit format '\(ulimit)': expected <type>=<soft>[:<hard>]")
    }

    let typeName = String(parts[0])
    guard let rlimitType = ulimitNameToRlimit[typeName] else {
        let valid = ulimitNameToRlimit.keys.sorted().joined(separator: ", ")
        throw ContainerizationError(.invalidArgument,
            message: "unsupported ulimit type '\(typeName)': valid types are \(valid)")
    }

    // Parse soft / hard values (allow "unlimited")
    let valueParts = parts[1].split(separator: ":", maxSplits: 1)
    let soft = try parseRlimitValue(String(valueParts[0]), typeName: typeName)
    let hard = (valueParts.count == 2)
        ? try parseRlimitValue(String(valueParts[1]), typeName: typeName)
        : soft

    guard soft <= hard else {
        throw ContainerizationError(.invalidArgument,
            message: "ulimit '\(typeName)' soft limit (\(soft)) cannot exceed hard limit (\(hard))")
    }

    return ProcessConfiguration.Rlimit(limit: rlimitType, soft: soft, hard: hard)
}

Configuring CPU and Memory Limits

Unlike ulimits, CPU and memory constraints are container‑wide resources rather than per‑process limits. These values bypass the POSIX rlimit mechanism and instead target Linux cgroups.

Container‑Wide Defaults

Default resource values reside in Sources/ContainerPersistence/ContainerSystemConfig.swift:

public struct ContainerSystemConfig: Sendable, Codable {
    public let cpus: Int                     // default: 4
    public let memory: MemorySize            // default: 1 GiB (1024 MiB)
}

Per‑Container Resource Specifications

When users specify --cpus or --memory on the command line, these populate the Resources struct inside ContainerConfiguration, defined in Sources/ContainerResource/Container/ContainerConfiguration.swift:

public struct ContainerConfiguration: Sendable, Codable {
    public var resources: Resources          // includes cpus, memoryInBytes, …
}

The runtime uses these values to configure the cgroup controller for the container process, ensuring hard caps on CPU cores and memory consumption.

Runtime Enforcement Inside the VM

XPC Message Handling in RuntimeService.swift

Once the client validates and packages the configuration, it sends an XPC message to the runtime service. In Sources/Services/RuntimeLinux/Server/RuntimeService.swift, the createProcess(_:) method deserializes the JSON and extracts the resource limits:

public func createProcess(_ message: XPCMessage) async throws -> XPCMessage {

    let config = try message.processConfig()   // ← JSON → ProcessConfiguration

    try await self.addNewProcess(id, config, stdio) // passes the Rlimit array

}

The ProcessConfiguration struct, defined in Sources/ContainerResource/Container/ProcessConfiguration.swift, carries the ulimits as an array of Rlimit objects:

public struct Rlimit: Sendable, Codable {
    /// The Rlimit type of the Process (e.g. "RLIMIT_NOFILE").
    public let limit: String
    /// Soft limit (the value a process may raise to its hard limit without privilege).
    public let soft: UInt64
    /// Hard limit (the absolute maximum).
    public let hard: UInt64
}

Linux System Call Execution

Inside the VM, the container-runtime-linux component receives the ProcessConfiguration JSON. For each entry in the rlimits array, it:

  1. Maps the limit string (e.g., "RLIMIT_NOFILE") to the corresponding Linux constant
  2. Invokes the setrlimit(2) system call with the soft and hard values
  3. Applies CPU and memory constraints via the cgroup v2 controllers before exec‑ing the user binary

This ensures that by the time the containerized process starts, all resource boundaries are active and enforced by the kernel.

Practical Examples

Running Containers with Custom Limits

To start a container with specific CPU, memory, and file descriptor constraints:


# Give the container a higher file-descriptor limit and a capped process count

container run -n demo \
    --ulimit nofile=4096:8192 \
    --ulimit nproc=256 \
    --cpus 2 \
    --memory 2G \
    alpine sh -c 'ulimit -n; ulimit -u'

Expected output inside the container:


4096
256

Using the Swift API Directly

For programmatic control, construct the configuration objects directly:

import ContainerAPIService

let flags = ContainerAPIServiceClient.Flags(
    cpus: 2,
    memory: "2G",
    ulimits: ["nofile=4096:8192", "nproc=256"]
)

let client = try ContainerAPIServiceClient()
let config = try client.createConfiguration(from: flags)

let processConfig = ProcessConfiguration(
    executable: "/bin/sh",
    arguments: ["-c", "ulimit -n; ulimit -u"],
    environment: [],
    workingDirectory: "/",
    terminal: false,
    user: .id(uid: 0, gid: 0),
    supplementalGroups: [],
    rlimits: config.rlimits           // <-- populated from `flags.ulimits`
)

try await client.createProcess(configuration: processConfig)

Inspecting Limits at Runtime

Retrieve current resource statistics to verify enforcement:

let stats = try await client.getStatistics(containerName: "demo")
print("CPU usage (µs):", stats.cpuUsageUsec ?? "‑")
print("Memory used (MiB):", stats.memoryUsageBytes.map { $0 / (1024*1024) } ?? "‑")
print("RLIMIT_NOFILE soft:", stats.processRlimits.first { $0.limit == "RLIMIT_NOFILE" }?.soft ?? "‑")

Testing Resource Limit Enforcement

The implementation includes comprehensive test coverage to verify the end‑to‑end flow:

These tests ensure that validation errors surface at the client layer while enforcement failures are caught during runtime integration.

Summary

  • CLI parsing happens in Flags.swift and Parser.swift, mapping human‑readable strings to ProcessConfiguration.Rlimit objects and ContainerConfiguration.Resources
  • Validation occurs before any VM communication, checking syntax, limit types, value ranges, and soft‑hard relationships
  • Serialization converts Swift structs into JSON‑encoded XPC messages sent to RuntimeService.createProcess(_:)
  • Enforcement splits by resource type: cgroups control CPU and memory at the container level, while setrlimit applies POSIX ulimits to the individual process inside the VM
  • Source files ContainerSystemConfig.swift, ProcessConfiguration.swift, and RuntimeService.swift form the critical path from user input to kernel constraints

Frequently Asked Questions

What is the correct syntax for the --ulimit flag?

The --ulimit flag requires the format <type>=<soft>[:<hard>], where type is a POSIX resource name (e.g., nofile, nproc, stack), soft is the current limit, and hard is the absolute ceiling. If omitted, the hard limit defaults to match the soft limit. Both limits accept unlimited or non‑negative integers, and the soft limit cannot exceed the hard limit according to the validation logic in Parser.swift.

How do CPU and memory limits differ from ulimits?

CPU and memory limits are container‑wide constraints enforced via Linux cgroups, configured through ContainerConfiguration.Resources and defaulting in ContainerSystemConfig.swift. Ulimits are per‑process POSIX restrictions enforced by the setrlimit system call, stored as an array of Rlimit structs in ProcessConfiguration, and applied to the specific process inside the VM before execution begins.

Why does the parser reject duplicate ulimit types?

The parser in Parser.swift explicitly detects and rejects duplicate ulimit specifications to prevent ambiguous configuration. Since each resource type can only have one soft and one hard limit, specifying the same type twice would create undefined behavior in the runtime, so the client throws a validation error before sending the configuration to the VM.

At what point are resource limits actually enforced?

Limits are enforced inside the VM after the Swift client sends the JSON configuration via XPC to RuntimeService.createProcess(_:). The VM‑side runtime unpacks the ProcessConfiguration, translates Rlimit strings into Linux constants, and invokes setrlimit for ulimits or configures cgroups for CPU and memory immediately before calling exec to start the containerized binary.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →