DEV Community

Alain Airom (Ayrom)
Alain Airom (Ayrom)

Posted on

Inside Apple Containers: Architectural Deep Dive, CLI Parity, and Benchmarking against Podman

Benchmarking Apple Containers vs. Podman

Introduction

With the release of macOS 26 (Tahoe), Apple introduced a native container orchestration model powered by Virtualization.framework and managed by the com.apple.container.apiserver launchd service. Rather than relying on shared-kernel Linux namespaces inside a single monolithic virtual machine (VM), Apple Container creates a lightweight virtual machine—backed by a guest Kata Containers kernel (version 3.32.0-debug)—for every individual container process.

For macOS developers accustomed to Red Hat's daemonless, rootless container engine, Podman, this architectural shift introduces fundamentally different performance characteristics, isolation security boundaries, and command-line interfaces.

In this post, I walk through container-compare (a Go tool built to benchmark and inspect these two runtimes) to explore:

  • How hardware-enforced per-container VM boundaries compare to Podman's shared Linux namespace architecture.

  • CLI mappings, network bridging via vmnet, and native Rosetta 2 binary translation.

  • Real-world execution timing, memory overhead, and JSON metadata differences.

By the way, Podman Desktop handles quite well both Podman and Apple containers engines!


High-Level Architecture Comparison

The container-compare Test Harness

The container-compare suite uses Go wrappers (internal/applecontainer and internal/podman) to issue commands directly to both engines, collecting timing statistics and JSON inspect schema metrics.

Apple Container Architecture: Dedicated Lightweight VMs

Apple Container interacts with the com.apple.container.apiserver daemon. Every container runs within its own lightweight VM isolated via Virtualization.framework.

Podman Architecture: Shared VM & Linux Namespaces

Podman operates daemonless on the host, but on macOS it controls containers inside a shared Fedora Linux VM (Podman Machine) via conmon and crun/runc.


Key Feature Matrix & Technical Differences

Feature / Dimension Apple Container Podman (macOS) Architectural Impact
Isolation Boundary Lightweight VM (Virtualization.framework) MD Linux namespaces & cgroups MD Apple provides hardware-enforced isolation per container; Podman relies on shared kernel boundaries within the Machine VM. MD
Background Daemon com.apple.container.apiserver (launchd) MD Daemonless (podman CLI) MD Apple requires container system start; Podman requires no persistent host service. MD
Guest Kernel Kata Containers kernel (3.32.0-debug) MD+ 1 Shared Fedora kernel inside Podman Machine MD Apple allows per-VM kernel configurations. MD
x86_64 Emulation Native --rosetta flag MD QEMU user emulation MD+ 1 Apple leverages macOS Rosetta 2 translation inside the VM for faster x86 execution on Apple Silicon. MD+ 1
Network IP Visibility Full vmnet per-container IP (ADDR column) MD+ 2 Shared bridge (CNI / Netavark) MD Apple exposes dedicated VM IPs directly in container list. MD+ 1

CLI Parity & Developer Workflow

Commands on Apple Container map closely to Docker and Podman conventions:

# System Service Operations
container system start              # Start launchd API server
podman machine start                # Start Podman Machine Linux VM

# Container Lifecycle
container run -d --name web nginx   # Apple: Boots dedicated VM + container
podman run -d --name web nginx      # Podman: Forks process in shared VM

# Native Rosetta 2 Execution on Apple Silicon
container run --rosetta --rm amd64/ubuntu:22.04 uname -m

# Log Extraction
container logs --boot web           # Apple: Extract VM boot logs
podman logs web                     # Podman: Standard container output logs
Enter fullscreen mode Exit fullscreen mode

Metadata Schema Differences (inspect)

Comparing output formats highlights the VM vs. namespace distinction.

  • Apple Container (container inspect): returns a structured JSON payload detailing allocated hardware resources (memoryInBytes, cpus) and network configurations:
