From Dockerfile to Running Process

In apple/container, the familiar container build and container run commands hide a sequence of different state changes. This chapter follows an image from a Dockerfile through the BuildKit builder and local OCI store, then follows that image into a per-container Linux VM and its first process. The goal is practical: once the transitions are clear, readers can tell whether a failure belongs to image construction, guest boot, process startup, observation, or cleanup.

The key distinction is between an artifact and an execution. A Dockerfile describes files and process configuration that can be packaged as an OCI image. The Mac-side system must still supply a Linux kernel, initial guest filesystem, virtual hardware, network, storage, and process service around that image. apple/container keeps the artifact side familiar while making the execution side explicitly VM-backed.

The familiar image contract runs through a VM

A build starts with a context directory and a Dockerfile or Containerfile. The context is the material the builder is allowed to read for instructions such as COPY; the file describes how to assemble the image and what its default process should be. container build passes that work to BuildKit rather than implementing each Dockerfile instruction in the Mac-side CLI.

BuildKit is not an invisible library call in this design. The project starts a utility container for the builder, and that container runs in its own lightweight VM. The builder has its own CPU and memory allocation, network configuration, and optional host integrations such as SSH forwarding or Rosetta support. The CLI connects to the builder through a virtual socket, sends the build context and options, and waits for BuildKit to return an output. This arrangement keeps image construction inside the same broad isolation model as application execution, but it also means that a large build competes for resources as another guest workload.

The builder is a managed resource rather than a disposable subprocess for every instruction. When a build begins, apple/container can reuse an existing BuildKit container if its image, resources, environment, DNS, and SSH requirements still match. If those requirements change, the project stops and recreates the builder. That reconciliation makes repeated builds efficient in the ordinary case, but it also gives the builder its own state and failure surface. A build can fail before the application image is touched because the builder cannot start, cannot be reached, or does not have the resources its workload needs.

The build request can carry build arguments, secrets, an optional SSH agent, labels, cache settings, a target stage, and one or more output formats. The central output is an OCI image. After BuildKit produces an OCI archive, container loads and unpacks it into the local image/content store, then applies the requested tags. A local image reference is therefore a handle into a managed store, not simply a directory copied into a future guest.

That store is what makes the workflow interoperable. The same project can pull an image from a registry, tag it for a repository, push it to a registry, save it to an archive, or load an archive produced elsewhere. The format boundary is deliberately conventional. A Python web-server image built by container can be published for another OCI-compatible runtime, and an image built elsewhere can be selected for a container guest.

The convention has a limit: an OCI image does not carry the macOS Virtualization configuration or the host’s network policy with it. The image contains application files and metadata; the runtime supplies the Linux environment in which those files execute. An image’s portability is consequently strongest at the artifact boundary. The behavior of a mounted host directory, a published port, a guest kernel feature, or a local DNS setup still depends on the destination runtime.

Platform selection is part of the artifact story

A single image reference can represent more than one platform. container build can request arm64 and amd64 variants, and the resulting multi-platform tag can be pushed like a single-platform image. On an Apple-silicon Mac, an arm64 variant executes natively in the Linux guest. An amd64 variant can run with Rosetta translation when the relevant runtime setting is enabled, so a command such as uname reports an x86-64 environment even though the physical host is Apple silicon.

Platform selection happens at more than one point. The build can create a set of variants, the image store can retain them under one reference, and run can select a platform when it resolves the image. A developer testing only the host architecture may never exercise the amd64 files, libraries, or process assumptions that will matter on an x86-64 server. Conversely, building a multi-platform image does not imply that every instruction or dependency is equally portable; the builder still has to produce a valid variant for each requested target.

This is useful for testing a target that differs from the developer’s default architecture, but it is not the same as eliminating architectural differences. Translation is a runtime condition, and an image must actually contain the requested variant. Build configuration, guest kernel support, and application assumptions still determine whether the workload works. The portable unit is the multi-platform image description; the performance and compatibility of each execution remain properties of the selected platform and runtime.

run is several transitions, not one event

Once an image is available, container run begins by turning command-line options into a container configuration. The configuration includes the image, init process, resources, mounts, networks, platform, and management choices. The API service persists that description and prepares a root filesystem snapshot and initial guest filesystem. This is the created state: the project knows what should run, but no guest process is running yet.

The distinction is visible in the command model. A user can create a container without starting it, inspect the saved configuration, and start it later. run is the convenient composition that creates the resource, bootstraps its runtime, and starts its init process. That composition is useful for an interactive command, but it does not turn the underlying stages into one indivisible operation. If image preparation succeeds and guest bootstrap fails, the saved resource may still be present and may need to be inspected or removed according to its lifecycle options.

The next transition is bootstrap. The API service registers the container’s runtime helper and connects to it. The helper reads the saved configuration, selects the kernel and initial filesystem, allocates network attachments, creates the virtualization-backed Linux container, attaches filesystems and interfaces, and prepares standard I/O and boot logging. When the VM and guest support have been created, the runtime is booted. The application’s init process has still not necessarily started.

Bootstrap is where the image becomes a particular guest. The image snapshot supplies the container root filesystem, but the runtime adds the initial filesystem and kernel selected for the Mac-side system. It also turns abstract configuration into concrete devices and connections: a network attachment becomes a guest interface, a filesystem setting becomes a mount, and an attached terminal becomes file handles that can cross the runtime boundary. The same image can consequently produce different live environments when platform, kernel, network, or host-integration choices differ.

