Process Supervisors: s6, runit, and supervisord

Process Supervisors: s6, runit, and supervisord

systemd supervises services on most Linux systems. There are three situations where you want something else: inside containers, on distributions that do not use systemd, and for user-level process management.

The PID 1 problem

The reason this matters in containers, and it is not obvious.

PID 1 has a special duty: reaping orphaned children. When a process exits, its parent must call wait() to collect the exit status. If the parent has already exited, the child is reparented to PID 1, which is expected to reap it.

Run your application directly as PID 1 and it is now responsible for that. Most applications were never written to be PID 1 and do not reap. Orphaned processes accumulate as zombies until the process table fills.

docker exec mycontainer ps aux | grep defunct

Zombies there mean nothing is reaping.

PID 1 also has special signal semantics. It does not get default signal handlers, so a SIGTERM to a process that has not explicitly installed a handler is simply ignored. That is why some containers take the full ten seconds and then get SIGKILLed on every docker stop.

tini, if that is all you need

FROM debian:13-slim
RUN apt-get update && apt-get install -y tini
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/usr/local/bin/myapp"]

Or simply:

docker run --init myimage

--init inserts a minimal init that reaps and forwards signals.

tini supervises nothing. It does PID 1 duties and nothing else. If you have one process and want correct shutdown and no zombies, this is the right size of tool and costs almost nothing.

Most containers need this and not a supervisor.

s6

Used when you genuinely need several processes in one container.

FROM debian:13-slim

ARG S6_OVERLAY_VERSION=3.2.0.2
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp/
RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz

COPY rootfs /
ENTRYPOINT ["/init"]
rootfs/etc/s6-overlay/s6-rc.d/
├── myapp/
│   ├── type          # "longrun"
│   ├── run           # the script that execs the process
│   └── dependencies.d/
│       └── nginx
├── nginx/
│   ├── type
│   └── run
└── user/contents.d/
    ├── myapp
    └── nginx
# rootfs/etc/s6-overlay/s6-rc.d/myapp/run
#!/command/execlineb -P
s6-setuidgid appuser
/usr/local/bin/myapp

s6 handles PID 1 correctly, supervises each service, restarts on failure, and s6-rc provides real dependency ordering.

The s6-overlay distribution is what makes it practical in containers: it adds init stages, service definitions, and a clean shutdown path without you assembling them.

The learning curve is real. execlineb is s6’s own scripting language and it is unlike shell. You can use #!/bin/sh in run scripts instead, which most people do while learning.

runit

The same daemontools lineage, smaller and simpler.

/etc/service/
├── nginx/
│   ├── run
│   └── log/
│       └── run
└── myapp/
    ├── run
    └── log/
        └── run
# /etc/service/myapp/run
#!/bin/sh
exec 2>&1
exec chpst -u appuser /usr/local/bin/myapp
# /etc/service/myapp/log/run
#!/bin/sh
exec svlogd -tt /var/log/myapp
sv status myapp
sv restart myapp
sv down myapp

The design is elegantly small: a directory per service, a run script that execs the process in the foreground, and an optional log/run that receives its output on stdin.

exec matters. Without it the shell stays as the parent and the supervisor is watching the wrong process.

The logging model is worth noticing: services write to stdout, and the supervisor pipes that to a logger process. That is the same pattern containers adopted, and runit did it first.

runit is the init on Void Linux, which is why the Void packaging dispute matters to a distribution that deliberately avoids systemd. Artix and Alpine also offer it.

supervisord

The one people reach for first, and usually the wrong choice in a container.

[supervisord]
nodaemon=true
user=root

[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0

[program:myapp]
command=/usr/local/bin/myapp
user=appuser
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0

Readable configuration, a web interface, an XML-RPC API. Genuinely pleasant to configure.

The objections for container use:

It is Python, so you are adding an interpreter and its dependencies to the image, which is at odds with everything in our container security guide about minimal images.

Zombie reaping is not reliable in all configurations, which is the specific problem you brought a supervisor in to solve.

daemon off; and equivalents are mandatory. A supervised process that daemonises exits immediately from the supervisor’s view, and supervisord restarts it forever.

supervisord is fine on a full system where you want something simpler than systemd units. In a container, s6-overlay or tini fit better.

One process per container

Worth stating, since this whole article is about running several.

The container model assumes one process. Orchestrators restart containers, not processes inside them. Logs come from stdout of one thing. Health checks target one service. Scaling means more containers.

Putting nginx and an application in one container means the orchestrator cannot restart nginx alone, cannot scale them separately, and cannot tell you which one is unhealthy.

The legitimate exceptions:

A genuinely coupled sidecar, such as a log shipper that must share the filesystem.

Legacy software that assumes several cooperating processes and cannot be split.

Development convenience, where one container is simpler than a compose file.

Outside those, use Docker Compose and give each process its own container. It is less work than configuring a supervisor and it matches how everything downstream expects to behave.

Choosing

Use it for
tini or --initOne process, correct PID 1 behaviour
s6-overlaySeveral processes in a container, done properly
runitVoid or Alpine, or a simple non-systemd system
supervisordNon-container systems where readable config wins
systemdEverything else

For most containers the honest answer is --init and one process. The supervisors matter when you have a real reason, and “I want two things in one image” is usually not one.

Frequently Asked Questions

Why would I use a process supervisor instead of systemd?

Inside containers, where systemd is awkward and frequently unavailable. On distributions that do not use systemd such as Void or Alpine. And for user-level process management where you want something simpler than systemd user units.

What is the zombie process problem in containers?

PID 1 is responsible for reaping orphaned child processes. If your container runs an application directly as PID 1 and that application does not reap, orphaned children accumulate as zombies until the process table fills. A supervisor as PID 1 handles reaping correctly.

What is the difference between s6 and runit?

Both follow the daemontools design of one directory per service with a run script. s6 is more capable, with a proper dependency system and s6-rc for ordering, while runit is smaller and simpler. runit is easier to learn, s6 scales further.

Should I run multiple processes in one container?

Generally no, since one process per container is the model that makes orchestration, logging, and restarts work properly. The legitimate exceptions are a main process with a genuinely coupled sidecar, or packaging legacy software that cannot be split.

Is supervisord a good choice for containers?

It is widely used and it is Python, which means an interpreter and dependencies in your image. It also does not reap zombies by default in all configurations. s6-overlay or tini are lighter and handle PID 1 duties more correctly.

What does tini do that a full supervisor does not?

tini does only PID 1 duties: reaping zombies and forwarding signals. It supervises nothing. If you have one process and only need correct PID 1 behaviour, tini is the right size of tool and adds almost nothing to the image.