[
  {
    "status": "running",
    "networks": [
      {
        "address": "192.168.64.3/24",
        "gateway": "192.168.64.1",
        "hostname": "my-container.test.",
        "network": "default"
      }
    ],
    "configuration": {
      "id": "my-container",
      "hostname": "my-container",
      "resources": {
        "cpus": 4,
        "memoryInBytes": 1073741824
      },
      "mounts": []
    }
  }
]
Enter fullscreen mode Exit fullscreen mode
  • Podman (podman inspect): uses OCI/Docker-compatible inspection schemas, nesting status under State and network information under NetworkSettings:
[
  {
    "Id": "abc123...",
    "Name": "/my-container",
    "State": {
      "Status": "running",
      "Running": true,
      "ExitCode": 0
    },
    "NetworkSettings": {
      "IPAddress": "",
      "Networks": {
        "podman": { "IPAddress": "10.88.0.2" }
      }
    },
    "HostConfig": {
      "Memory": 0,
      "NanoCpus": 0
    }
  }
]
Enter fullscreen mode Exit fullscreen mode

Implementation Code Excerpts

Apple Container Go Client (internal/applecontainer/client.go)

The Client handles execution safely via direct parameter arrays to eliminate shell injection vulnerabilities:

// Package applecontainer wraps the Apple Container CLI (`container`).
//
// Apple Container (https://github.com/apple/container) is an open-source
// tool from Apple that creates and runs Linux containers as lightweight
// virtual machines on macOS. It is written in Swift and optimised for
// Apple Silicon.
//
// Key architectural traits (v1.3.1):
//   - Each container runs as a dedicated lightweight VM using
//     Virtualization.framework (macOS 26+).
//   - OCI-compatible: pulls/pushes to any standard OCI registry.
//   - CLI convention mirrors Docker/Podman closely.
//   - macOS 26 (Tahoe) required for full network isolation.
//
// Security: all user-supplied strings are validated against strict
// allowlists before use; arguments are passed as a []string to
// exec.Command — no shell is ever invoked.
//
// Validated against Apple Container v1.3.1 (released 2026-08-29).
package applecontainer

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "os/exec"
    "regexp"
    "strings"
    "time"
)
// ....
// RunContainerDetached starts a container in the background.
// Equivalent CLI command: container run --detach --name <name> <image> [<args>...]
func (c *Client) RunContainerDetached(
    ctx context.Context,
    name, image string,
    cmdArgs ...string,
) (string, time.Duration, error) {
    if err := validateContainerName(name); err != nil {
        return "", 0, err
    }
    if err := validateImage(image); err != nil {
        return "", 0, err
    }
    for _, a := range cmdArgs {
        if err := validateSafeArg(a); err != nil {
            return "", 0, fmt.Errorf("unsafe container argument: %w", err)
        }
    }
    args := append([]string{"run", "--detach", "--name", name, image}, cmdArgs...)
    start := time.Now()
    out, err := c.runner.Run(ctx, args...)
    return strings.TrimSpace(out), time.Since(start), err
}
Enter fullscreen mode Exit fullscreen mode

Podman Go Client (internal/applecontainer/client.go)

The Podman Go handles execution safely via Podman in detached mode:

// Package podman wraps the Podman CLI (`podman`).
//
// Podman (https://podman.io) is a daemonless, rootless OCI container engine
// from Red Hat. It is the locally-installed Docker replacement on this machine.
//
// Key architectural traits:
//   - Daemonless: each `podman` invocation is a standalone process.
//   - Rootless by default: containers run as the calling user.
//   - OCI-compatible: uses the same image format and registry protocol.
//   - Uses kernel namespaces + cgroups for isolation (Linux-side).
//   - On macOS, Podman runs inside a Linux VM managed by Podman Machine.
//
// This package mirrors the applecontainer package API so the comparator
// can drive both runtimes with the same interface.
//
// Security: all user-supplied strings are validated against strict
// allowlists before use; no shell expansion takes place.
package podman

