cli: Add --init to run/create (#1244)

Closes #1225

Add a flag to signify that we'd like to run a minimal init process that
can reap zombie processes. The actual support for this is in the
Containerization library so the plumbing here is very simple.
This commit is contained in:
Danny Canter
2026-02-23 16:02:38 -08:00
committed by GitHub
parent 9f9a7c9a15
commit 899081d094
9 changed files with 121 additions and 20 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
{
"originHash" : "a0d9f0581740922266b0739fae8ec0998c7d5c7d98ff76cccb68a878f27e88ab",
"originHash" : "052cce484c36a6e3389f4630d1fbaf7a73c8902737f282ec0697eedc84c833e9",
"pins" : [
{
"identity" : "async-http-client",
@@ -15,8 +15,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/containerization.git",
"state" : {
"revision" : "185b04af7414db37577e003bad052cc8fc75a5c9",
"version" : "0.26.2"
"revision" : "185bc1ecd7b6d9ef1938da1846e620ab73db8950",
"version" : "0.26.3"
}
},
{
+1 -1
View File
@@ -23,7 +23,7 @@ import PackageDescription
let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0"
let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified"
let builderShimVersion = "0.8.0"
let scVersion = "0.26.2"
let scVersion = "0.26.3"
let package = Package(
name: "container",
@@ -51,6 +51,8 @@ public struct ContainerConfiguration: Sendable, Codable {
public var ssh: Bool = false
/// Whether to mount the rootfs as read-only.
public var readOnly: Bool = false
/// Whether to use a minimal init process inside the container.
public var useInit: Bool = false
enum CodingKeys: String, CodingKey {
case id
@@ -70,6 +72,7 @@ public struct ContainerConfiguration: Sendable, Codable {
case virtualization
case ssh
case readOnly
case useInit
}
/// Create a configuration from the supplied Decoder, initializing missing
@@ -100,6 +103,7 @@ public struct ContainerConfiguration: Sendable, Codable {
virtualization = try container.decodeIfPresent(Bool.self, forKey: .virtualization) ?? false
ssh = try container.decodeIfPresent(Bool.self, forKey: .ssh) ?? false
readOnly = try container.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false
useInit = try container.decodeIfPresent(Bool.self, forKey: .useInit) ?? false
}
public struct DNSConfiguration: Sendable, Codable {
@@ -188,6 +188,7 @@ public struct Flags {
runtime: String?,
ssh: Bool,
tmpFs: [String],
useInit: Bool,
virtualization: Bool,
volumes: [String]
) {
@@ -213,6 +214,7 @@ public struct Flags {
self.runtime = runtime
self.ssh = ssh
self.tmpFs = tmpFs
self.useInit = useInit
self.virtualization = virtualization
self.volumes = volumes
}
@@ -238,6 +240,15 @@ public struct Flags {
)
public var entrypoint: String?
@Flag(name: .customLong("init"), help: "Run an init process inside the container that forwards signals and reaps processes")
public var useInit = false
@Option(
name: .long,
help: .init("Use a custom init image instead of the default", valueName: "image")
)
public var initImage: String?
@Option(
name: .shortAndLong,
help: .init("Set a custom kernel path", valueName: "path"),
@@ -248,12 +259,6 @@ public struct Flags {
)
public var kernel: String?
@Option(
name: .long,
help: .init("Use a custom init image instead of the default", valueName: "image")
)
public var initImage: String?
@Option(name: [.short, .customLong("label")], help: "Add a key=value label to the container")
public var labels: [String] = []
@@ -293,21 +298,24 @@ public struct Flags {
)
public var publishSockets: [String] = []
@Flag(name: .long, help: "Mount the container's root filesystem as read-only")
public var readOnly = false
@Flag(name: [.customLong("rm"), .long], help: "Remove the container after it stops")
public var remove = false
@Flag(name: .long, help: "Enable Rosetta in the container")
public var rosetta = false
@Option(name: .long, help: "Set the runtime handler for the container (default: container-runtime-linux)")
public var runtime: String?
@Flag(name: .long, help: "Forward SSH agent socket to container")
public var ssh = false
@Option(name: .customLong("tmpfs"), help: "Add a tmpfs mount to the container at the given path")
public var tmpFs: [String] = []
@Option(name: [.customLong("volume"), .short], help: "Bind mount a volume into the container")
public var volumes: [String] = []
@Flag(
name: .long,
help:
@@ -315,11 +323,8 @@ public struct Flags {
)
public var virtualization: Bool = false
@Flag(name: .long, help: "Mount the container's root filesystem as read-only")
public var readOnly = false
@Option(name: .long, help: "Set the runtime handler for the container (default: container-runtime-linux)")
public var runtime: String?
@Option(name: [.customLong("volume"), .short], help: "Bind mount a volume into the container")
public var volumes: [String] = []
}
public struct Progress: ParsableArguments {
@@ -248,6 +248,7 @@ public struct Utility {
config.ssh = management.ssh
config.readOnly = management.readOnly
config.useInit = management.useInit
if let runtime = management.runtime {
config.runtimeHandler = runtime
@@ -842,6 +842,7 @@ public actor SandboxService {
}
// If the host doesn't support this, we'll throw on container creation.
czConfig.virtualization = config.virtualization
czConfig.useInit = config.useInit
for mount in config.mounts {
if try mount.isSocket() {
@@ -487,6 +487,74 @@ class TestCLIRunCommand2: CLITest {
return
}
}
@Test func testRunCommandInit() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--init"])
defer {
try? doStop(name: name)
}
let inspectResp = try inspectContainer(name)
#expect(inspectResp.configuration.useInit == true, "expected useInit to be true in container configuration")
// With --init, PID 1 should be the init process, not "sleep".
var output = try doExec(name: name, cmd: ["cat", "/proc/1/cmdline"])
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(
!output.hasPrefix("sleep"),
"expected PID 1 to be init process, not 'sleep', got '\(output)'"
)
try doStop(name: name)
} catch {
Issue.record("failed to run container with --init: \(error)")
return
}
}
@Test func testRunCommandInitReapsZombies() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: ["--init"])
defer {
try? doStop(name: name)
}
_ = try doExec(
name: name,
cmd: [
"sh", "-c",
"sh -c 'sh -c \"exit 0\" &' && sleep 1",
])
let psOutput = try doExec(name: name, cmd: ["sh", "-c", "ps aux | grep -c '\\[sh\\]' || true"])
let zombieCount = Int(psOutput.trimmingCharacters(in: .whitespacesAndNewlines)) ?? -1
#expect(
zombieCount == 0,
"expected no zombie processes with --init, found \(zombieCount)"
)
try doStop(name: name)
} catch {
Issue.record("failed to verify zombie reaping with --init: \(error)")
return
}
}
@Test func testRunCommandWithoutInitDefault() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: [])
defer {
try? doStop(name: name)
}
let inspectResp = try inspectContainer(name)
#expect(inspectResp.configuration.useInit == false, "expected useInit to be false by default")
try doStop(name: name)
} catch {
Issue.record("failed to run container without --init: \(error)")
return
}
}
}
class TestCLIRunCommand3: CLITest {
+9 -2
View File
@@ -50,6 +50,7 @@ container run [<options>] <image> [<arguments> ...]
* `--dns-option <option>`: DNS options
* `--dns-search <domain>`: DNS search domains
* `--entrypoint <cmd>`: Override the entrypoint of the image
* `--init`: Run an init process inside the container that forwards signals and reaps processes
* `--init-image <image>`: Use a custom init image instead of the default. This allows customizing boot-time behavior before the OCI container starts, such as running VM-level daemons, configuring eBPF filters, or debugging the init process.
* `-k, --kernel <path>`: Set a custom kernel path
* `-l, --label <label>`: Add a key=value label to the container
@@ -61,13 +62,14 @@ container run [<options>] <image> [<arguments> ...]
* `-p, --publish <spec>`: Publish a port from container to host (format: [host-ip:]host-port:container-port[/protocol])
* `--platform <platform>`: Platform for the image if it's multi-platform. This takes precedence over --os and --arch
* `--publish-socket <spec>`: Publish a socket from container to host (format: host_path:container_path)
* `--read-only`: Mount the container's root filesystem as read-only
* `--rm, --remove`: Remove the container after it stops
* `--rosetta`: Enable Rosetta in the container
* `--runtime`: Set the runtime handler for the container (default: container-runtime-linux)
* `--ssh`: Forward SSH agent socket to container
* `--tmpfs <tmpfs>`: Add a tmpfs mount to the container at the given path
* `-v, --volume <volume>`: Bind mount a volume into the container
* `--virtualization`: Expose virtualization capabilities to the container (requires host and guest support)
* `--runtime`: Set the runtime handler for the container (default: container-runtime-linux)
**Registry Options**
@@ -104,6 +106,9 @@ container run -e NODE_ENV=production --cpus 2 --memory 1G node:18
# run a container with a specific MAC address
container run --network default,mac=02:42:ac:11:00:02 ubuntu:latest
# run a container with an init process to reap zombies and forward signals
container run --init ubuntu:latest my-app
# run a container with a custom init image for boot customization
container run --init-image local/custom-init:latest ubuntu:latest
```
@@ -205,6 +210,7 @@ container create [<options>] <image> [<arguments> ...]
* `--dns-option <option>`: DNS options
* `--dns-search <domain>`: DNS search domains
* `--entrypoint <cmd>`: Override the entrypoint of the image
* `--init`: Run an init process inside the container that forwards signals and reaps processes
* `--init-image <image>`: Use a custom init image instead of the default. This allows customizing boot-time behavior before the OCI container starts, such as running VM-level daemons, configuring eBPF filters, or debugging the init process.
* `-k, --kernel <path>`: Set a custom kernel path
* `-l, --label <label>`: Add a key=value label to the container
@@ -216,13 +222,14 @@ container create [<options>] <image> [<arguments> ...]
* `-p, --publish <spec>`: Publish a port from container to host (format: [host-ip:]host-port:container-port[/protocol])
* `--platform <platform>`: Platform for the image if it's multi-platform. This takes precedence over --os and --arch
* `--publish-socket <spec>`: Publish a socket from container to host (format: host_path:container_path)
* `--read-only`: Mount the container's root filesystem as read-only
* `--rm, --remove`: Remove the container after it stops
* `--rosetta`: Enable Rosetta in the container
* `--runtime`: Set the runtime handler for the container (default: container-runtime-linux)
* `--ssh`: Forward SSH agent socket to container
* `--tmpfs <tmpfs>`: Add a tmpfs mount to the container at the given path
* `-v, --volume <volume>`: Bind mount a volume into the container
* `--virtualization`: Expose virtualization capabilities to the container (requires host and guest support)
* `--runtime`: Set the runtime handler for the container (default: container-runtime-linux)
**Registry Options**
+15
View File
@@ -497,6 +497,21 @@ container run --name nested-virtualization --virtualization --kernel /path/to/a/
[ 0.017893] kvm [1]: Hyp mode initialized successfully
```
## Run a container with a provided init process
By default, the command you specify in `container run` runs as PID 1 inside the container. This means it is responsible for reaping zombie processes and handling signals, which many applications are not designed to do. The `--init` flag runs a lightweight init process as PID 1 that automatically forwards signals and reaps orphaned child processes.
```bash
container run --init ubuntu:latest my-app
```
The init process is also available with `container create`:
```bash
container create --init --name my-container ubuntu:latest my-app
container start my-container
```
## Use a custom init image
The `--init-image` flag allows you to specify a custom init filesystem image for the lightweight VM that runs your container. This enables: