refactor(docker): supervise the container with s6-overlay - #4019
refactor(docker): supervise the container with s6-overlay#4019gantoine wants to merge 2 commits into
Conversation
The init script was a hand-rolled supervisor: PID files, /proc polling, a 5s watchdog loop and a trap-based shutdown. Its shutdown wait was unbounded, so a process that refused to exit stranded PID 1 and left the container up and serving nothing, which no restart policy can detect. s6-rc replaces it with a declared service graph. Ordering and readiness gates that were sequential bash become dependencies, and the failure modes become configuration: - timeout-kill bounds SIGTERM before SIGKILL, and flag-timeout-killpg escalates to the whole process group. - S6_BEHAVIOUR_IF_STAGE2_FAILS=2 exits the container when a startup step fails, so the restart policy retries instead of the container wedging. - valkey gets --shutdown-on-sigterm force, so it stops refusing to exit when the snapshot it writes on shutdown cannot be written. A crashed service is now restarted on its own instead of taking the whole container down. Its leftover children are swept first, because children reparented from a hard-killed master keep holding its listening sockets and would make every replacement fail to bind. Optional services have no conditional mechanism in s6-rc, so they start, check their environment variable, and take themselves down with s6-svc -O. Running with a read-only root filesystem now needs /run mounted exec, since s6 executes its generated stage scripts from there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Heads up: if [[ ${ENABLE_SCHEDULED_RESCAN:-false} != "true" &&
${ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB:-false} != "true" &&
${ENABLE_SCHEDULED_UPDATE_LAUNCHBOX_METADATA:-false} != "true" &&
${ENABLE_SCHEDULED_CLEANUP_ORPHANED_RESOURCES:-false} != "true" ]]; then
debug_log "No scheduled tasks are enabled, not starting the RQ scheduler"
disable_serviceNothing else respects that condition, in three separate ways:
Worth noting Not a blocker for this PR, just flagging so it does not get re-introduced. Whichever of the two lands second will need to carry the change across, and in the s6 layout that means dropping the Separately, and much smaller: this drops the I verified the valkey change against 5.1.0 on my own hardware, read-only Nice. |
The s6 service carried over the four-flag ENABLE_SCHEDULED_* gate from the init script, which #4033 has since removed on master. Since this branch deletes that script, the fix would be lost on rebase. Nothing else respects the condition: startup.py registers the netplay, upload-tmp and zip-cache cleanups unconditionally, three more flags gate periodic tasks absent from the list, and the watcher defers its rescans through the scheduler. Any of those leaves jobs in the scheduler registry with no process to run them, and a stuck delayed scan_platforms entry reads to the concurrent scan guard as a scan already queued, refusing every manual scan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Description
Explain the changes or enhancements you are proposing with this pull request.
Supersedes #3990, which fixed the unbounded shutdown wait in
docker/init_scripts/init. This takes the alternative route discussed there: rather than bounding the wait, replace the hand-rolled supervisor with one that already solves this class of problem.initwas ~400 lines of process supervision: PID files,/procpolling, a 5s watchdog loop, and a trap-based shutdown whose wait was unbounded. A process that refused to exit stranded PID 1, and the container then satUpserving nothing, which no restart policy can detect because policies only react to exits.s6-overlay replaces it. Ordering and readiness gates that were sequential bash become a declared dependency graph, and the failure modes become configuration rather than code:
docker-entrypoint.shis unchanged and stillexecs/init, which is now s6's. Every process, environment variable and conditional from the old script carried over.Changes
timeout-kill(10s) bounds SIGTERM before SIGKILL, andflag-timeout-killpgescalates to the whole process group. No process can hold PID 1 open.S6_BEHAVIOUR_IF_STAGE2_FAILS=2exits the container when a startup step fails, so the restart policy retries. This is the self-healing path for the reported case, where the daemon's restart policy starts containers at host boot without honouring compose'sdepends_onand RomM comes up before its database.--shutdown-on-sigterm force, so it stops refusing to exit when the snapshot it writes on shutdown cannot be written. This is the root cause of the reported hang, and unlike settingREDIS_SAVE_POLICYempty it costs no persistence.linux/amd64andlinux/arm64.One regression found and fixed during testing
Hard-killing nginx's master leaves its workers orphaned and still holding
:8080, so every replacement fails to bind. The first version of this branch restart-looped forever on that.masteris not immune either: I tested it, and the same kill takes the whole container down (exit 1, after 20Address in useerrors), relying on the restart policy to clear the orphans. Each longrun now has afinishscript that sweeps the dead service's process group before s6 brings it back, which makes this strictly better than today: the service recovers and the container stays up.Behaviour change for read-only root filesystems
/runmust now be mountedexec, since s6 executes its generated stage scripts from there, and Docker's--tmpfs /rundefaults tonoexec. Documented indocs/BACKEND_ARCHITECTURE.md.Worth saying plainly that a read-only root filesystem does not fully work on
mastertoday either: it fails earlier, on/etc/nginx/conf.d/default.conf, before s6 is involved. I did not try to fix that pre-existing part here.Testing
Built the image and exercised it against a real MariaDB. All results below are from the built image, not the fast iteration image:
/redis-data,docker stop -t 60master: 60.3s, exit 137, SIGKILLed by docker)master: wedges)kill -9edREDIS_HOSTset)valkey-serverprocesses--user 1000:1000)/runmountedexec)Also
shellcheckandbash -nclean across all 21 shell scripts, andtrunk fmt && trunk checkclean.Draft, because
linux/arm64path is built locally, thelinux/amd64tarball is pinned but unbuilt here.Checklist
Please check all that apply.
There is no shell test harness in the repo. The verification above is manual, against real containers, and reproducible from the table.
AI assistance disclosure
Per
CONTRIBUTING.md: this change was written with AI assistance (Claude Code). The AI wrote the s6 service tree, the Dockerfile changes and the docs, and ran the verification above. Reviewed before opening.