Starting the init process produces the running state. By default, the command supplied by the image or by run becomes PID 1 inside the container. PID 1 has responsibilities that ordinary application processes often do not handle well: it receives signals intended for the container and must reap orphaned child processes. The project’s --init option inserts a lightweight init process that forwards signals and reaps zombies before the application runs. That is a process-management choice inside the guest, separate from the VM’s existence.

The sequence looks like this:

sequenceDiagram participant D as Developer CLI participant B as BuildKit builder VM participant S as OCI image store participant A as API server participant R as Per-container runtime participant V as Linux guest VM D->>B: Send context and build options B->>B: Execute Dockerfile or Containerfile B-->>S: Produce OCI image output S-->>D: Return local image reference D->>A: Create container from image A->>S: Resolve image and prepare snapshot A-->>D: Persist created container D->>A: Bootstrap container A->>R: Register and connect runtime helper R->>V: Create VM, attach kernel, rootfs, and network V-->>R: Guest support is ready D->>A: Start init process A->>R: Start PID 1 R->>V: Launch workload V-->>D: Stream process output when attached opt container is running D->>A: exec, logs, or stats A->>R: Request guest operation R->>V: Run or observe process V-->>D: Return output or statistics end D->>A: Stop container A->>R: Signal, wait, and stop guest R-->>A: Close network sessions and forwarders alt auto-remove requested A->>A: Delete saved container bundle else retained container A->>A: Keep configuration and logs end

The diagram separates three events that users often collapse into “the container started.” Creation establishes durable intent. Bootstrap realizes the guest and its infrastructure. Process start gives the workload a running PID 1. Each can fail for a different reason. An invalid image reference can prevent creation; a missing kernel or network attachment can prevent boot; an application error can make PID 1 exit after the VM was healthy.

The sequence is also not a promise that every failed stage rolls back every earlier stage immediately. The project has explicit cleanup paths: a failed run attempts to delete the container, a failed runtime startup stops the guest and closes sessions, and a normal exit lets the API service decide whether to retain or remove the saved resource. That distinction matters when diagnosing a stopped container after an interrupted command. A stopped resource can be evidence of a completed cleanup policy, an available restart point, or a partial failure that requires inspection; the state alone does not identify which explanation is correct.

The distinction also explains attachment behavior. In the foreground, the CLI connects standard input and output to the guest process and waits for its exit code. With --detach, the CLI can return after the process has been started while the API server and runtime continue to own the container. A terminal closing is not the same event as the process exiting. The runtime records output in container logs so the workload remains inspectable after an attached command has returned.

Observation and stop have their own state

After PID 1 is running, container exec creates another process inside the same guest rather than creating another container VM. container logs can read the workload’s standard output or the VM boot log. container stats asks the live runtime for CPU, memory, network, block-I/O, and process information. These commands cross the API and runtime boundaries, but they do not change the image or recreate the guest merely because they observe it.

This separation is valuable when the visible symptom is vague. If exec cannot run, the relevant question may be whether the container is still in a booted or running state, not whether the image can be pulled again. If application output is missing, boot logs and standard-output logs answer different questions. If statistics are unavailable, the problem may be a stopped runtime rather than an unhealthy application. The commands expose different evidence because the project keeps process, guest, and stored-resource state distinct.

Stopping reverses the startup sequence, but it is not just a deletion. The API service asks the runtime to stop the workload gracefully, beginning with the configured signal and allowing a timeout. If the process does not exit, the runtime can force termination and then stop the VM. It closes host port forwarders and network sessions as part of cleanup. The resulting stopped container may retain its configuration, root filesystem, and logs, while the live VM, process handles, network attachment, and forwarders are gone.

A forced kill is therefore different from a graceful stop even when both produce a stopped container. Graceful termination gives the application a chance to flush data and release resources; a forced signal prioritizes ending the workload. The API service still has to reconcile the runtime’s exit with its own stored snapshot and remove the live helper. The final state tells the user that the guest is no longer running, but the exit path determines how much application-level shutdown work had a chance to complete.

The --rm option changes what happens after the stopped state is recorded. With auto-removal, the API service deletes the saved container resource after the runtime exits. Without it, the stopped resource remains available for listing, inspection, logs, export, or a later start. This flag applies to the container lifecycle; it should not be read as a promise that every named or anonymous volume, image layer, or builder cache is removed as well.

A useful debugging order follows the transitions. First ask whether the image was built or fetched successfully. Then ask whether the container was created, whether its runtime bootstrapped a VM, and whether PID 1 started. Only after those questions should a connection failure be interpreted as an application or network problem. The project’s separation of image, API, runtime, and guest state makes that order possible.

The same image can travel, but the path around it changes

The web-server example exposes the design’s most important balance. Its image can be built from a familiar Dockerfile, tagged and pushed through ordinary registry operations, and selected as an arm64 or amd64 artifact. On a Mac, however, the server still needs a Linux guest, an allocated network interface, and whatever host port or DNS path makes it reachable. Another OCI runtime may accept the image while offering different defaults for networking, mounts, kernel features, or process supervision.

apple/container therefore offers portability at the point where portability is most valuable: the packaged application and its declared execution metadata. It does not pretend that a portable image removes the operating system beneath it. The VM-backed lifecycle is the mechanism that makes Linux execution possible on macOS, and it is also the source of the next set of questions: which guest and host boundaries are protected by default, which can be opened deliberately, and what does each opening cost?

References