import (
    "bytes"
    "context"
    "fmt"
    "os/exec"
    "regexp"
    "strings"
    "time"
)

// ...

// RunContainer starts a container and waits for exit (foreground).
//
// Podman run flags compared to Apple Container:
//   - `--rm`    → remove on exit   (same)
//   - `--name`  → assign name      (same)
//   - `--cpus`  → CPU quota        (cgroup-based, unlike Apple Container VM CPU)
//   - `--memory`→ memory limit     (cgroup limit, not VM RAM)
//   - `--userns=keep-id` → rootless UID mapping (Podman-specific)
//
// Equivalent CLI command:
//
//  podman run --rm --name <name> <image> [<args>...]
func (c *Client) RunContainer(
    ctx context.Context,
    name, image string,
    cmdArgs ...string,
) (string, time.Duration, error) {
    if err := validateContainerName(name); err != nil {
        return "", 0, err
    }
    if err := validateImage(image); err != nil {
        return "", 0, err
    }
    for _, a := range cmdArgs {
        if err := validateSafeArg(a); err != nil {
            return "", 0, fmt.Errorf("unsafe container argument: %w", err)
        }
    }
    args := append([]string{"run", "--rm", "--name", name, image}, cmdArgs...)
    start := time.Now()
    out, err := c.runner.Run(ctx, args...)
    return out, time.Since(start), err
}

// RunContainerDetached starts a container in the background.
//
// Equivalent CLI command:
//
//  podman run --detach --name <name> <image> [<args>...]
func (c *Client) RunContainerDetached(
    ctx context.Context,
    name, image string,
    cmdArgs ...string,
) (string, time.Duration, error) {
    if err := validateContainerName(name); err != nil {
        return "", 0, err
    }
    if err := validateImage(image); err != nil {
        return "", 0, err
    }
    for _, a := range cmdArgs {
        if err := validateSafeArg(a); err != nil {
            return "", 0, fmt.Errorf("unsafe container argument: %w", err)
        }
    }
    args := append([]string{"run", "--detach", "--name", name, image}, cmdArgs...)
    start := time.Now()
    out, err := c.runner.Run(ctx, args...)
    return strings.TrimSpace(out), time.Since(start), err
}

// ListContainers returns containers as JSON.
//
// Educational note:
//   - Podman list output does NOT include an IP column by default
//     (unlike Apple Container which always shows VM IPs).
//   - Use `podman inspect` to retrieve IP info for Podman containers.
//
// Equivalent CLI command:
//
//  podman ps [--all] --format json
func (c *Client) ListContainers(ctx context.Context, all bool) (string, error) {
    args := []string{"ps", "--format", "json"}
    if all {
        args = append(args, "--all")
    }
    return c.runner.Run(ctx, args...)
}

// InspectContainer returns detailed JSON for a named container.
//
// Key JSON fields:
//   - State.Status              → "running" | "exited"
//   - NetworkSettings.IPAddress → container IP (bridge network)
//   - HostConfig.NanoCpus       → CPU limit
//   - HostConfig.Memory         → memory limit in bytes
//
// Equivalent CLI command:
//
//  podman inspect <name>
func (c *Client) InspectContainer(ctx context.Context, name string) (string, error) {
    if err := validateContainerName(name); err != nil {
        return "", err
    }
    return c.runner.Run(ctx, "inspect", name)
}

// InspectImage returns detailed JSON for a local image.
//
// Equivalent CLI command:
//
//  podman inspect --type image <image>
func (c *Client) InspectImage(ctx context.Context, image string) (string, error) {
    if err := validateImage(image); err != nil {
        return "", err
    }
    return c.runner.Run(ctx, "inspect", "--type", "image", image)
}

