Running in containers
Containers are where most processkit-using services actually run, and it's
the environment with the most sharp edges: which containment mechanism you
get depends on privileges the orchestrator may or may not grant, your process
is usually PID 1 (with everything that implies for signal delivery and
reaping), and the image itself is often minimal (musl/Alpine, sometimes no
shell at all). None of this is new machinery — every fact below is already
documented in Platform support — this page is the
container-shaped tour through it, with the Dockerfile fragments and gotchas
that only show up once you actually run the crate inside docker run /
Kubernetes.
- Which containment mechanism you get
- PID 1: signals, zombies, and what's contained
- Graceful shutdown on the orchestrator's
SIGTERM - Minimal images: musl/Alpine, no shell, no
setpriv - Container resource limits vs the crate's
limits
Which containment mechanism you get
On Linux, ProcessGroup's cgroup backend needs write access to the cgroup v2
hierarchy at the real hierarchy root (see
Platform support → containment mechanisms
for exactly why). A plain, unprivileged docker run container gets neither:
/sys/fs/cgroup is typically mounted read-only, so the crate quietly
falls back to the ProcessGroup (POSIX process-group) mechanism — kill-on-drop
still works, but the whole-tree accounting and limits capabilities of the
cgroup backend do not (confirmed by running the crate inside docker run
with no extra flags: mechanism() reports ProcessGroup, and
/sys/fs/cgroup refuses even a touch). --privileged (or an equivalent
cgroup-namespace delegation) makes the filesystem writable enough for
mechanism() to report CgroupV2, but the container's cgroup is still a
namespace root, not the real hierarchy root — /proc/self/cgroup reads
0::/ either way — so resource limits
stay unenforceable even then.
use processkit::{Mechanism, ProcessGroup}; fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; if group.mechanism() == Mechanism::ProcessGroup { // The common case inside an unprivileged Linux container: kill-on-drop // still holds (see the next section), but `members()`'s CPU/memory // totals and `limits` are not available — see the capability // matrices in Platform support. `CgroupV2`/`JobObject` get those too. } Ok(()) }
Never assume a mechanism; check mechanism() if your service's behavior
must not silently degrade (e.g. it relies on limits for sandboxing an
untrusted child) — see
Container resource limits
for the fail-fast alternative when a cap truly matters. Whichever mechanism
you land on, whole-tree kill-on-drop itself is unconditional — the fallback
only narrows accounting and limits, never containment.
Preflight without spawning anything. A container entrypoint often runs a
host-check / doctor step before it starts any real work, whose whole point is
to have no side effects — no child spawned, no container created. Such a step
can't build a ProcessGroup just to read mechanism(), so use
host_containment() instead: it reports the mechanism a group would get here (plus the reach of a
soft stop and abrupt-owner-death cleanup, and the crate version) from cheap
read-only checks, creating nothing — on Linux specifically, no new cgroup
directory. The mechanism it predicts is the same one a real ProcessGroup::new
selects; the Linux cgroup answer is best-effort (it checks whether a cgroup
could be created rather than creating one), so in the rare window where a
writable-looking cgroup then rejects creation the live mechanism() is the final
word.
fn main() { let host = processkit::host_containment(); // Log the containment story a run *would* get — no container created, no // process spawned (on Linux: no cgroup directory left behind): println!( "mechanism={:?} parent_death={:?} processkit={}", host.mechanism(), host.parent_death_cleanup(), host.crate_version(), ); }
PID 1: signals, zombies, and what's contained
Inside a container your process is almost always PID 1 — Docker and
Kubernetes don't run a real init unless you ask for one. PID 1 has two kernel
duties an ordinary process doesn't: it's the implicit reparent target for
every orphaned descendant in the container's PID namespace, and — for signals
without an installed handler — some default dispositions are ignored instead
of applied (irrelevant to SIGKILL/SIGSTOP, which are never blockable, but
relevant to graceful termination — see the next section).
Neither duty is something processkit does for you, and neither needs to be:
- Containment (killing the tree) is unaffected. The whole point of the
cgroup/pgroup mechanisms is that
kill_all/shutdown/Dropreach every descendant, including ones spawned after your direct child — that's true whether or not your process happens to be PID 1. - Reaping an orphan that isn't yours is not. A grandchild your own
child forked and then exited without waiting for still needs someone to
call
wait()on it once it dies, or it lingers as a zombie (kill(pid, 0)still reports it alive) forever. Once that grandchild reparents to PID 1, "someone" has to be PID 1 itself — and an ordinary process,processkit-managed or not, doesn't indiscriminately reap processes it never spawned. - If your own process dies abruptly (SIGKILL), its
Dropnever runs, so the cgroup/pgroup teardown that normally reaps the tree does not fire. The opt-inkill_on_parent_death()hardens this case, but its reach is platform-limited and reported honestly byCommand::kill_on_parent_death_scope()—WholeTreeon Windows,DirectChildOnlyon Linux (the direct child dies; a surviving cgroup keeps grandchildren alive),Unsupportedon macOS/BSD. There is no portable Unix kernel primitive that tears a whole tree down on its creator's abrupt death; when that guarantee matters, keep an outer container/cgroup (or a subreaper) that owns the tree independently of your process.
Why there's no "reattach to the leaked tree by its id" escape hatch. On Linux
the surviving cgroup could in principle be reaped by a later process that knew
its path (cgroup.kill acts on the whole subtree) — so it's natural to wish for a
stable container identifier you could record and later reattach to. The crate
deliberately does not expose one, because such an identifier cannot be made
identity-safe across reuse: a cgroup path (or a Windows Job Object name) is
freed when its container is torn down and can be handed to an unrelated later
container, so acting on a recorded id risks killing an innocent tree. Nothing
the crate can stamp on the container fixes this — a cgroup directory has nowhere to
hold a crate-chosen marker, its pseudo-filesystem inode/birth-time are not a
contractual identity, and giving a second process a live Windows job handle would
itself defeat the KILL_ON_JOB_CLOSE kill-on-owner-death that makes the abrupt-death
case already clean on Windows (the kernel reaps the whole tree there, so there is
no leaked tree to reattach to in the first place). The honest remedy is the same one
above: have an external owner — a subreaper, or a delegated systemd scope — own
the tree independently of your process, so its death, not a fragile recorded id, is
what triggers cleanup.
This is exactly the gap Platform support's CI section
documents and works around with --init for the crate's own test suite —
and it reproduces identically for any container. Run a process that
orphans a short-lived grandchild as the container's PID 1 with a plain
docker run (no --init), and the grandchild is left a permanent zombie
after it exits; the identical container run with --init (which runs
tini as the real PID 1, a subreaper) shows
no zombie at all — confirmed by running both side by side. Baking tini into
the image itself works identically, and is the only option on Kubernetes,
which has no --init-equivalent flag:
FROM alpine:latest
RUN apk add --no-cache tini
COPY my-app /usr/local/bin/my-app
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/my-app"]
(Confirmed against a real pk-container-demo image built from this exact
pattern: with tini as ENTRYPOINT, an orphaned grandchild is reaped with no
--init flag at all; the same image invoked without tini — the bare binary
as PID 1 — leaks the identical zombie that the plain docker run case above
does.) sh -c 'sleep 0.3 &'-style orphans are the illustrative worst case;
in practice this only bites processes with descendants that outlive their
direct parent — most single-service containers with no forking children
never hit it. When in doubt, run with a subreaper as PID 1; it's a no-op cost
otherwise.
Graceful shutdown on the orchestrator's SIGTERM
Docker (docker stop) and Kubernetes both stop a container by sending
SIGTERM to PID 1 — your process — then wait a grace period (Docker's
--stop-timeout / docker stop -t, Kubernetes'
terminationGracePeriodSeconds, both default to a small handful of seconds)
before escalating to SIGKILL. That SIGTERM targets your process, not
the tree processkit manages — the two are related but distinct signals:
catching the orchestrator's SIGTERM is ordinary application code (e.g.
tokio::signal::unix::signal
with tokio's own signal feature, or a crate like signal-hook); reacting to
it by tearing down the child tree gracefully is
ProcessGroup::shutdown
(or RunningProcess::shutdown for a single
start()ed service): SIGTERM the tree, wait up to shutdown_timeout, then
SIGKILL any survivor.
use processkit::{Command, ProcessGroup}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let _server = group.start(&Command::new("app-server")).await?; // Left to the application: install a handler for the orchestrator's own // SIGTERM (tokio::signal::unix::signal(SignalKind::terminate()) with // tokio's `signal` feature, or the `signal-hook`/`ctrlc` crates) and // resolve this future when it fires. wait_for_orchestrator_sigterm().await; // SIGTERM the tree, wait shutdown_timeout, SIGKILL stragglers: group.shutdown().await?; Ok(()) } async fn wait_for_orchestrator_sigterm() { // … }
(This exact pattern — tokio::signal::unix::signal(SignalKind::terminate())
followed by group.shutdown() — was built and run for real as a small
processkit-consuming binary: docker stop on the resulting container
delivered SIGTERM to PID 1, the handler fired, shutdown() tore the tree
down, and the process exited well inside Docker's default 10-second grace —
no SIGKILL needed.)
To observe that teardown — log whether the tree drained within your grace or had
to be hard-killed, export the elapsed as a shutdown metric, or drive your own race
between the orchestrator's SIGTERM and a control-socket "drain" command — use the
process-control ProcessGroup::stop(grace, escalate)
in place of shutdown(): the same teardown, returning a ShutdownReport (the
attempted soft signal, member counts before/after, drained-within-grace vs
escalated, and the actual elapsed). stop(Duration::ZERO, true) additionally gives
a "kill and wait for the tree to actually empty" that bare kill_all — which
returns as soon as the kill is issued — does not.
Two things worth setting deliberately, both already documented at the
ProcessGroup/Command level:
- The orchestrator's grace period must exceed your own. If
shutdown_timeout(or a per-Commandtimeout_grace) is longer than Docker's--stop-timeout/ Kubernetes'terminationGracePeriodSeconds, the orchestrator sends the hardSIGKILLto your still-shutting-down process before your own escalation ever gets a chance to run — set the outer grace at least as generous as the inner one. - A frozen tree can't shut down gracefully. If you've called
group.suspend(), the frozen processes can't run theirSIGTERMhandler —shutdown()waits out the whole grace and then hard-kills. Resume first; see Platform support's frozen-tree caveat.
Minimal images: musl/Alpine, no shell, no setpriv
Two properties of a lean final image turn out to be non-issues for this
crate specifically, because of how it does privilege drop and spawning —
confirmed by building and running against rust:alpine/alpine:latest:
- No
setpriv/su-exec/gosuneeded foruid()/gid()/groups(). The privilege drop issetgroups→setgid→setuidcalled directly as raw syscalls inside the child'spre_exechook (see Running commands → privileges) — the crate never shells out to an external helper binary, so a final stage that lacks one entirely still drops privileges correctly. Confirmed by running the drop (.uid(...).gid(...).groups(...)) inside a container and getting back the target identity with no such binary invoked. - No shell needed to spawn.
Command/ProcessGroup::spawnalwaysexecs the target program directly with an argv array — there is nosh -cstep anywhere in the crate's own spawn path (pipelines wire pipes at the OS level, not through a shell either — see Pipelines). AFROM scratch-style final stage with no shell at all works, as long as the programs you spawn are themselves present — the crate not needing a shell doesn't exempt a command you invoke assh -c "…"yourself.
# Builder: musl/Alpine — the same base the crate's own CI `test-musl` job and
# `just test-musl` build against (see Platform support → CI coverage), so the
# toolchain and libc pairing is already exercised.
FROM rust:alpine AS builder
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release
# Runtime: no shell, no setpriv/su-exec/gosu — the crate needs none of them.
FROM alpine:latest
COPY --from=builder /src/target/release/my-app /usr/local/bin/my-app
ENTRYPOINT ["/usr/local/bin/my-app"]
(Built and run for real, end to end: cargo build --release inside
rust:alpine, then the resulting binary run from a bare alpine:latest
final stage — privilege drop and a no-shell spawn both succeeded exactly as
above.) Add tini to the final stage — see PID 1 — if
anything you spawn can outlive its own children.
Container resource limits vs the crate's limits
The orchestrator's own cgroup limits (Docker's --memory/--cpus,
Kubernetes' resources.limits) apply to the whole container, enforced by
the kernel regardless of anything processkit does — a container that hits
its memory limit gets OOM-killed independent of any max_memory the crate
was asked to set. That outer limit is not the same thing as the crate's own
limits feature (max_memory /
max_processes / cpu_quota on ProcessGroupOptions), which caps a
specific tree within the container and needs this process to sit at the
real cgroup v2 hierarchy root — a requirement an ordinary container
essentially never meets, privileged or not (see
Which containment mechanism you get).
Confirmed by actually requesting a limit from inside a container:
plain docker run: ErrorReason::ResourceLimit { kind: Memory, reason: Unenforceable,
detail: "…Read-only file system…" }
docker run --privileged: ErrorReason::ResourceLimit { kind: Memory, reason: Unenforceable,
detail: "…cgroup v2's 'no internal processes' rule…Resource busy…" }
Both fail the same way the crate documents for any non-delegated host —
ErrorReason::ResourceLimit with reason: LimitReason::Unenforceable — never a
silently-unbounded group (see
Errors → ResourceLimit and
Platform support → containment mechanisms
for the delegation requirement in full). In practice: rely on the
orchestrator's own memory/CPU limits as the outer boundary for anything
running in a container, and reserve the crate's limits feature for hosts
where this process genuinely owns the cgroup root — a minimal, non-systemd
init on bare metal or a VM, not a container. mechanism()/kill-on-drop
containment keep working either way; only the limits cap itself is
unavailable.
On a host where the crate's limits do apply, the caps are not frozen at
creation: ProcessGroup::update_limits
re-applies a fresh set to the live tree (a full replacement) for adaptive
tightening or relaxing, and refuses — rather than silently drops — a cap on a
mechanism that can't enforce it, exactly as creation does.
Reading the tree's usage, even where limits can't be enforced. Reporting
isn't gated on a cap being enforceable: ProcessGroup::stats
and the sample_stats time-series (the opt-in stats feature) still answer
inside a container — the live process count on every backend, plus total CPU
time and peak memory wherever a real cgroup or Job Object is active (a plain
unprivileged container's process-group fallback leaves those two None, mirroring
its unavailable limits). A long-lived containerized service typically holds its
group behind an Arc (shared with a supervisor, or a metrics task) — for that
shape reach for
OwnedStatsSampler, the
Send + 'static owning sampler: it takes an &Arc<ProcessGroup>, so it can be
moved into a spawned telemetry task without being tied to the borrow's lifetime,
holds the group only weakly (never pinning a tree that should be torn down), and
ends its series honestly if the group is ever released.
"The cap couldn't be applied" and "the cap fired" are different questions.
Everything above is about applying a cap: ErrorReason::ResourceLimit says a
requested cap could not be put in force. with_options raises it before
anything runs at all; update_limits raises it against an already-running tree,
where it does not mean "nothing changed" — that call is
not a rollback, so part of the
requested set may already be in force. The separate question — did a cap
actually stop this tree? — is answered post-run by
ProcessGroup::limit_evidence(),
which returns a three-valued verdict per axis (Tripped / NotTripped /
Unknown) read from the container's own kernel counters. Neither one changes
the other's meaning: an admission failure is ResourceLimit, a cap that fired
is a Tripped verdict, and where no authoritative evidence exists the answer is
an explicit Unknown rather than a "no". They stay separable even where they
meet — every axis a failed update_limits named is still read from the
counters, never quietly reported as NotTripped.
This matters most in exactly the situation this page is about, because in
a container the interesting kill usually comes from the outer limit: a verdict
is Tripped only when this crate's own cgroup recorded hitting its own
cap (memory.events' oom), so an orchestrator-level OOM kill of the whole
container is never dressed up as "your max_memory killed it". On a Windows Job
Object and on the process-group fallback every capped axis reports Unknown —
those mechanisms preserve no post-mortem record — which is why the guide states
the limitation rather than inventing a verdict.
Running something you don't trust inside that container? See Running untrusted children for the full hardening checklist — containment, resource limits, privilege drop, env hygiene, output/wall-time bounds — with the platform caveats from this page and Platform support folded in.
Next: Platform support · Running untrusted children · Process groups · docs index