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:

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.

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 (via system).

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 --.)

Code & Download