Container images¶
The three images built by task P0-T03, how they are pinned, and how to run the local stack. Nothing
from ROS 2 or Gazebo installs on the host (ADR-0004);
everything below runs in containers driven by docker/compose.yaml and the root Makefile.
Quick start¶
make images # build base-ros2, sim and dev (under 15 minutes cold, a few minutes when cached)
make up # headless Gazebo server (gz sim -s -r empty.sdf) + dev container
make shell # bash in the dev container, ROS 2 and Gazebo on PATH, repo at /workspace
make down # stop the stack
make prune # also remove the project's images and dangling build layers
make test-images runs tests/images/test_images.sh, the verification for this task
(--quick reuses the build cache, --no-build tests images that are already present, which is
what CI does after building them with docker buildx bake).
Images¶
| Image | Dockerfile | Contents | Size |
|---|---|---|---|
ghcr.io/dimitrios-kafetzis/fieldproof-base-ros2 |
docker/base-ros2/Dockerfile |
Ubuntu 24.04, ROS 2 Jazzy ros-base, tini, non-root user fieldproof (uid 1000) |
0.9 GB |
ghcr.io/dimitrios-kafetzis/fieldproof-sim |
docker/sim/Dockerfile |
base + ros-jazzy-ros-gz (Gazebo Harmonic via the Jazzy vendor packages), xacro for the vehicle descriptions, Mesa EGL stack, headless defaults; the repository's Gazebo plugins (sim/fieldproof_gz_plugins: the world events of task P2-T03) built by a build stage into /opt/fieldproof_sim, where GZ_SIM_SYSTEM_PLUGIN_PATH points (worlds.md) |
3.2 GB |
ghcr.io/dimitrios-kafetzis/fieldproof-dev |
docker/dev/Dockerfile |
sim + build-essential, cmake, ninja, clang/clang-format/clang-tidy/clangd, gdb, colcon with mixins, rosdep, vcstool, uv, passwordless sudo | 4.1 GB |
ghcr.io/dimitrios-kafetzis/fieldproof-bridge |
docker/bridge/Dockerfile |
sim + the repository's fieldproof_bridge and fieldproof_robots packages built into /opt/fieldproof_bridge (two stages: colcon in the first, the install space and the network tools iproute2, iperf3 and iputils-ping in the second); default command fieldproof-bridge, the bridge launch on FIELDPROOF_MANIFEST (bridge.md) |
3.3 GB |
ghcr.io/dimitrios-kafetzis/fieldproof-apt-lists |
docker/base-ros2/Dockerfile, stage apt-lists |
only /var/lib/apt/lists of the base image, tagged <APT_SNAPSHOT>-<ROS_SNAPSHOT>; seeds later builds (see Package lists) |
46 MB |
Local builds are tagged :local. CI pushes sha-<short>, main and vX.Y.Z tags after a
syft SBOM and a Trivy scan (ci.md). Override the name or tag with
FIELDPROOF_IMAGE_PREFIX and FIELDPROOF_IMAGE_TAG; FIELDPROOF_IMAGE_TAG=main make up runs
the published images instead of local builds.
Each image runs as the non-root user fieldproof, starts through tini and an entry point that
sources /opt/ros/jazzy/setup.bash, and carries OCI labels (org.opencontainers.image.source,
revision = git SHA, created, licenses, title, version, base.name/base.digest) plus
io.fieldproof.apt-snapshot and io.fieldproof.ros-snapshot. make images fills the revision and
date; docker buildx build without arguments leaves them at unknown.
Gazebo Harmonic through the ROS vendor packages¶
ROS 2 Jazzy ships Gazebo Harmonic as ros-jazzy-gz-*-vendor packages, which ros-jazzy-ros-gz
pulls in. The simulator therefore comes from the same snapshot-pinned ROS repository as ROS itself
and no third apt source (packages.osrfoundation.org) is needed. The gz CLI, the simulation
systems and the bundled worlds (empty.sdf and friends) land under /opt/ros/jazzy/opt/ and are
put on PATH and GZ_SIM_RESOURCE_PATH by the ROS environment.
Pinning¶
Every apt source inside the images is a dated snapshot, so a rebuild months later resolves the same package versions:
| Pin | Where | Value |
|---|---|---|
| Ubuntu base image digest | UBUNTU_DIGEST in docker/base-ros2/Dockerfile |
sha256:534baea6a22c03a63003dbc8dbe78fe34bc0d7e595d9a9dc9834884ff530eb55 (ubuntu:24.04, 2026-09-18) |
| Ubuntu archive snapshot | APT_SNAPSHOT |
20261010T000000Z at https://snapshot.ubuntu.com/ubuntu/<id> (main, updates, backports, security) |
| ROS 2 apt snapshot | ROS_SNAPSHOT |
2026-10-05 at http://snapshots.ros.org/jazzy/<date>/ubuntu |
| ROS snapshot signing key | docker/base-ros2/ros-snapshot-keyring.gpg |
ROS Snapshot builder, fingerprint 4B63 CF8F DE49 746E 98FA 01DD AD19 BAB3 CBF1 25EA |
| uv | UV_IMAGE in docker/dev/Dockerfile |
ghcr.io/astral-sh/uv:0.13.1 by digest, same version as the host |
| colcon mixins | COLCON_MIXIN_COMMIT in docker/dev/Dockerfile |
commit of colcon/colcon-mixin-repository |
| Lint containers | tests/images/test_images.sh |
hadolint/hadolint:v2.12.0, koalaman/shellcheck:v0.10.0 by digest |
| Scanners in CI | .github/workflows/images.yml, tests/ci/test_workflows.sh |
Trivy v0.76.0 (policy in docker/trivy-policy.rego), syft through anchore/sbom-action |
The only packages fetched from the live Ubuntu archive are ca-certificates and its dependencies,
installed before the sources are switched to the HTTPS snapshot host (the base image ships without
a CA bundle, so this bootstrap step cannot itself use the snapshot).
The ROS snapshot is signed by a dedicated key, not the packages.ros.org key from ros.key. The
public key is vendored as a minimal keyring and the test checks its fingerprint. It expires on
2027-06-01; refresh it from keyserver.ubuntu.com (key AD19BAB3CBF125EA) when that date nears.
The Ubuntu snapshot host is a single origin that answers each request with seconds of latency,
and apt fetches from one host serially, so installing the ~800 packages of the sim image would
take far longer than the 15-minute cold-build budget. fieldproof-apt-install (from
docker/base-ros2/apt-install.sh) therefore resolves versions from the snapshot index as usual,
asks apt for the file URIs, and downloads them with 16 parallel connections through apt-helper:
Ubuntu pool files are tried on the regular archive mirror first (FIELDPROOF_APT_MIRROR, default
http://archive.ubuntu.com/ubuntu, which carries the same files under the same paths for as long
as the version is current) and fall back to the snapshot host with retries. The real
apt-get install then verifies every cached file against the signed snapshot index before
unpacking, so the mirror cannot change what gets installed. The package lists fetched from the
snapshots are kept in the base image (about 30 MB): they describe immutable snapshots, so the sim
and dev images, and any vehicle image built on the base, install from them without another index
refresh from the slow host; fieldproof-apt-install only runs apt-get update when a configured
source has no lists yet. FIELDPROOF_APT_PARALLEL and FIELDPROOF_APT_MIRROR are environment
variables read by the script, so pass them as ENV or ARG lines in a derived Dockerfile.
Package lists from GHCR¶
The one request the mitigations above cannot avoid is the index refresh itself (about 30 MB in
eight requests to the slow snapshot host, with the odd 500 to retry). Since the snapshots never
change, the lists are fetched once and published: the apt-lists stage of the base Dockerfile is
a scratch image holding /var/lib/apt/lists, pushed by CI as
ghcr.io/dimitrios-kafetzis/fieldproof-apt-lists:<APT_SNAPSHOT>-<ROS_SNAPSHOT> on every push to
main. At build time docker/apt-lists-context.sh looks for that image (locally first, then on
GHCR) and hands it to the base build as the fieldproof-apt-lists context; the Dockerfile copies
it into /var/lib/apt/lists before the first install, so fieldproof-apt-install finds lists
for every configured source and skips apt-get update. When the image does not exist (a new
snapshot id, no network, or FIELDPROOF_APT_LISTS=off), the context is the empty directory
docker/base-ros2/apt-lists-empty and the refresh from the snapshot hosts happens as before.
The tag carries both snapshot ids, and the helper only skips the refresh when lists for every
configured source are present, so lists from another snapshot can never be used by mistake.
Changing a snapshot pin therefore costs one slow refresh on the first build, after which CI
publishes the new lists image.
Downloaded .deb files live in a BuildKit cache mount, so a rebuild after changing a Dockerfile
line does not re-download the ROS and Gazebo packages. NO_CACHE=1 make images ignores the layer
cache and uses a fresh cache-mount namespace, which is what a new machine sees. CI keeps a layer
cache in GHCR instead (ci.md).
Rendering¶
The sim image renders sensors headless with Gazebo's ogre2 engine over EGL (EGL_PLATFORM=surfaceless,
QT_QPA_PLATFORM=offscreen, gz sim --headless-rendering). Two modes:
- GPU through
/dev/dri. The container gets the host render node (devices: /dev/dri) and the host'srendergroup id (group_add). Mesa picks the hardware driver (radeonsi on the reference workstation).docker/compose.gpu.yamladds both; the Makefile includes it automatically when/dev/dri/renderD*exists and exportsFIELDPROOF_RENDER_GIDfromgetent group render. - Software.
LIBGL_ALWAYS_SOFTWARE=1forces Mesa's llvmpipe. This is the default indocker/compose.yaml, what CI uses, and whatRENDER=software make upselects on a GPU host.
fieldproof-render-info inside the sim or dev container prints the render nodes, the
LIBGL_ALWAYS_SOFTWARE setting and the EGL devices Mesa sees. Ogre writes the renderer it
initialised to ~/.gz/rendering/ogre2.log (GL_RENDERER line).
Compose profiles and networking¶
| Profile | Service | Purpose |
|---|---|---|
sim |
sim |
gz sim -s -r --headless-rendering ${FIELDPROOF_WORLD:-depot.sdf} with the Fieldproof worlds, models and assets bind-mounted (below) |
dev |
dev |
development container, repository bind-mounted at /workspace, sleep infinity |
bridge |
bridge |
the bridge of one vehicle (task P1-T04): fieldproof-bridge on FIELDPROOF_VEHICLE_MANIFEST (default examples/vehicles/ugv_nav2/robot.yaml), the repository bind-mounted read-only at /workspace so the manifest and its description resolve; started on demand, not by make up |
images |
base-ros2, apt-lists |
build-only services (the base image the others build on, and its package lists); never started |
make up enables sim and dev, after make assets has fetched the Gazebo Fuel models pinned
in assets/fuel.lock. The sim service runs a Fieldproof world: FIELDPROOF_WORLD names the
file (default depot.sdf, the reference world of task P1-T02; fieldproof_empty.sdf is the
other one), resolved through GZ_SIM_RESOURCE_PATH, which the service sets to four read-only
bind mounts so the image itself carries no repository content and a world or model edit needs
no rebuild (worlds.md):
| Mount | Source | Contents |
|---|---|---|
/fieldproof/worlds |
sim/fieldproof_worlds/worlds |
the world files |
/fieldproof/models |
sim/fieldproof_worlds/models |
the package's primitive models (model://pallet, model://traffic_cone) |
/fieldproof/fuel |
assets/fuel/models |
the fetched Fuel models (model://pallet_rack_section, ...) |
/fieldproof/robots |
robots/fieldproof_robots/models |
the reference vehicle models, so a spawned UGV's meshes resolve (model://ugv_ref/meshes/...) |
Gazebo's own worlds stay reachable through the ROS environment, so FIELDPROOF_WORLD=empty.sdf
make up still works. The vehicle itself is spawned from the dev container or by a one-shot
container of the bridge image (ros2 launch fieldproof_robots spawn_ugv_ref.launch.py
world:=depot ..., see robots.md); the bridge follows:
docker compose -f docker/compose.yaml --profile bridge run --rm --no-deps bridge \
ros2 launch fieldproof_robots spawn_ugv_ref.launch.py world:=depot x:=10 y:=3 yaw:=-1.5708 robot_state_publisher:=false
docker compose -f docker/compose.yaml --profile bridge up -d bridge
make images builds the bridge image with the others, make down stops it with the stack.
The bridge image is also what fieldproof run starts as the recorder of a run (task P1-T06,
assertions.md): the engine's observer script
and the run directory bind-mounted, ros2 bag record to MCAP and the /fieldproof/* side
channels; not a compose service, labelled io.fieldproof.role=recorder. With radio links
(task P2-T02, network.md) it is also each vehicle's link holder
(sleep infinity as root with NET_ADMIN, whose network namespace the vehicle shares and on
which tc applies the link's profile; role link) and the ground station (the engine's
ground.py mounted like the observer; role ground).
fieldproof run (task P1-T05) starts the same three kinds of container itself through the
Docker API, with the same mounts, environment and network, from a scenario file
(scenarios.md); the compose stack remains the
way to run the simulator alone or to work from the dev container.
The services share the fieldproof bridge network; Gazebo
transport and DDS discovery use multicast on it, and GZ_PARTITION=fieldproof is set in every
container so Gazebo topics are visible across containers regardless of hostname. ROS_DOMAIN_ID
defaults to 0 for the compose stack and can be overridden from the environment; fieldproof run
gives every run a domain of its own (derived from the run id, between 1 and 100, unless
ROS_DOMAIN_ID or fieldproof.toml pins one) and can start a Fast DDS discovery server per run
instead of multicast (scenarios.md), so a run never sees the
compose stack's graph and two runs never see each other's.
Inside the dev container the ROS environment is sourced for interactive shells (.bashrc) and for
login shells (/etc/profile.d/10-ros2.sh), so both of these work:
docker compose -f docker/compose.yaml exec dev bash # interactive
docker compose -f docker/compose.yaml exec -T dev bash -lc 'ros2 topic list'
Vehicle images¶
A vehicle runs in its own container built from its repository
(ADR-0004). Task P1-T04 generates that image from the
vehicle manifest: fieldproof vehicle build robot.yaml writes a two-stage Dockerfile on
fieldproof-base-ros2 (build tools, vcs import at pinned commits, rosdep against a pinned
ros/rosdistro commit, colcon build --merge-install; then a runtime stage with only the run-time
dependencies, the install space and the manifest, as the fieldproof user) and runs
docker buildx build. make vehicle-image builds the example vehicle
examples/vehicles/ugv_nav2 (Nav2 and SLAM Toolbox on the reference UGV) on the same base image
prefix and tag as the stack, fieldproof vehicle run robot.yaml starts it on the fieldproof
network with the manifest's CPU, cpuset and memory budget (cgroup limits, what docker stats
shows) and its DDS profile mounted, where it discovers the other containers over DDS, and
fieldproof vehicle wait robot.yaml runs the image's readiness probe (also its HEALTHCHECK)
until the manifest's runtime.ready conditions hold. The generated Dockerfile,
the commands and the example are described in
manifest.md; make test-vehicles is their test and
make test-integration brings sim, bridge and vehicle up together in the depot
(manifest.md).
Dev container¶
.devcontainer/devcontainer.json opens the repository in the dev service with VS Code's
Dev Containers extension and also starts sim. It uses docker/compose.yaml alone, so rendering is
in software; to use the GPU add "../docker/compose.gpu.yaml" to dockerComposeFile and set
FIELDPROOF_RENDER_GID in your environment (docker/.env is git-ignored and a good place for it).
rosdep is initialised in the image but not updated, because rosdep update fetches the live
rosdistro index. Run it in the container when you need rosdep key resolution.
Updating a pin¶
make pins (docker/pins-check.sh, run every Monday by the Pins workflow) reports which pins are
behind their upstream; Dependabot does not cover them (ci.md).
- Pick the new value:
docker buildx imagetools inspect ubuntu:24.04for the digest; the Ubuntu snapshot id is anyYYYYMMDDTHHMMSSZtimestamp; ROS snapshot dates are listed athttp://snapshots.ros.org/jazzy/. - Change the
ARGdefault in the Dockerfile and the table above in the same commit. make imagesthenmake test-images. The Trivy scan in CI fails on a fixed CRITICAL vulnerability, which is the usual reason to move a snapshot forward. The first build after a snapshot change refreshes the lists from the snapshot hosts; once the change is onmain, CI publishes the matchingfieldproof-apt-listsimage and later builds skip the refresh.
Troubleshooting¶
make up fails with error gathering device information while adding custom device "/dev/dri".
The host has no render node but RENDER=gpu was forced. Use RENDER=software or let the Makefile
detect it.
Camera topics never publish, gz-sim.log shows Unable to create the rendering window or EGL
errors. With /dev/dri mapped, the container user must be in the host's render group:
check FIELDPROOF_RENDER_GID matches getent group render on the host. Without a usable device
Mesa still has the software path, so setting LIBGL_ALWAYS_SOFTWARE=1 isolates GPU problems.
gz topic -l in the dev container is empty while the sim is running. Both containers must
share the fieldproof network and GZ_PARTITION. docker compose ps shows whether sim is up;
docker compose logs sim shows the server output.
Build cannot reach snapshot.ubuntu.com or snapshots.ros.org. Both are single-origin
services without mirrors; retry (apt is configured with three retries) or check the proxy. A
corporate proxy that rewrites HTTPS breaks the snapshot host's certificate check.