A small PID 1 supervisor for container images. Reads a documented
subset of systemd unit files, supervises services, and provides
socket activation in two modes (native via sd_listen_fds, and proxy
for unmodified upstream binaries).
The binary is generic; image-specific behaviour lives in the unit
files an image author drops at /etc/container-init/units/ and
/etc/container-init.d/.
This project was inspired by
docker-systemctl-replacement,
a Python script that replaces /usr/bin/systemctl inside containers so that
standard systemd unit files work without a full systemd daemon. It solves the
right problem, but it carries a Python runtime dependency and starts services
sequentially, both of which add friction and latency.
container-init addresses those constraints:
- No runtime dependency. A single statically-linked binary (≤ 8 MiB, no CGO); no Python, no interpreter, no shared-library surprises.
- Faster cold starts. Services with no inter-dependencies start concurrently. Socket activation lets the binary bind public ports immediately and defer heavyweight services until the first real connection.
The PID 1 problems documented by docker-systemctl-replacement are solved the same way:
| Problem | How container-init addresses it |
|---|---|
| Zombie accumulation | internal/pid1 owns a dedicated SIGCHLD-driven wait4(-1) loop. Every reparented grandchild -- from dbus-launch, Type=forking units, or any process that re-parents onto PID 1 -- is reaped silently and immediately. |
| SIGTERM not reaching children | docker stop sends SIGTERM to PID 1, which stops every supervised unit before --stop-timeout expires. |
| Unordered / incomplete shutdown | Reverse-dependency shutdown stops units in reverse After= / Before= order, each with its KillSignal= and TimeoutStopSec= before it is killed, all within --stop-timeout. |
| Service startup at boot | The supervisor reads unit files and starts all services at launch, respecting After= / Before= ordering and Requires= -- no bespoke shell scripts needed. |
cmd/
container-init/ PID 1 supervisor binary
unit/ unit-file parser + supported-directive validator + env expansion (public API)
internal/
supervisor/ goroutine-per-service supervision
socketact/ socket activation (native + proxy)
cgroup/ per-unit cgroup-v2 placement + cgroup.kill teardown
pid1/ SIGCHLD dispatcher, signal forwarding, reverse shutdown
trace/ JSONL boot-trace emission
statefile/ the --state-file format, written atomically
health/ `container-init health`, judging the state file
userdb/ /etc/passwd + /etc/group resolution for User= / Group=
Makefile
unit/ is the only exported package -- downstream images can import it
for property-level corpus tests against their unit files. Everything
else stays internal so the supervisor / socket-activation / cgroup
internals remain free to evolve.
make build # produces bin/container-init.linux-{amd64,arm64}
make test
The binary is statically linked, CGO disabled, ≤ 8 MiB.
container-init reads from two directories, in priority order:
/etc/container-init/units/-- core unit files installed by the base image./etc/container-init.d/-- drop-ins shipped by derived images that layer on top of the base. Drop-ins go through the same parser and validator as core; they can reference core units viaAfter=/Requires=/OnFailure=.
Both paths are configurable via the --units and --drop-in flags.
A unit whose Requires= chain reaches a unit that neither directory
installs is also a load warning (required unit X not found (required by Y); Z will not start), so an image that drops a required unit is
caught at build time rather than losing services at run time.
The --strict-units flag promotes any load warning (unknown
directive / section, unsupported value form, missing requirement) into
a fatal load error -- useful in CI to catch typos before they ship.
The --validate flag loads + parses units, prints a summary, and
exits without supervising. It also makes the unit-set checks startup
makes: a dependency cycle in After= / Before=, including one
through a .socket's implicit ordering before its service, or a
native-mode socket sharing its service with another socket, exits 64
as it would at boot, with or without --strict-units; any load
warning exits 1. Combined with --strict-units, this is a build-time
sanity check.
The --stop-timeout flag (default 8s) bounds the whole reverse
shutdown: once it has passed, every unit still running is killed at
once. Keep it under the time docker stop allows (--time, 10s by
default), after which Docker SIGKILLs the container with whatever is
still running.
The --state-file flag makes container-init report every unit's
state in a JSON file, for container-init health; see
Health checks.
Some desktop apps detect "real systemd" by spawning systemd-run and
fail if the call doesn't succeed. The notable case is GNOME Ptyxis,
which wraps every shell it opens with
systemd-run --user --scope … -- if that returns non-zero, every
terminal tab shows up as "Terminal (Failed)". Older gnome-terminal
versions, GNOME apps that use Flatpak's host-spawn, and anything
that calls systemd-run from a shell script land in the same place.
container-init ships an optional shim that answers
org.freedesktop.systemd1.Manager.StartTransientUnit (plus the
supporting Start/Stop/Reload/Restart-Unit, GetUnit, ListUnits,
Subscribe, Reload, Reexecute, Introspect, and Properties surface for
unit objects) well enough to make systemd-run exit 0. It is a
compatibility surface, not an implementation: no cgroup is
created, no resource limits are enforced, properties like Slice=,
MemoryMax=, CPUQuota=, PIDs= are accepted and ignored.
A. Built into PID 1 (--systemd1-shim flag). Suitable when the
target bus accepts ownership requests from root -- typically the
system bus or a permissively-configured custom bus. The session bus
owned by an unprivileged user (kasm-user, your-desktop-user, etc.)
will reject a root connection's RequestName via EXTERNAL auth's
uid-match check, so this path does not help for desktop session
buses.
ExecStart=/usr/local/bin/container-init --systemd1-shim=system
B. Standalone binary (/usr/local/bin/systemd1-shim) supervised
as the bus owner. This is the right shape for desktop sessions:
container-init runs the binary as the user who owns the session bus,
so EXTERNAL auth succeeds and org.freedesktop.systemd1 is exposed
on the bus the user's apps already talk to. Ship a drop-in:
# /etc/container-init.d/systemd1-shim.service
[Unit]
Description=systemd1 D-Bus compatibility shim
After=session-setup.service
Requires=session-setup.service
[Service]
Type=simple
User=app-user
EnvironmentFile=/tmp/dbus.env # exports DBUS_SESSION_BUS_ADDRESS
ExecStart=/usr/local/bin/systemd1-shim
Restart=on-failure
RestartSec=2sWith no --address flag the binary reads $DBUS_SESSION_BUS_ADDRESS
from its environment. Pass --address=… if you want to override.
Both entry points accept the same address vocabulary:
| Form | Resolves to |
|---|---|
system |
the system bus ($DBUS_SYSTEM_BUS_ADDRESS or /var/run/dbus/system_bus_socket) |
user |
the calling uid's $XDG_RUNTIME_DIR/bus |
user:UID |
/run/user/UID/bus |
user:env:VAR |
/run/user/$VAR/bus -- reads VAR at start (e.g. user:env:KASM_OS_UID) |
dbus:env-file:PATH |
reads DBUS_SESSION_BUS_ADDRESS from a shell-syntax env file (handles KEY='val'; and export KEY=val) |
unix:path=… |
any raw D-Bus address |
Multiple addresses can be comma-separated in the --systemd1-shim
flag; one goroutine per address dials with retry, reconnects on
disconnect, and stands down without disrupting a real systemd if
one is already on the bus.
StartTransientUnitsynthesizes a monotonic job id, emitsJobNew+JobRemoved{result:"done"}, returns the job's object path.systemd-runblocks onJobRemovedbefore exec'ing its target, so the signal order matters.Get(InvocationID)on/org/freedesktop/systemd1/unit/*returns an emptyay(the "no recorded invocation" sentinel) sosystemd-run's post-StartTransientUnit query doesn't abort.- Units started through the shim (
StartTransientUnit,StartUnit,RestartUnit;StopUnitforgets them) answerGet/GetAllonorg.freedesktop.systemd1.UnitwithId,LoadState=loaded,ActiveState=activeandSubState=running. Chrome and Chromium start anapp-…-<pid>.scopeand hold their file dialogs until it reads back as active. The shim remembers the last 1024 such units. - Everything else returns zero values or empty arrays.
Callers that read property values for anything other than "is this thing alive" will get back zero answers -- that's the honest signal that this is a shim, not a unit-state store.
The first-class way for layered images to add their own services or replace core ones.
A drop-in named identically to a core unit (full filename match,
including the .service / .socket suffix) replaces the core
unit. container-init logs one line per replacement at startup:
container-init: drop-in override: web.service replaces /etc/container-init/units/web.service with /etc/container-init.d/web.service
The trace JSONL (when CONTAINER_INIT_TRACE=1) emits an
unit_overridden event so dashboards can spot overlays at a glance.
A drop-in with any other name is additive -- added to the unit graph, parsed, supervised, and shut down alongside the core set.
- For an intentional override, name the drop-in identically to the core unit you want to replace. There is no namespacing; full filename match is the override key.
- Recommended convention for additive units:
<image-name>-<service>.service-- e.g.chrome-launcher.service,vscode-server.service.
Drop-ins go through the same supported-directive validator as core
units. Unsupported directives produce a parse-time warning naming the
file + section + directive (default) or fail-fast (--strict-units).
Drop-ins participate in the supervisor's full lifecycle:
-
After= / Before= -- start ordering.
Before=Xis folded intoX'sAfter=when units are loaded, so it orders the boot sequence and blocks the target at run time, and it composes with anAfter=the target declares itself. Ordering a drop-in ahead of a core unit -- the usual reason to reach for it -- therefore needs no override of that core unit. -
Requires= -- a requirement, not an ordering, as in systemd. On its own it does not make a unit wait:
A Requires=Bstarts A and B in parallel, andA Requires=BwithA Before=Bstarts A first. Pair it withAfter=to wait for the requirement, which is almost always what you want. -
Failed dependencies -- a unit fails when it cannot be started (bad
EnvironmentFile=, unresolvableUser=,fork/execerror) or is a oneshot that exits non-zero, and itsRestart=policy gives up. Units that only orderAfter=it then start anyway; units that bothRequires=it and orderAfter=it are not started and fail in turn, as in systemd. A unit thatRequires=it withoutAfter=has not waited for it and is not stopped by the failure. ARequires=chain that reaches a unit that is not installed -- directly, or through the units it requires -- is a failed requirement too: the unit is not started (not started: required unit X not found), with or withoutAfter=.After=/Before=/Wants=naming a missing unit are ignored. A missing requirement is also reported as a load warning, so--validatecatches it. Each step logs a line (failed to start,failed,not started: required unit X failed). A dependency failure does not fire the dependent's ownOnFailure=/FailureAction=/ExitContainerOnFailure=. -
Restart= / RestartSec= / StartLimitBurst= / StartLimitIntervalSec= -- per-unit restart policy and rate limiting. Every start counts, the first and each restart; a unit that would start more than
StartLimitBurst=times withinStartLimitIntervalSec=is not started again and fails (OnFailure=/FailureAction=/ExitContainerOnFailure=fire,Requires=+After=dependents fail if it never became ready). The limit directives belong in[Unit], as in systemd, and are also accepted in[Service]. Setting one alone gets systemd's default for the other (5 starts / 10s), and0in either disables the limit. Unlike systemd, a unit that sets neither has no limit. -
ConditionPathExists= / ConditionPathExistsGlob= / ConditionEnvironment= / ConditionUser= -- unit is loaded but skipped at boot when conditions are unmet.
ConditionEnvironment=takesVAR(set) orVAR=value(set to exactly that value), either negated with!as in systemd:ConditionEnvironment=!FEATURE=falseruns unlessFEATUREisfalse.ConditionUser=takes a uid or user name, optionally negated with!, and compares it with container-init's own uid -- e.g.ConditionUser=rootfor a unit that needs a root PID 1,ConditionUser=!rootfor one that only makes sense without it. Names resolve at load time, before any unit runs;rootneeds no passwd entry. systemd's@systemis not supported. A.socketwhose service is skipped is skipped with it and does not listen, so a client cannot start a service its conditions ruled out. -
OnFailure= -- invoke a sibling oneshot when this unit fails for good: it failed and
Restart=will not start it again, or it hit its start limit. A failure the unit restarts from does not fire it, as in systemd -- so underRestart=always/Restart=on-failureit fires only once a start limit runs out. Chains across the core/drop-in boundary identically. The target's own dependencies apply: it waits for the units it ordersAfter=, and a missing or failed requirement keeps it from running, as for any other start. A target that is already active is not started a second time, as in systemd: two failures that share a handler still running from the first get one invocation (OnFailure: X not invoked: already active). -
Socket activation -- a
.socketstarts its service on the first connection. Once the service stops andRestart=will not start it again (a clean exit, or a failure), the socket listens again and the next connection starts it anew, as in systemd. The socket fails and closes its listener when the service hits its start limit, when a unit the serviceRequires=and ordersAfter=failed, or when its own trigger limit is spent: more thanTriggerLimitBurst=activations withinTriggerLimitIntervalSec=(both in[Socket]). Unlike the start limit, a socket that sets neither gets systemd's defaults forAccept=no, 20 activations per 2s, so a service that exits without taking its connection cannot be restarted as fast as it exits;0in either disables it. A failed socket fires its ownOnFailure=. A Unix socket's node getsSocketMode=exactly as written,0000included (omitted: 0666, as in systemd), and is owned bySocketUser=/SocketGroup=when set;SocketUser=alone gives it that user's group. The socket is bound in a private directory beside its path and renamed into place only once its owner and mode are set, so it is never reachable with any others. Without root, container-init cannot give it another owner, and the socket fails. A socket that cannot bind at boot fails at once: itsOnFailure=fires, and its service, which needs it, fails too, so units thatRequires=and orderAfter=either one are not started. -
Reverse shutdown -- on SIGTERM / SIGINT, or when a unit's
FailureAction=/SuccessAction=/ExitContainerOnFailure=fires, units stop in reverse dependency order, as in systemd: a unit is stopped once every unit orderedAfter=it (or that it ordersBefore=) has stopped, and units with no ordering between them stop in parallel. Each is sent itsKillSignal=(default SIGTERM) and given itsTimeoutStopSec=(default 5s, and0means the default, not systemd's "no timeout") to exit before its cgroup (or process group) is killed; the whole shutdown is bounded by--stop-timeout. A unit that exits during shutdown is being stopped, not failing: neitherRestart=norOnFailure=acts on it, and no unit is started once shutdown has begun, including anOnFailure=target queued just before. A.socketcloses its listener in its own turn, after its service and every unit orderedAfter=it; until then a proxy socket still reaches a running helper but starts no new one. As in systemd, a.socketis ordered before the service it activates whether or not the service saysAfter=, so a socket orderedAfter=its own service is a dependency cycle and the unit set is refused at startup.ExecStop=/ExecStopPost=are parsed but not run. -
FailureAction= / SuccessAction= -- what PID 1 does once this unit is done for good, as in systemd:
none(the default),exit(reverse shutdown, then exit) orexit-force(exit without waiting for units to stop: each is sent itsKillSignal=and killed at once).FailureAction=fires when the unit fails andRestart=will not start it again --Restart=no,Restart=on-failureonce the start limit is hit, a start failure -- and also for a.socketthat fails.SuccessAction=fires when a service exits cleanly and is not restarted, so it never fires underRestart=always, and aRemainAfterExit=yesoneshot stays active and does not fire it. A socket-activated service is no exception: each clean exit makes it inactive. Neither fires for a unit stopped by reverse shutdown, or one not started because a requirement failed. systemd's other actions (reboot,poweroff,halt, ...) have no meaning for a container's PID 1 and are refused at load. To end the container whenever a service stops on its own, setRestart=nowith both actions. -
FailureActionExitStatus= / SuccessActionExitStatus= -- the status PID 1 exits with when that action is
exitorexit-force: 0-255, or empty for the default, as in systemd. By default it is the exit code of the unit's main process. When there is none -- it was killed by a signal, could not start, or hit its start limit, or the unit is a socket -- PID 1 exits as any shutdown would: 0, or 1 if a unit had to be killed. Set it whenever an orchestrator must tell a unit's end from a normal stop (--restart=on-failure, alerting). The first unit to fire an action decides the status; the state file records which (see Health checks). -
ExitContainerOnFailure=true -- container-init's own fail-fast switch: take the whole container down via reverse shutdown on this unit's first failure, even one
Restart=would have recovered from;OnFailure=fires on the way down. UnlikeFailureAction=, it does not wait forRestart=to give up. The container exits with the unit's exit code, orFailureActionExitStatus=when set, andFailureAction=exit-forcemakes it forced. Useful for compliance gates an image author owns. -
Environment= / EnvironmentFile= --
Environment=takes whitespace-separatedK=Vassignments;"..."or'...'keep whitespace in one, whether they wrap the whole assignment ("GREETING=hello world") or the value (GREETING="hello world"). Backslashes are kept as written. In anEnvironmentFile=, whitespace around a value is stripped unless it is quoted. Files overrideEnvironment=, and a later file an earlier one, as in systemd. -
User= / Group= / WorkingDirectory= -- privilege drop. Accepts the
${VAR:-default}env-expansion form so per-image overrides flow through automatically. AUser=unit getsHOME,USERandLOGNAMEfrom the resolved user, over any value in the inherited environment,Environment=orEnvironmentFile=.Group=withoutUser=changes only the group, as in systemd: the unit runs as container-init's own uid with that gid and no supplementary groups.When container-init itself runs without root (the container is started with
--user), it cannot switch identity at all. AUser=unit that resolves to container-init's own uid and gid then runs as-is, keeping container-init's supplementary groups; one that wants any other identity fails to start with a log line naming both. One line at boot says switching is off. A numericUser=with no passwd entry gets gid 0 unlessGroup=is set, so under a non-root container-init such a unit needs an explicitGroup=to match.Every service starts by re-executing the container-init binary as the unit's user, which then enters
WorkingDirectory=and execsExecStart=-- so the binary must be executable by everyUser=(mode0755, as installed normally). This keeps a slow or hungWorkingDirectory=orExecStart=path from stalling PID 1: it only delays that one unit.User=/Group=names resolve against/etc/passwdand/etc/group, read directly (the binary is static and never uses NSS). SetCONTAINER_INIT_PASSWD_FILE/CONTAINER_INIT_GROUP_FILEto read other files instead -- for example the files annss_wrappersetup generates to rename the user of a non-root container. They are read at each unit start, so a unit can generate them for the units after it; while a named file does not exist yet, lookups read/etc/passwd//etc/groupinstead, and the file used is logged when it changes.
# /etc/container-init.d/myimage-init.service
[Unit]
Description=My image's per-session init
After=session-setup.service
Requires=session-setup.service
[Service]
Type=oneshot
RemainAfterExit=yes
User=${APP_USER:-app}
ExecStart=/usr/local/bin/myimage-init
TimeoutStartSec=60s# /etc/container-init.d/myimage-app.service
[Unit]
Description=Background app
After=window-manager.service
Requires=window-manager.service
StartLimitBurst=5
StartLimitIntervalSec=60s
[Service]
Type=simple
User=${APP_USER:-app}
WorkingDirectory=${APP_HOME:-/home/app}
ExecStart=/usr/local/bin/myimage-app --listen 127.0.0.1:5000
Restart=on-failure
RestartSec=2sWhen the helper consumes LISTEN_PID / LISTEN_FDS, use native
activation. container-init binds the public port and hands the
listener fd over on first connect:
# /etc/container-init.d/myhelper.socket
[Unit]
Description=My helper -- public listener
[Socket]
ListenStream=5050
ActivationMode=native
Service=myhelper.service
[Install]
WantedBy=sockets.target# /etc/container-init.d/myhelper.service
[Unit]
Requires=myhelper.socket
[Service]
Type=simple
User=${APP_USER:-app}
ExecStart=/usr/local/bin/myhelper
Restart=on-failure
RestartSec=500msThe helper reads LISTEN_PID / LISTEN_FDS and uses
os.NewFile(3, "listener") to consume the inherited fd. Cold-start
lands on the first connection, not at boot.
A native socket must be the only socket for its service: container-init
passes the service just the listener that activated it, so a unit set
where a native socket shares its service with any other socket is
refused at startup and by --validate. Give each native socket its own
service, or use proxy mode for all of them.
When the helper is an unmodified upstream binary that doesn't speak
sd_listen_fds, use proxy mode. container-init binds the public
port and proxies bytes to the helper's internal listener:
# /etc/container-init.d/legacy.socket
[Socket]
ListenStream=8080
ActivationMode=proxy
ProxyTarget=127.0.0.1:18080
Service=legacy.service
[Install]
WantedBy=sockets.target# /etc/container-init.d/legacy.service
[Unit]
Requires=legacy.socket
[Service]
Type=simple
ExecStart=/usr/local/bin/legacy-server --listen 127.0.0.1:18080
Restart=on-failure
RestartSec=500msFor images that bundle a backend that needs the system bus (polkitd, NetworkManager stub, custom hardware daemons):
# /etc/container-init.d/dbus-system.socket
[Socket]
ListenStream=/run/dbus/system_bus_socket
SocketUser=root
SocketMode=0666
ActivationMode=native
Service=dbus-system.service# /etc/container-init.d/dbus-system.service
[Unit]
Requires=dbus-system.socket
[Service]
Type=simple
User=messagebus
Group=messagebus
ExecStartPre=/usr/bin/install -d -m 0755 /run/dbus
ExecStart=/usr/bin/dbus-daemon --system --nofork --nopidfile --syslog-only
Restart=on-failureThe backend service then Requires=dbus-system.service and
After=dbus-system.service. dbus-daemon natively understands
sd_listen_fds and consumes the listener fd container-init hands
over.
When CONTAINER_INIT_TRACE=1, container-init writes JSONL records to
${CONTAINER_INIT_TRACE_FILE:-/tmp/container-init-trace.jsonl}. Each
record has a stable shape: t_start_ms, dt_ms (per-phase elapsed,
NOT elapsed-from-boot), phase, optional mem_snapshot, and any
event-specific fields.
Tracing never holds up the supervisor. Records go through a bounded
queue (trace.Queue, 4096 records) to a writer goroutine; while the
destination is not keeping up -- a full pipe, a stalled disk -- records
that do not fit are dropped, and a trace_dropped record with the
count (dropped, and the running total) follows once the writer
catches up. At exit the queue is flushed within reverse shutdown's own
stop deadline, or for at most 1s on an early exit; a writer still stuck
then is abandoned, so records queued at the end can be lost.
jq -s 'sort_by(.t_start_ms) | .[] | "\(.t_start_ms)ms \(.phase) \(.dt_ms)ms"' \
/tmp/container-init-trace.jsonlSet CONTAINER_INIT_TRACE_LABELS="<unit>:<label>,<unit>:<label>" to
capture a labelled mem_snapshot after specific units come up.
A Docker HEALTHCHECK can probe what a container serves, but not what
only PID 1 knows: whether a setup oneshot finished, or whether a
service has stopped for good. With --state-file <path>,
container-init writes every unit's state to a JSON file -- at startup,
on every change, and on a 10s heartbeat -- and container-init health
reads it and exits 0 (healthy) or 1 (unhealthy), never Docker's
reserved 2. PID 1 opens no listener for this.
ENTRYPOINT ["/usr/bin/container-init", "--state-file", "/run/container-init/state.json"]
HEALTHCHECK --interval=30s --start-period=60s --retries=3 \
CMD ["/usr/bin/container-init", "health", "--state-file", "/run/container-init/state.json", \
"--require", "app-setup.service,app.service,app.socket"]health is unhealthy when the heartbeat is older than --max-age
(default 30s, three missed heartbeats), when container-init is
shutting down, and, with --require, when a named unit is missing or
fails the rule for its kind:
- a service must be
active; - a oneshot must have finished successfully (
RemainAfterExit=is not needed), so it is unhealthy while it runs, and one underRestart=alwaysnever passes; - a socket must be listening, and its service must pass;
- a socket-activated service must be running or waiting for a connection, with every one of its sockets listening;
- an
OnFailure=target must be untriggered or finished.
A unit that will never start this boot (a failed condition, or a
failed or missing requirement) fails. Without --require, any failed
unit makes the container unhealthy. When a unit's FailureAction=,
SuccessAction= or ExitContainerOnFailure= takes the container
down, the file records which unit, the directive and the exit status
(pid1.exit), and health names it. A restarted container rewrites
the file, so afterwards the log line (unit X: FailureAction=exit -- initiating reverse shutdown) and the exit status are the record. The heartbeat advances only while
the supervisor passes its own checks -- its state lock can be taken,
the child reaper answers, and no unit's exit has gone unhandled -- so a
hung container-init goes stale and unhealthy.
The file is written atomically (a temporary file renamed over it), 0644. A missing directory is created 0755 (beneath a directory another uid can write, it keeps mkdir's mode and is never chmod'ed); put the file in a directory only container-init's user can write -- as root, container-init warns if another uid could replace it. Writing never holds up supervision or shutdown. The format and every field are in docs/design/state-file.md.
Apache License 2.0 -- see LICENSE.