Decision records
ADR-0020: Container runtime adapter
- Status: Accepted (2026-10-06)
Context
M10: containers as an application runtime, next to PHP/Node/Python. Constraints from the handoff: avoid unnecessary infrastructure, Linux first, tenant isolation through cgroup v2, no shell interpretation of operator input, runtimes behind the adapter contract (ADR-0004) with supervision (ADR-0008).
Options
- Talk to a container daemon API (Docker Engine): adds a privileged daemon and an API client; the daemon owns the process lifecycle, not scalws.
- Drive an OCI runtime (crun/runc) directly: requires image pulling, unpacking, rootfs/overlay management — a container engine of our own.
- Run a daemonless engine CLI (
podman run, alsodocker run) as a supervised worker and proxy to it like any HTTP application.
Decision
Option 3, as runtime: {type: container} in a new crate scalws-runtime-container.
- Each worker is the foreground process
<engine> run --rm --name scalws-<tenant>-<app>-<n>-<token> …started by the existing supervisor (argv only, no shell, environment cleared); readiness is the usual HTTP probe; SIGTERM to the worker stops the container (--sig-proxy, and--stop-timeout);--rmremoves it. A worker pool (workers.min..max) gives several containers with live scaling (ADR-0014/0017) and zero-drop restart. - Networking:
network: bridge(default) publishes the containerporton a loopback port chosen by scalws (-p 127.0.0.1:<host>:<port>);network: hostpasses the chosen port inPORT(and{port}incommand) for images that listen where told. - Security defaults:
--security-opt no-new-privileges,--pull missing(configurable), environment only from configuration (--env KEY=VALUE), optionalread_only,drop_capabilities,user, and validated bindmounts(absolute paths,roby default). Image references are validated (no options smuggling: must not start with-). - Tenancy: with cgroups enforced and engine
podman, containers are created under the application’s cgroup (--cgroup-manager cgroupfs --cgroup-parent <app group>), so tenant CPU/memory/pids limits include them; the engine CLI process itself runs in the server’s group. Withdockerthe daemon places containers; limits are not enforced (validation warning). --initby default (init: falseto disable): a minimal init as PID 1 forwards SIGTERM — server binaries running as PID 1 ignore it (found in testing: containers outlived their workers). podman needscatatonit.- Every worker exit (stop, crash, SIGKILL after
stop_timeout) is followed by<engine> rm --force <name>through the supervisor’s newcleanuphook, so no container outlives its worker and a restart never collides with a leftover name. - Default engine
podman;engine_pathoverrides the binary. - Verified end to end with rootful podman 4.3 (vfs storage, cgroupfs) in the Linux test container: serving, tenant cgroup placement, scaling, zero-drop restart, cleanup. Bridge networking is covered by command-line unit tests only (nested networking is unreliable in the test container).
Consequences
- Requires podman (or docker) on the host; scalws does not pull images until a worker starts
(
pull: missing); a missing image makes the worker fail and back off like any crash. - If scalwsd is killed with SIGKILL,
podman runprocesses die with the systemd unit (KillMode=control-group); containers created under the tenant cgroup are in the same unit. Names carry a per-start token so restarts never collide with leftovers.