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

  1. Talk to a container daemon API (Docker Engine): adds a privileged daemon and an API client; the daemon owns the process lifecycle, not scalws.
  2. Drive an OCI runtime (crun/runc) directly: requires image pulling, unpacking, rootfs/overlay management — a container engine of our own.
  3. Run a daemonless engine CLI (podman run, also docker 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); --rm removes 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 container port on a loopback port chosen by scalws (-p 127.0.0.1:<host>:<port>); network: host passes the chosen port in PORT (and {port} in command) for images that listen where told.
  • Security defaults: --security-opt no-new-privileges, --pull missing (configurable), environment only from configuration (--env KEY=VALUE), optional read_only, drop_capabilities, user, and validated bind mounts (absolute paths, ro by 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. With docker the daemon places containers; limits are not enforced (validation warning).
  • --init by default (init: false to 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 needs catatonit.
  • Every worker exit (stop, crash, SIGKILL after stop_timeout) is followed by <engine> rm --force <name> through the supervisor’s new cleanup hook, so no container outlives its worker and a restart never collides with a leftover name.
  • Default engine podman; engine_path overrides 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 run processes 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.