How to Use the Init Process to Handle Zombie Processes in Apple Container

Use the --init flag when running containers to inject the vminitd init process as PID 1, which automatically reaps zombie processes via waitpid() and forwards signals to your application.

Zombie processes accumulate in containers when the main process lacks init system responsibilities. The apple/container repository solves this with a lightweight init process that runs as PID 1 to reap orphaned children and handle signal forwarding, preventing the PID exhaustion that un-reaped zombies cause.

Why Zombie Processes Occur Without an Init System

When you start a container without the --init flag, your specified command becomes PID 1 inside the lightweight virtual machine. In Linux, PID 1 has two critical responsibilities that most applications do not implement:

  1. Signal forwarding – receiving signals like SIGTERM and SIGINT from the kernel and propagating them to child processes.
  2. Reaping orphaned children – calling waitpid() to collect exit statuses when child processes terminate.

Without these behaviors, terminated child processes enter a zombie state (defunct processes showing as <defunct> in ps output). The kernel keeps these entries in the process table because PID 1 never collected their exit status. Over time, zombies waste PID slots and can eventually prevent new process creation.

Enabling the Init Process with the --init Flag

The Container toolchain provides the vminitd binary as a lightweight init process. When you pass --init to container run or container create, the tooling inserts vminitd as PID 1 and runs your command as a child process.

The default init image is ghcr.io/apple/containerization/vminit:<tag>, which contains the vminitd binary. As documented in docs/command-reference.md (lines 56-57) and the "Run a container with a provided init process" section of docs/how-to.md (lines 43-44), this flag eliminates zombie accumulation without requiring changes to your application code.


# Run with the built-in init process

container run --init ubuntu:latest my-application

# Or create then start

container create --init --name my-container ubuntu:latest my-application
container start my-container

Signal Forwarding and Zombie Reaping

The vminitd process handles the two PID 1 responsibilities that prevent zombie accumulation:

  • Continuous reaping: It calls waitpid(-1, ...) in a loop to collect exit statuses from any orphaned child process, immediately removing zombies from the process table.
  • Signal propagation: It receives signals from the kernel and forwards them to the real application process, ensuring graceful shutdowns work correctly.

This behavior is implemented in the vminitd binary located at /sbin/vminitd within the init image.

Verifying Zombie Process Handling

The integration test TestCLIRunCommand in Tests/IntegrationTests/Run/TestCLIRunCommand.swift (lines 553-564) validates that containers run with --init leave zero zombie processes. The test spawns short-lived child processes and asserts that zombieCount == 0 after they exit.

You can verify this behavior manually by spawning background processes that exit immediately:


# Start a container that spawns a short-lived child

container run --init --rm ubuntu:latest sh -c "sleep 1 & wait"

# Inspect from the host to confirm no zombies remain

# The test suite automates this verification

Without --init, the same command would leave zombie entries because the shell running as PID 1 would not reap the background sleep process.

Building a Custom Init Image

For scenarios requiring additional initialization—such as configuring network settings or writing to /dev/kmsg before the main process starts—you can supply a custom init image via the --init-image flag. The custom image wraps the default vminitd binary, performs additional work, then execs the real init binary.

As shown in docs/how-to.md (lines 68-89), the wrapper must eventually execute /sbin/vminitd.real to assume init responsibilities.

Step 1 – Create a Go wrapper:

// wrapper.go
package main

import (
    "os"
    "syscall"
)

func main() {
    // Optional: Write debugging info to kernel log
    kmsg, err := os.OpenFile("/dev/kmsg", os.O_WRONLY, 0)
    if err == nil {
        kmsg.WriteString("<6>custom-init: === CUSTOM INIT IMAGE RUNNING ===\n")
        kmsg.Close()
    }

    // Hand off to the real vminitd binary
    if err := syscall.Exec("/sbin/vminitd.real", os.Args, os.Environ()); err != nil {
        os.Exit(1)
    }
}

Step 2 – Build the wrapper:

CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o wrapper wrapper.go

Step 3 – Construct the Containerfile:

FROM ghcr.io/apple/containerization/vminit:0.34.0 AS base

FROM ghcr.io/apple/containerization/vminit:0.34.0
COPY --from=base /sbin/vminitd /sbin/vminitd.real
COPY wrapper /sbin/vminitd

Step 4 – Build and run:

container build -t local/custom-init:latest .
container run --init-image local/custom-init:latest alpine:latest echo "hello"

Confirm the custom logic executed by checking the VM boot log:

container logs --boot my-container | grep custom-init

# → <6>custom-init: === CUSTOM INIT IMAGE RUNNING ===

The scripts/install-init.sh script provides a helper for building the vminit image and loading it into the container VM during development.

Summary

  • Use --init to inject vminitd as PID 1, which automatically reaps zombie processes via waitpid(-1, ...) and forwards signals to your application.
  • Without --init, your command runs as PID 1 and likely accumulates zombies because it lacks the required reaping logic.
  • Use --init-image to supply a custom init wrapper when you need pre-startup initialization logic, ensuring the wrapper eventually executes /sbin/vminitd.real.
  • Verification is available through the integration tests in Tests/IntegrationTests/Run/TestCLIRunCommand.swift, which assert zero zombies after container execution.

Frequently Asked Questions

What is the default init image used by the --init flag?

The default init image is ghcr.io/apple/containerization/vminit:<tag>, which contains the vminitd binary. This binary runs as PID 1 to handle signal forwarding and continuously calls waitpid() to reap orphaned child processes, preventing zombie accumulation.

How does the init process prevent zombie processes?

The vminitd process prevents zombies by calling waitpid(-1, ...) in a continuous loop. This system call collects the exit status of any terminated child process, allowing the kernel to remove the process from the process table immediately rather than leaving it in a zombie state.

Can I run additional setup scripts before my main application starts?

Yes, by using the --init-image flag to specify a custom init image. You can create a wrapper script or binary that performs initialization tasks—such as writing to /dev/kmsg or configuring the environment—and then uses exec to replace itself with the real vminitd binary at /sbin/vminitd.real.

Where is the init process functionality tested in the source code?

The zombie reaping behavior is validated in Tests/IntegrationTests/Run/TestCLIRunCommand.swift (lines 553-564), which runs a container with the --init flag and asserts that zombieCount == 0. Additional tests for custom init images reside in Tests/IntegrationTests/Run/TestCLIRunInitImage.swift.

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 →