How to Gracefully Stop a Container with a Custom Timeout in apple/container

Use the container stop command with the --timeout flag or instantiate ContainerStopOptions with timeoutInSeconds to send SIGTERM, wait for your specified duration, then automatically force-kill the container if it hasn't exited.

The apple/container repository provides a robust runtime for managing container lifecycles with fine-grained control over shutdown behavior. Gracefully stopping a container with a custom timeout ensures that applications have sufficient time to flush state, close connections, and perform cleanup operations before the system terminates the process. This mechanism is implemented through the ContainerStopOptions structure and coordinated between the CLI, RuntimeClient, and RuntimeService via XPC communication.

Understanding the Stop Mechanism

The graceful stop implementation in apple/container follows a strict state machine that transitions containers from running to stopped while respecting your specified timeout. When you initiate a stop command, the runtime first moves the container to a stopping state, sends the configured signal, and waits for either the process to exit or the timeout to elapse.

ContainerStopOptions Structure

The ContainerStopOptions struct defined in ContainerStopOptions.swift encapsulates the two configurable parameters for container shutdown:

  • signal: An optional String representing the Unix signal to send (defaults to SIGTERM if not specified)
  • timeoutInSeconds: An Int representing the grace period before forceful termination (defaults to 10 seconds)

Here is how the options are structured in the source:

import ContainerResource

let stopOpts = ContainerStopOptions(signal: "SIGTERM", timeoutInSeconds: 15)

State Transitions and Signal Handling

According to the implementation in RuntimeService.swift (lines 508-540), the stop process follows this sequence:

  1. State Transition: The container state moves from .running to .stopping
  2. Signal Delivery: The runtime sends the specified signal (or SIGTERM by default) to the container's init process
  3. Timeout Wait: A timer starts for the duration specified in timeoutInSeconds
  4. Conditional Kill: If the container is still running after the timeout expires, the runtime sends SIGKILL
  5. Final State: The container transitions to .stopped regardless of which signal terminated it

This ensures that containers cannot hang indefinitely while still providing a window for graceful cleanup.

Using the CLI to Stop Containers

The command-line interface provides the most direct way to gracefully stop a container with a custom timeout. The StopCommand.swift implementation parses the --signal and --timeout flags before constructing the ContainerStopOptions and invoking RuntimeClient.stop.

Stop a single container with a 15-second grace period:

container stop my-app --timeout 15

Specify both a custom signal and timeout:

container stop my-app --timeout 20 --signal SIGTERM

If you omit the --timeout flag, the system defaults to 10 seconds. If you omit the --signal flag, it defaults to SIGTERM.

Programmatic Implementation with Swift

You can also trigger graceful stops programmatically using the RuntimeClient API. This approach is useful when building container management tools or integrating with orchestration systems.

RuntimeClient Approach

The RuntimeClient.swift file (lines 181-192) provides a wrapper that packages the ContainerStopOptions into an XPC message and forwards it to the runtime service.

import ContainerResource
import RuntimeClient

let client = RuntimeClient(containerID: "my-app")
let stopOpts = ContainerStopOptions(signal: "SIGTERM", timeoutInSeconds: 15)

Task {
    do {
        try await client.stop(options: stopOpts)
        print("Container stopped gracefully")
    } catch {
        print("Failed to stop container: \(error)")
    }
}

RuntimeService Internals

For developers extending the runtime, the core stop logic in RuntimeService.swift demonstrates how the timeout and signal interact:

public func stop(_ message: XPCMessage) async throws -> XPCMessage {
    let stopOptions = try message.stopOptions()
    let signal = try Signal(stopOptions.signal ?? "SIGTERM")
    let timeout: Duration = .seconds(stopOptions.timeoutInSeconds)

    // Move to the "stopping" state first
    await self.setState(.stopping)

    // Send the signal to the container's init process
    try await self.sendSignal(to: self.containerPID, signal: signal)

    // Wait for the container to exit or for the timeout to elapse
    try await withTimeout(timeout) {
        try await self.waitForContainerExit()
    }

    // If the container is still running after the timeout, force-kill it
    if await self.state == .stopping {
        try await self.sendSignal(to: self.containerPID, signal: .kill)
    }

    // Final state transition
    await self.setState(.stopped)
    return XPCMessage() // empty success response
}

This implementation guarantees that the container reaches the stopped state even if it ignores the initial termination signal.

Default Values and Edge Cases

Understanding the default behavior and edge cases helps prevent unexpected container behavior in production environments.

Default Timeout Behavior If you do not specify a timeout value, the system uses a 10-second default grace period. This provides a reasonable balance between allowing cleanup time and preventing indefinite hangs.

Idempotent Operations The stop operation is idempotent—attempting to stop an already-stopped container returns success without error. This makes the API safe to call in retry loops or concurrent scenarios.

Race Conditions As demonstrated in TestCLIRmRace.swift, attempting to remove a container while it is still in the stopping state can result in errors like "container is not yet stopped and cannot be deleted." Always ensure the container reaches the .stopped state before removal, or handle the corresponding error appropriately.

Summary

  • Primary Keyword Implementation: Use ContainerStopOptions with timeoutInSeconds to control how long the runtime waits before force-killing a container.
  • Default Configuration: The system defaults to 10 seconds and SIGTERM when you gracefully stop a container without specifying custom parameters.
  • Core Files: ContainerStopOptions.swift defines the options structure, RuntimeService.swift (lines 508-540) implements the timeout logic, and RuntimeClient.swift (lines 181-192) provides the client-side wrapper.
  • Safety Mechanisms: The runtime automatically escalates to SIGKILL after the timeout expires, ensuring containers cannot ignore the shutdown request indefinitely.
  • CLI Usage: Use container stop <id> --timeout <seconds> for command-line container management.

Frequently Asked Questions

What is the default timeout when stopping a container?

The default timeout is 10 seconds. If you do not specify the --timeout flag in the CLI or provide a timeoutInSeconds value in ContainerStopOptions, the runtime waits 10 seconds after sending SIGTERM before force-killing the container with SIGKILL.

Can I use a custom signal instead of SIGTERM?

Yes. The ContainerStopOptions struct accepts an optional signal parameter as a String. You can specify any valid Unix signal name (such as SIGINT or SIGHUP) either via the CLI --signal flag or programmatically when constructing the options struct. If omitted, it defaults to SIGTERM.

Is the stop operation idempotent?

Yes. The stop operation is idempotent, meaning you can safely call it multiple times on the same container. If the container is already in the .stopped state, the operation returns success without performing any additional actions or raising errors.

What happens if the container ignores the termination signal?

If the container process does not exit within the specified timeout period, the runtime in RuntimeService.swift automatically sends SIGKILL (signal 9) to force immediate termination. This guarantees that the container eventually reaches the .stopped state, regardless of whether the application handles the initial signal gracefully.

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 →