Decision record

AD-0011 One binary, one process tree, one port, one UI server

Status: accepted. Date: 2026-08-27.

Context

The launch shape is fixed: one image, one docker run --rm -p 8080:8080 ghcr.io/roarge/sysml-federation once the first release publishes it, with the apps at /viewer and /document, the playground at /playground and / redirecting to the viewer. Behind that line stand three subgraphs, a router, two web apps and whatever serves them, and the decision here is how they are arranged inside the container. The router is a child process from the copied binary (AD-0010), which fixes one of the processes, and the two apps are vanilla JavaScript served from a Go binary with embedded assets (AD-0017).

The base image constrains the arrangement. Distroless static has no shell and no Python runtime, so s6-overlay, supervisord and a wait -n wrapper cannot run in it, and a Dockerfile HEALTHCHECK has nothing to execute unless the check is built into the image’s own binary. The router binary has no probe subcommand. Docker’s guidance calls one process per container a rule of thumb rather than a hard rule and allows several processes where the container has one concern.

The port question is separate. The router listens on localhost:3002 by default with the playground at / and the endpoint at /graphql. Go’s httputil.ReverseProxy flushes text/event-stream immediately and proxies 101 Switching Protocols, so SSE and WebSocket both pass through it, and one origin on one port needs no CORS configuration, whereas a two-port topology would depend on the router’s CORS defaults, whose acceptance by browsers for credentialed requests was not checked. The research left the one-port or two-port choice as an open question, with the remark that one port simplifies the launch line and hides the router as a separately addressable component.

The reading at gate 2 settled where the UI server lives. The single UI server that serves both apps and proxies the router belongs to the image element, so the two apps are HTML, CSS and JavaScript and nothing else. The supervisor, with its UI server and command dispatch, is expected to run to about 700 lines.

Decision

We will ship one Go binary, sysml-federation, whose default serve subcommand is PID 1 and runs the three subgraphs as goroutines on loopback ports 3011, 3012 and 3013, starts the router as a child on 127.0.0.1:3002, and runs one UI server on 0.0.0.0:8080 that serves both apps from embed.FS, proxies /graphql and /playground to the router and redirects / to /viewer. The subcommands adapter, capacity, document and ui run one component each on a given address for anyone who wants the services apart, and healthcheck GETs the router’s /health/ready on its loopback port and /viewer on the published port, exiting non-zero if either fails, for the image’s HEALTHCHECK. The router’s health path is not proxied.

Alternatives considered

Docker Compose with one container per service against the official router image, the shape the vendor’s own demos use and the lowest-risk one in the Cosmo research. Every subgraph is then a visibly separate service, which suits the README’s argument. It lost because “one command” would be docker compose up after obtaining a file, which needs Compose and either a clone, a git-backed URL or an OCI artefact, and the routing URLs in the execution configuration would differ from the single-container layout, so a second configuration would be needed. One command is literally true only with a single image.

Both shapes at once, the same image started several times by a compose file beside the official router container. The packaging research recommends it as a second entry point. It carries the second configuration and the file-fetching problem of the compose route and was not taken.

One image per service, four images plus the router. The cleanest separation and closest to how an adopter would deploy, and the most artefacts to build, publish and carry across an air gap. Not taken for a demo.

s6-overlay, supervisord or a shell wrapper as the in-container supervisor. Each needs a userland the distroless base does not have, and s6 would add a supervision layer to explain in a repository meant to be read in an afternoon. tini or docker run --init supervises exactly one child and would put a flag in the launch line.

Two published ports, the router on 3002 and the apps on 8080. It keeps the router addressable on its own. It lost on the launch line, which would carry two -p flags, and on the cross-origin rules that one origin avoids.

Consequences

The visitor gets one port and four paths (SR-04), the apps reference nothing outside that origin (SR-10), and every request from an app reaches the router through the proxy (SR-40). Startup is one process tree with an ordering the supervisor controls, so SR-02’s ten seconds is measured in one place. The healthcheck subcommand gives the image a HEALTHCHECK that a router container on its own could not carry.

The proxy hides the router. A visitor who wants to see the three services as separate processes has the subcommands, and nothing on the published port shows where the router’s responsibility ends and the UI server’s begins. A crash in any goroutine or in the child takes the whole demo down, which the packaging research calls acceptable for a demo while noting that it hides the process boundaries the argument is about.

The supervisor carries an ordering risk. What /health/ready reports with the subgraphs down was unverified when this record was written, so the subgraphs are started first and the published port is opened last, and a spike settles what the health check proves. Another settles whether the child starts from environment alone. The UI server also sets Cache-Control: no-cache and answers 404 for directory paths, because embed.FS sends no cache headers and lists directories by default.

Requirements affected

SR-01, SR-02, SR-04, SR-10, SR-40

Sources

Docker’s guidance on running more than one process in a container, the distroless static base image, and the Cosmo router’s default listen address and paths. Five views and twenty-six decisions for the deployment view and its process tree, and Five spikes before the first line for what the router’s readiness path proves.