Container Launch
ContainerServer, server_image, docker-run launch vehicle, image contract
Container launch is one of server modeβs two launch vehicles (the other is Native Launch, a host subprocess on server_python): a nodeset that declares server_image is auto-hosted inside docker run instead of a native host process. The hard promise: the container serves the exact same protocol on the same kind of port, so everything downstream β proxy-node generation, env-panel bridge, multi-worker fan-out, the /mcp projection β is byte-identical to a conda-launched server, and images never contain framework code (the repo is bind-mounted read-only; the image carries only the model's dependencies). For the protocol itself and the conda path read Server Mode; for what a nodeset is read NodeSets.
How the code launches a server_image nodeset.
1. What it does
Some systems can't be expressed as a conda environment at all β a C++ SLAM stack with its own compiled world (ORB-SLAM3), or a GPL codebase that must stay out of the framework's process and env (pySLAM). Container launch lets such a system join the canvas the same way every server-mode nodeset does: the registry launches it, reads its /manifest, and generates proxy nodes. The only thing that changes is the launch vehicle β docker run instead of a conda interpreter. The container runs the stock auto_host.py from a read-only bind mount of the repo, so there is no per-nodeset bridge code, no in-image copy of the framework, and a framework upgrade never requires an image rebuild.
Before this existed, the one containerized nodeset (model_pyslam) carried its own private bridge β a hand-written _client.py/_server.py pair pretending to be a local-mode nodeset, invisible to the registry, the scheduler, and GPU admission. Container launch moves that mechanism into the framework: declare an image, get the whole server-mode feature set.
2. The smallest container nodeset
One ClassVar is the whole trigger β everything else is ordinary nodeset authoring:
class OrbSlam3NodeSet(BaseNodeSet): name = "model_orbslam3" server_image = "agentcanvas/orbslam3:latest" # β the whole trigger server_image_gpu = False # CPU SLAM core server_mounts = {"data/tum": "/opt/data/tum:ro"} # get_tools() returns the reset / track / get_trajectory nodes as usual
Loading it (POST /api/components/nodesets/model_orbslam3/load) auto-routes to server mode (registry.py Β· load_nodeset β the server_image check sits above the server_python one and wins when both are set) and ultimately runs this real command as a direct child of the backend:
docker run --rm --init --name ac_auto_model_orbslam3_9204 \ -p 127.0.0.1:9204:9204 \ --add-host host.docker.internal:host-gateway \ -v /path/to/agentcanvas:/opt/agentcanvas-workspace:ro \ -v /path/to/agentcanvas/data/tum:/opt/data/tum:ro \ -e PYTHONPATH=/opt/agentcanvas-workspace/agentcanvas/backend:/opt/agentcanvas-workspace \ -e AGENTCANVAS_EXECUTOR_URL=http://host.docker.internal:8000 \ agentcanvas/orbslam3:latest \ python3 -m app.server.auto_host \ --module workspace.nodesets.model.model_orbslam3 \ --class OrbSlam3NodeSet --port 9204
3. Declaration surface
Five ClassVars on BaseNodeSet (bases.py Β· BaseNodeSet) steer the container launch; server_env from the conda path also applies (its vars ride into the container's environment via -e):
| ClassVar | Default | Effect |
|---|---|---|
server_image | None | Docker image tag. Non-None auto-routes the nodeset to server mode and selects the container vehicle; takes precedence over server_python. |
server_image_gpu | False | Adds --device nvidia.com/gpu=all (CDI β the recipe that works on rootless docker; legacy --gpus does not). A failed GPU start is retried once without the flag. |
server_mounts | {} | {host_path: "container_path[:ro]"}. Host paths may be absolute, ~-prefixed, $VAR-bearing, or repo-root-relative. A missing :ro host path is skipped with a warning β the capability that needed it errors at call time, the server still starts. A missing rw host path is created β it is an output drop, and skipping it would silently strand the container's writes. |
server_container_python | "python3" | Interpreter path inside the image (images with a baked venv name its exact python). |
server_container_user | None | docker run --user override. Under rootless docker container uid 0 is the invoking host user, while a non-root image USER maps to a foreign subuid that cannot write host-owned rw mounts β an image that drops to a non-root USER (pyslam's slam) sets "0:0" so writes land host-user-owned. Loosens nothing: rootless already caps container-root at the host user's privileges. Pair with a HOME entry in server_env when the image's paths assume the baked user's home. None keeps the image's USER. |
4. Launch pipeline β what the registry assembles
registry.py Β· _build_container_server turns the nodeset declaration into a ContainerServer; three translations happen there, and only there:
- Path translation. The repo root is mounted at
/opt/agentcanvas-workspace(container_server.py Β· WORKSPACE_MOUNT); every host path riding the inner command line (--file) orPYTHONPATHis rewritten under that prefix. A source file outside the repo root is a hardRuntimeError, not a silently wrong path. - Mount resolution.
server_mountsentries are expanded (expandvars+expanduser, relative β repo-root-relative) and appended after the repo mount; missing:rohost paths are skipped with a warning, missing rw host paths are created (output drops). - Executor-URL rewrite. Loopback executor URLs (
127.0.0.1/localhost) are unreachable from inside a container, soAGENTCANVAS_EXECUTOR_URLis rewritten tohost.docker.internal, whichContainerServermaps to the host via--add-host host.docker.internal:host-gateway. This keeps the reverse log/error channel and cross-nodeset container access working from inside the container.
ContainerServer (container_server.py Β· ContainerServer) is a BaseServer subclass whose command is a foreground docker run: health polling, startup timeout, manifest fetch, and the pdeathsig chain all apply unchanged to the docker client, which proxies SIGTERM to PID 1 in the container (--init reaps). Everything after start() β proxy generation, env-panel bridge, worker fan-out bookkeeping, rollback on partial failure β is the shared server-mode path in registry.py Β· _load_nodeset_as_server, byte-identical for both vehicles.
5. Lifecycle, orphans, rootless
Container mode has one failure surface the conda path doesn't: the docker client can die (SIGKILL, OOM) while the container lives on β pdeathsig kills the client, not the daemon-owned container. Three mechanisms close it (container_server.py Β· ContainerServer):
- Deterministic names + pre-start reap. Every container is named
ac_<label>_<port>;start()first runsdocker rm -fon that name, so a leftover from a killed client is removed before the next launch (docker β₯ 23 exits 0 either way; the reap is logged only when something was actually removed). --rm+ signal proxying. The attached client forwards SIGTERM; a normal stop removes the container.docker stopinstop(). The authoritative path goes through the daemon first, then falls back toBaseServer.stop()killing the client's process group.
The docker client always talks to the rootless daemon: DOCKER_HOST and XDG_RUNTIME_DIR are forced to the user socket (container_server.py Β· rootless_docker_env) regardless of what the backend inherited, because images live only in the rootless store. A missing image fails fast at start() with an actionable error (container_server.py Β· image_exists) instead of a docker-pull hang.
6. Image contract
An image is valid for container launch when the interpreter named by server_container_python can import the model's own dependencies plus the small serve set in agentcanvas/backend/requirements-serve.txt (fastapi, uvicorn, httpx, msgpack, numpy, pydantic, pyyaml; mcp optionally adds the /mcp projection). Framework code is never baked in β it arrives at run time via the read-only repo mount. Consequences worth stating:
- Framework upgrades reach containers with no image rebuild (same property the conda envs have).
- The image cannot be started standalone against a stale framework copy β there is none inside it.
- Model weights follow the established external-weights pattern: keep them under
data/and mount viaserver_mounts, so images stay code-only.
7. Where it deviates from the clean model
server_image (its private _client.py/_server.py bridge is deleted β ~870 lines). The migration surfaced one image-contract gap worth knowing: an image ENTRYPOINT that mutates the environment (pyslam's pyenv-activate.sh cleared PYTHONPATH) silently destroys the -e injection auto_host depends on β the pyslam entrypoint now saves/restores PYTHONPATH around activation. Images should either have no ENTRYPOINT (orbslam3) or an env-transparent one.server_mounts Γ non-root image USER β closed 2026-08-20 by server_container_user. Rootless docker maps container uid 0 to the host user but container uid N to a subuid β so an image that drops to a non-root USER (pyslam's slam, uid 1000) could not write a host-owned rw mount (PermissionError on the /opt/out handle drop); orbslam3 never hit it because its image runs as root (= the host user). The fix is identity alignment, not permission loosening: such a nodeset declares server_container_user = "0:0" (Β§3) and its writes land host-user-owned; the registry also now creates missing rw-mount dirs instead of skipping them.RuntimeError from the GPU attempt (container_server.py Β· ContainerServer.start) β a GPU-unrelated startup failure inside a server_image_gpu nodeset therefore gets one redundant CPU retry before surfacing._load_nodeset_as_server loop constructs N ContainerServers exactly as it does BaseServers (unit-tested), but no worker_count > 1 container run has been exercised end-to-end; the validated smoke is single-worker.expected_vram_mb presets work as for any nodeset, but the measured-calibration path has not been exercised against a containerized process; whether measurement attributes a container's GPU memory correctly is unverified.AGENTCANVAS_EXECUTOR_URL passes through untouched β correct for a real LAN address, but a hostname that only resolves on the host will silently break the reverse channel inside the container.8. File reference
| File | Role |
|---|---|
app/server/container_server.py | ContainerServer, rootless_docker_env, container_name, image_exists, WORKSPACE_MOUNT |
app/components/bases.py | server_image / server_image_gpu / server_mounts / server_container_python / server_container_user ClassVars |
app/components/registry.py | auto-route in load_nodeset; assembly in _build_container_server; shared launch loop in _load_nodeset_as_server |
agentcanvas/backend/requirements-serve.txt | minimal in-image dependency set for running the stock auto_host |
app/server/test_container_server.py Β· app/components/test_registry_container_mode.py | command-assembly and translation unit tests |