// StopContainer stops a container gracefully.
//
// Educational note:
//   - Podman sends SIGTERM then SIGKILL (after --time seconds).
//   - Unlike Apple Container, no VM is shut down — only the Linux process.
//
// Equivalent CLI command:
//
//  podman stop <name>
func (c *Client) StopContainer(ctx context.Context, name string) (string, time.Duration, error) {
    if err := validateContainerName(name); err != nil {
        return "", 0, err
    }
    start := time.Now()
    out, err := c.runner.Run(ctx, "stop", name)
    return out, time.Since(start), err
}

// RemoveContainer deletes a stopped container.
//
// Equivalent CLI command:
//
//  podman rm <name>
func (c *Client) RemoveContainer(ctx context.Context, name string) (string, error) {
    if err := validateContainerName(name); err != nil {
        return "", err
    }
    return c.runner.Run(ctx, "rm", name)
}

// RemoveImage removes a local image.
//
// Equivalent CLI command:
//
//  podman rmi <image>
func (c *Client) RemoveImage(ctx context.Context, image string) (string, error) {
    if err := validateImage(image); err != nil {
        return "", err
    }
    return c.runner.Run(ctx, "rmi", image)
}

// Logs returns container stdout/stderr logs.
//
// Educational note:
//   - Podman does not have a --boot flag (no VM boot logs).
//   - Boot parameter is accepted for API compatibility but ignored.
//
// Equivalent CLI command:
//
//  podman logs <name>
func (c *Client) Logs(ctx context.Context, name string, _ bool) (string, error) {
    if err := validateContainerName(name); err != nil {
        return "", err
    }
    return c.runner.Run(ctx, "logs", name)
}

// Stats returns a one-shot resource stats snapshot.
//
// Educational note:
//   - `podman stats --no-stream` returns one sample then exits.
//   - Output format: CONTAINER  CPU%  MEM USAGE/LIMIT  NET I/O  BLOCK I/O
//
// Equivalent CLI command:
//
//  podman stats --no-stream <name>
func (c *Client) Stats(ctx context.Context, name string) (string, error) {
    if err := validateContainerName(name); err != nil {
        return "", err
    }
    return c.runner.Run(ctx, "stats", "--no-stream", name)
}

// SystemInfo returns Podman system information.
//
// Educational note:
//   - Podman has no "system start/stop" — it is daemonless.
//   - `podman system info` shows host + store + registry info.
//
// Equivalent CLI command:
//
//  podman system info --format json
func (c *Client) SystemInfo(ctx context.Context) (string, error) {
    return c.runner.Run(ctx, "system", "info", "--format", "json")
}

// DiskUsage reports disk usage of images/containers/volumes.
//
// Equivalent CLI command:
//
//  podman system df --format json
func (c *Client) DiskUsage(ctx context.Context) (string, error) {
    return c.runner.Run(ctx, "system", "df", "--format", "json")
}

// NetworkList lists container networks.
//
// Equivalent CLI command:
//
//  podman network ls --format json
func (c *Client) NetworkList(ctx context.Context) (string, error) {
    return c.runner.Run(ctx, "network", "ls", "--format", "json")
}

// VolumeList lists named volumes.
//
// Equivalent CLI command:
//
//  podman volume ls --format json
func (c *Client) VolumeList(ctx context.Context) (string, error) {
    return c.runner.Run(ctx, "volume", "ls", "--format", "json")
}

// CommandLine returns the shell command string for educational display.
func CommandLine(args ...string) string {
    return "podman " + strings.Join(args, " ")
}

// ── realRunner ────────────────────────────────────────────────────────────────

// realRunner implements cmdRunner using the installed `podman` binary.
//
// Security: the executable is the literal string "podman", resolved by the
// OS PATH at runtime. User-supplied data flows only into args, which are
// validated before reaching here. No shell is invoked.
type realRunner struct{ verbose bool }

