AgentCanvas / Pages / Developer Guide / Design Docs / Operations / Container Launch
2026-08-13

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.

AgentCanvas process registry.load_nodeset server_image set β†’ mode="server" _build_container_server path translate Β· mounts Β· executor URL ContainerServer.start() reap stale Β· docker run (foreground child) then: /health poll Β· /manifest Β· proxy-node gen Container (server_image) repo mount (read-only) /opt/agentcanvas-workspace auto_host + AutoServerApp stock code, run FROM the mount manifest protocol /manifest Β· /call Β· /health Β· /mcp -p 127.0.0.1:port:port docker run HTTP

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):

ClassVarDefaultEffect
server_imageNoneDocker image tag. Non-None auto-routes the nodeset to server mode and selects the container vehicle; takes precedence over server_python.
server_image_gpuFalseAdds --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_userNonedocker 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:

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):

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:

7. Where it deviates from the clean model

model_pyslam migrated 2026-08-20. The one pre-existing containerized nodeset now declares 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.
rw 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.
GPU degrade is coarser than "CDI absent". The model says GPU falls back when CDI is unavailable; the code retries the whole start once on any 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.
Multi-worker fan-out with containers is assembled but not integration-tested. The shared _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.
VRAM admission sees the declaration, not a container measurement. 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.
Only loopback executor URLs are rewritten. A non-loopback 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

FileRole
app/server/container_server.pyContainerServer, rootless_docker_env, container_name, image_exists, WORKSPACE_MOUNT
app/components/bases.pyserver_image / server_image_gpu / server_mounts / server_container_python / server_container_user ClassVars
app/components/registry.pyauto-route in load_nodeset; assembly in _build_container_server; shared launch loop in _load_nodeset_as_server
agentcanvas/backend/requirements-serve.txtminimal in-image dependency set for running the stock auto_host
app/server/test_container_server.py Β· app/components/test_registry_container_mode.pycommand-assembly and translation unit tests
AgentCanvas docs