Mini Docker sysinit
Minimal init daemon as container entrypoint. Manages multiple processes in parallel without needing Docker Compose or the often fragile shell script approach.
Running multiple processes inside the same Docker container is a common use-case. While the documentation does not recommend this in general, shell scripts or supervisord are presented as possible approaches. Also, Docker Compose for multiple containers with individual services can be an option.
All these solutions come with certain limitations (as discussed below), mainly because the only functionality actually needed is just orderly starting and stopping processes.
Following the Unix philosophy, dinit
focuses on this single purpose without further optional features that could just be needless or can already be achieved using existing tools.
This results in a very simple to use, robust, and lightweight process control daemon – without heavy dependencies or runtimes needed, due to plain POSIX C99.
In a nutshell, dinit
will:
- Start the daemons from commandline in the given order. Per default, each process needs to stay alive for a few seconds until the next one will be run.
- Monitor all children running in parallel for unexpected exits, in which case the remaining ones will also be stopped – in reverse order.
After a few seconds, the per-child stop signal will escalate to
KILL
. Ultimately leads to an overall exit with error status. - Broadcast incoming signals such as
INT
,QUIT
, andTERM
. Reaping is done as for subprocess exits, but initiates an orderly shutdown.
Background
Given a list of commands to run, dinit
spawns the corresponding processes and waits for a shutdown signal.
Once received, the signal is forwarded to each child (in reverse startup order).
The only other shutdown condition is when a child unexpectedly exits for itself – in that case a signal will be sent to the other processes, too.
When all children have terminated, the main program exits.
- No configuration file is needed, all commands are directly given as arguments.
- Any child process is awaited, thereby “reaping zombies” – i.e., when running as PID 1.
- Child processes are created in the current environment – permissions, file descriptors, environment, working directory, priority, and the like are all inherited (via
execvp
). Children are spawned as “ordinary” subprocesses without double-fork or daemonize, but with their own new session (process group). For changing users or other aspects of the environment inside a container there already exist other tools that can be used for example in a wrapper if needed. - Children’s
stdout
andstderr
are not explicitly monitored or intercepted but simply passed through, leaving log management to the container runtime. So no needless reading and forwarding from the processes’ output is needed. Thestdin
descriptor gets redirected from/dev/null
, and messages fromdinit
itself are printed tostderr
. - Very light on resources, minimal overhead, and failsafe:
- No CPU usage while running due to a fully blocking wait for signals or children. Sleeping, busy waiting, or polling is not needed.
- No memory allocations, no explicit
malloc
calls. (Resulting in no or a single page/4KB residual memory usage.) - No temporary files, configuration files, or even read-only filesystem access needed.
- Simple to build, with minimal dependencies:
- Single source file in plain POSIX C99.
- No additional requirements apart from a standard
gcc
toolchain, builds for example on a currentalpine
ordebian-slim
. - Statically linked, so the single resulting binary is “portable” between compatible architectures.
Of course, dinit
can also be used as on-demand process control daemon without any containerization involved.
Alternatives and Motivation
Traditional small init systems such as for example sysvinit or busybox init try very hard to not provide a way to terminate for themselves.
Without an orderly exit, they are thus not really suited for Docker environments.
Also, they usually need to provide a tty
and maintain a socket for runlevel changes, e.g., by shutdown
commands.
Using a shell script with background tasks – as the Docker documentation suggests – needs customizing effort and seems fragile. Such a shell implementation can become complicated very quickly when signals to be broadcasted come into play or when trying to not leave child processes behind.
With a compose file (documentation) one can rely on Docker for (re)starting and stopping multiple containers with individual services. When the processes are tightly coupled, this however can require a certain effort it to provide a way for IPC (if even possible) and shared volume access.
The process control system supervisord (manpage) requires a Python interpreter stack.
Similar to an inittab
, this approach is also configuration-file based and watches a socket (http server) for cli commands.
As most of the other approaches, additional features do not come “for free” regarding CPU overhead here, sleeping/polling for events require the main process to wake up and possibly even fail.
As simple process supervisor, dumb-init can be used inside minimal container environments for handling signals when running a single process. However, it can wait for all children of this process, which can for example be a simple script.
Similarly, tini as tiny init for containers can spawn a single child inside a container, await/reap processes, and forward signals.
The minimal Ubuntu baseimage with a correct init process (Python) spawns runit
, which in turn can be used to run multiple start/stop scripts.
There also seem to be several related Go-based projects. These however do not satisfy the requirements regarding minimal runtime dependencies and resource overhead.
Usage
To build locally using gcc
, simply calling make
is sufficient:
make
Running make install
will additionally create the static and stripped binary at /usr/local/bin/dinit
.
Starting locally will result in:
./dinit
Usage: ./dinit [-q...] [-d startup_delay] [-k kill_timeout] -- command -- command -- ...
Commands: [:wait:][-]command [args, ...]
No configuration is needed, all commands to spawn are given as arguments, each one preceded by two dashes.
./dinit -- sleep 5 -- sleep 3
dinit: Started 18289 'sleep'
dinit: Started 18290 'sleep'
dinit: Child 18290 'sleep' exited with status 0
dinit: Stopped child 18289 'sleep', exited with status 15
dinit: Exiting, status 15
When prefixed with :wait:
, the command is not treated as daemon but has to complete at startup.
This can be useful for example when calling setup or migration scripts or when waiting on a port to become reachable.
If prefixed with a dash, a nonzero exit status does not result in an overall error.
Optional argument flags:
-q
-
Quiet, can be given up to three times.
Per default, informational messages are printed to
stderr
. Given once, only errors are logged; Twice only lets command output through; Three times mutes everything via/dev/null
redirections. -d startup_delay
(default 1)- Time in seconds a process must stay alive in order to continue with the next one during startup.
-k kill_timeout
(default 5)-
When stopping a process, wait up to this time in seconds until using the
KILL
signal.
In a Dockerfile
, an extra stage with the basic compiler toolchain can be used to build the static binary.
Base images can be for example alpine
or debian-slim
.
The static binary can then be copied into the actual image and used as CMD
or ENTRYPOINT
.
FROM alpine AS build-dinit
RUN apk update && apk add --no-cache build-base
# or for example:
# FROM debian:bullseye-slim
# RUN apt-get update && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/*
COPY dinit.c Makefile /
RUN make install clean
FROM alpine
# …
COPY --from=build-dinit /usr/local/bin/dinit /usr/local/bin/dinit
COPY --from=build-dinit /usr/local/bin/dcrond /usr/local/bin/dcrond
# for example:
CMD ["/usr/local/bin/dinit", \
"--", "/entrypoint.sh", "php-fpm", \
"--", ":wait:php", "-f", "console", "core:update", \
"--", "nginx", "-g", "daemon off;", "-e", "stderr", \
"--", "/usr/bin/crond.sh", "3600"]
ENTRYPOINT []
The above example will result in the expected process tree such as:
/usr/bin/containerd-shim-runc-v2
\_ /usr/local/bin/dinit -- /entrypoint.sh php-fpm -- nginx -g daemon off; -e stderr -- /usr/bin/crond.sh 3600
\_ php-fpm: master process
| \_ php-fpm: pool www
\_ nginx: master process
| \_ nginx: worker process
\_ /bin/sh /usr/bin/crond.sh 3600
\_ sleep 3600
Instead of building in a separate stage and copying from it, a similar Dockerfile
also is provided for COPY
or as base image.
Mini Cron for Docker
Just like in-container process management, running periodic tasks is a common concern as well. A full cron clone can be overkill and cron-like shell scripts can be fragile when it comes to signal and error handling.
As lightweight solution, dcrond
is built along with dinit
and can be used for recurring, arbitrary shell commandlines:
./dcrond
Usage: dcrond [-q...] [-d delay] -i interval -- [-]commandline
./dcrond -qqq -d 60 -i 3600 "php -f cleanup-things.php"
-q
-
Quiet, can be given up to three times.
Per default, informational messages are printed to
stderr
. Given once, only errors are logged; Twice only lets command output through; Three times mutes everything via/dev/null
redirections. -d delay
(default 0)- Initial delay in integer seconds until the first run starts. Per default, one command instance is created immediately, followed by runs respecting the configured interval.
-i interval
- Time to sleep in seconds between periodic runs.
- command
-
Single argument for the command or commandline to execute.
Gets passed to a shell as provided by
sh -c
(viasystem
).
Upon signal or Ctrl^C interrupt, dcrond
will exit cleanly – in contrast to an error status if the command fails.
If the commandline starts with a dash, errors will be ignored. (In this case, the argument itself should be separated by --
.)