// Run implements CmdRunner.
func (r *realRunner) Run(ctx context.Context, args ...string) (string, error) {
    cmd := exec.CommandContext(ctx, "podman", args...)
    var stdout, stderr bytes.Buffer
    cmd.Stdout = &stdout
    cmd.Stderr = &stderr

    if r.verbose {
        fmt.Printf("[podman] podman %s\n", strings.Join(args, " "))
    }

    if err := cmd.Run(); err != nil {
        return "", fmt.Errorf(
            "podman %q failed: %w\n  stderr: %s",
            strings.Join(args, " "), err, strings.TrimSpace(stderr.String()),
        )
    }
    out := stdout.String()
    if r.verbose && out != "" {
        fmt.Printf("[podman] output:\n%s\n", out)
    }
    return out, nil
}

Enter fullscreen mode Exit fullscreen mode

Dashboard View Model Generator (internal/podman/client.go)

Generates a single-file, zero-dependency HTML dashboard to display benchmarking data and visual charts:

// Package reporter — dashboard.go
//
// WriteHTMLDashboard generates a fully self-contained, single-file HTML
// dashboard that visualises all comparison and benchmark data produced by the
// container-compare tool.
//
// Design goals:
//   - Zero external dependencies: no CDN, no JS framework, no fonts fetch.
//     Everything is inlined — CSS, all content, SVG charts built by template.
//   - Tab-based navigation (pure CSS, no JavaScript required).
//   - Colour-coded status badges, SVG horizontal bar charts, timing tables.
//   - Safe HTML generation via html/template throughout; no template.HTML
//     conversions of dynamic data (avoids XSS risk).
//   - Written to ./output/ with an ISO-8601 timestamp prefix.
package reporter

import (
    "fmt"
    "html/template"
    "math"
    "os"
    "path/filepath"
    "time"

    "github.com/apple-container-update/internal/comparator"
)
// ...
// buildBenchRows converts BenchmarkSummary into pre-computed benchRow values.
func buildBenchRows(bs *comparator.BenchmarkSummary) []benchRow {
    rows := make([]benchRow, 0, len(bs.Results))
    for _, pair := range bs.Results {
        appleMS := msFloat(pair.Apple.Mean)
        podmanMS := msFloat(pair.Podman.Mean)
        maxMS := math.Max(appleMS, podmanMS)
        if maxMS == 0 {
            maxMS = 1
        }
        appleW := int(float64(barMaxPx) * appleMS / maxMS)
        podmanW := int(float64(barMaxPx) * podmanMS / maxMS)

        rows = append(rows, benchRow{
            OpName:      pair.OperationName,
            AppleMS:     appleMS,
            AppleBarW:   appleW,
            PodmanMS:    podmanMS,
            PodmanBarW:  podmanW,
            SVGHeight:   104,
        })
    }
    return rows
}
Enter fullscreen mode Exit fullscreen mode

Performance & Benchmarking Observations

Initial benchmarks using container-compare benchmark --image alpine:latest highlight key performance tradeoffs:

Metric / Operation Apple Container Podman (macOS) Tradeoff / Analysis
First Container Startup ~1.0 – 3.0 s MD ~0.3 – 1.0 s MD Apple incurs cold VM boot overhead per container. MD
Subsequent Startups ~0.5 – 1.5 s MD ~0.3 – 0.8 s MD Podman forks a process in an active shared VM. MD
Memory Overhead ~100–200 MB per container MD ~1–10 MB per container MD Apple allocates dedicated hypervisor overhead per instance. MD
x86_64 Translation High (Rosetta 2) MD+ 1 Low/Moderate (QEMU) MD+ 1 Rosetta 2 significantly reduces runtime CPU overhead for legacy x86 images. MD+ 1

Conclusion

Both Apple Container and Podman offer compelling container development environments on macOS, but address distinct priorities:

  • Choose Apple Container when strong, hardware-isolated multi-tenant security is required (each container runs in its own kernel boundary), when running non-native x86_64 workloads via Rosetta 2, or when testing native macOS 26 vmnet capabilities.
  • Choose Podman when startup speed, low per-container memory footprint, and standard Docker-CLI / Kubernetes (podman kube) workflow compatibility are paramount.

Thanks for reading 🚛

Links

Top comments (0)