Skip to main content
  1. Projects/
  2. Tendon-Driven Robotic Hand — A Vision-Teleoperated MuJoCo Digital Twin/

Docker Compose architecture for ROS 2: dependency-only images and mounted code

Mulham Fetna
Author
Mulham Fetna
Renaissance Engineer
Table of Contents
ROS 2 Tendon-Driven Hand MuJoCo Twin - This article is part of a series.
Part 16: This Article
Both images hold dependencies and nothing else. The code and the robot model are mounted from your checkout at runtime — so an edit is a five-second restart, not a five-minute rebuild.

The big picture
#

flowchart TB
    subgraph HOST["🐧 Linux host"]
        CAMDEV["/dev/video0"]
        GPU["/dev/dri · Intel iGPU"]
        X11["/tmp/.X11-unix
XWayland :0"] SHM["/dev/shm
Fast DDS segments"] NET["host network
UDP multicast · domain 42"] REPO["repository checkout"] subgraph VT["🐳 vision_tracker · 2.9 GB image"] VN["vision_tracker_node.py
mediapipe 0.10.14 · OpenCV"] end subgraph MT["🐳 mujoco_twin · 1.7 GB image"] MN["mujoco_twin_node.py
mujoco 3.13.0 · GLFW"] end end CAMDEV --> VN GPU --> VN GPU --> MN X11 <--> VN X11 <--> MN VN <--> SHM <--> MN VN <--> NET <--> MN REPO -. "bind mount .:/workspace:ro" .-> VN REPO -. "bind mount .:/workspace:ro" .-> MN

Repository layout
#

.
├── docker-compose.yml          # both services, shared namespaces
├── setup_host.sh               # xhost + device checks, once per login
├── vision_tracker/
│   ├── Dockerfile              # ros:jazzy + mediapipe==0.10.14
│   ├── .dockerignore           # src/ is mounted, so keep it out of the build context
│   └── src/vision_tracker_node.py
├── mujoco_twin/
│   ├── Dockerfile              # ros:jazzy + mujoco==3.13.0
│   ├── .dockerignore           # src/ and model/ are mounted
│   ├── src/mujoco_twin_node.py
│   └── model/                  # scene.xml → robot.xml (+ tendons.xml), assets/, config.json
├── standalone/main.py          # the same pipeline, one process
└── docs/

Each service folder is its own build context: editing the vision Dockerfile never invalidates the twin’s image cache, and neither build uploads the 13 MB of meshes it doesn’t need.

Images hold dependencies
#

Both Dockerfiles follow one pattern:

FROM ros:jazzy

# OpenGL + GLFW/X11 libs for the MuJoCo passive viewer
RUN apt-get update && apt-get install -y --no-install-recommends \
    python3-pip python3-venv libgl1 libegl1 libglfw3 libx11-6 libxcursor1 \
    libxi6 libxinerama1 libxrandr2 libxkbcommon0 mesa-utils \
    && rm -rf /var/lib/apt/lists/*

RUN python3 -m venv --system-site-packages /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir "mujoco==3.13.0"

WORKDIR /root
ENTRYPOINT ["/bin/bash", "-c", "\
  source /opt/ros/jazzy/setup.bash && \
  exec python3 /workspace/mujoco_twin/src/mujoco_twin_node.py"]
FROM ros:jazzy

# runtime deps of the OpenCV wheel MediaPipe pulls in (cv2.imshow over X11)
RUN apt-get update && apt-get install -y --no-install-recommends \
    python3-pip python3-venv libgl1 libglib2.0-0 libsm6 libxext6 libxrender1 \
    libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 \
    libxcb-render-util0 libxcb-shape0 libxcb-xinerama0 libxcb-xkb1 v4l-utils \
    && rm -rf /var/lib/apt/lists/*

RUN python3 -m venv --system-site-packages /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir "mediapipe==0.10.14"

WORKDIR /root
ENTRYPOINT ["/bin/bash", "-c", "\
  source /opt/ros/jazzy/setup.bash && \
  exec python3 /workspace/vision_tracker/src/vision_tracker_node.py"]
Decision Why
FROM ros:jazzy Ubuntu 24.04, Python 3.12, rclpy, Fast DDS — no host ROS install
venv with --system-site-packages Ubuntu 24.04 blocks system pip install (PEP 668); the venv takes pip packages while apt-installed rclpy dependencies stay importable
mediapipe==0.10.14 later releases removed the mp.solutions API
mujoco==3.13.0 the version the standalone pipeline was validated with
source setup.bash then exec python3 ROS environment in place; exec replaces the shell so docker stop’s SIGTERM reaches the node, not a wrapper bash
WORKDIR /root the mount is read-only, and MuJoCo writes MUJOCO_LOG.TXT to the working directory
No COPY of source the code is mounted

The code is mounted
#

volumes:
  - .:/workspace:ro
  • Edit → restart, no rebuilddocker compose restart <service> takes seconds.
  • One source of truth — the model simulated is the file in your checkout.
  • A relative path — the repo works from any clone location.
  • Read-only — a container can’t modify your checkout.

Rebuild only when a Dockerfile or a pinned dependency changes.

The shared anchor
#

x-ros-common: &ros-common
  network_mode: host
  ipc: host
  pid: host
  environment: &ros-env
    ROS_DOMAIN_ID: 42
    ROS_AUTOMATIC_DISCOVERY_RANGE: SUBNET
    PYTHONUNBUFFERED: 1
    DISPLAY: ${DISPLAY:-:0}
    QT_X11_NO_MITSHM: 1
  volumes:
    - .:/workspace:ro
    - /tmp/.X11-unix:/tmp/.X11-unix:rw
  privileged: true

services:
  vision_tracker:
    <<: *ros-common
    build: { context: ./vision_tracker }
    environment:
      <<: *ros-env
      CAMERA_INDEX: 0
      VISION_RATE_HZ: 30
    devices: [/dev/video0:/dev/video0, /dev/dri:/dev/dri]
  mujoco_twin:
    <<: *ros-common
    build: { context: ./mujoco_twin }
    environment:
      <<: *ros-env
      MUJOCO_GL: glfw
      MUJOCO_SCENE: /workspace/mujoco_twin/model/scene.xml
    devices: [/dev/dri:/dev/dri]
The nested merge matters. A service’s own environment: replaces the anchor’s environment instead of extending it — unless it merges <<: *ros-env too. Forget it and the service silently loses ROS_DOMAIN_ID and QT_X11_NO_MITSHM.
Setting Purpose Deep dive
network_mode: host DDS discovery between containers and across the LAN Part 17
ipc: host, pid: host Fast DDS shared-memory transport Part 17
ROS_DOMAIN_ID, discovery range which ROS graph, how far discovery reaches Part 17
PYTHONUNBUFFERED log lines appear in docker compose logs immediately
DISPLAY, X11 socket, QT_X11_NO_MITSHM, /dev/dri windows and GPU rendering Part 18
devices: /dev/video0 the webcam Part 18
privileged: true development convenience — with a real security cost Part 18

Everyday commands
#

./setup_host.sh                            # once per login
docker compose up --build                  # build (first time ~5 min) and start both
docker compose up -d                       # detached
docker compose logs -f vision_tracker      # follow one service
docker compose restart mujoco_twin         # pick up Python/XML edits
docker compose up mujoco_twin              # twin only; drive it with ros2 topic pub
docker compose exec mujoco_twin bash       # a shell inside; then source /opt/ros/jazzy/setup.bash
docker compose down                        # stop and free the camera

A healthy start, captured from real runs:

$ docker compose up --build
 Image ros2-tendon-driven-hand-mujoco-digital-twin-vision-teleoperation-mujoco_twin Built
 Image ros2-tendon-driven-hand-mujoco-digital-twin-vision-teleoperation-vision_tracker Built
 Container mujoco_twin Started
 Container vision_tracker Started
mujoco_twin     | [INFO] [1789562507.994533012] [mujoco_twin_node]: MuJoCo twin listening on /hand/target_flexions

$ docker compose exec mujoco_twin bash -c 'source /opt/ros/jazzy/setup.bash && ros2 topic hz /hand/target_flexions'
average rate: 10.863
        min: 0.081s max: 0.112s std dev: 0.00862s window: 30
Mulham Fetna
Author
Mulham Fetna
Renaissance Engineer
ROS 2 Tendon-Driven Hand MuJoCo Twin - This article is part of a series.
Part 16: This Article

Related

Containerizing ROS 2 without losing the hardware

Web developers containerize to isolate processes. Roboticists containerize and then spend the rest of the day punching holes back through the isolation — for the camera, the GPU, the display server and the network. ROS 2 Jazzy is hard-locked to Ubuntu 24.04. That single fact is reason enough to containerize: without it, adopting a ROS distribution means adopting an operating system version, on every machine that will ever run the code. But a ROS container is a strange artifact. A web service container wants isolation — that is the product. A robotics container needs a webcam, a GPU, a display server and multicast networking, all of which live outside it. You end up building a box and then carefully cutting four holes in it. Here is every hole in this project’s compose file and what it is for. Cold start to live tracking. Note hand_msgs being compiled separately inside two different containers — that duplication is deliberate and load-bearing. flowchart TB subgraph HOST["🖥️ Host — Ubuntu / Kubuntu"] X11["X11 socket /tmp/.X11-unix"] CAM["/dev/video0"] GPU["/dev/dri"] NET["host network UDP multicast"] end subgraph C["Active containers · ROS_DOMAIN_ID=42"] HT["hand_tracker"] RV["ros_rviz"] TS["topic_sniffer"] end GZ["gazebo_sim (commented out)"]:::parked classDef parked stroke-dasharray: 5 5,opacity:0.55 CAM --> HT GPU --> HT GPU --> RV X11 --> HT X11 --> RV NET <--> HT NET <--> RV NET <--> TS Hole 1 — the network wall has to come down # network_mode: "host" ipc: host pid: host This is the one that breaks stacks silently, so it is worth understanding rather than copying.

ROS 2 across Docker containers: shared network, discovery and Fast DDS shared memory

“The topic shows up in ros2 topic list, but echo prints nothing” — the classic ROS-in-Docker symptom. Here’s why it happens, how two containers in this project share a network and a block of memory instead, and what that costs. How ROS 2 nodes find each other # ROS 2 has no master. Nodes discover each other through DDS — in Jazzy, eProsima Fast DDS by default — using the SPDP protocol: Each participant announces itself over UDP multicast (239.255.0.1) on ports derived from the domain ID \(d\): discovery multicast on \(7400 + 250d\), unicast on \(7410 + 250d + 2p\) for participant \(p\). For domain 42: UDP 17900 and 17910+. Peers exchange their topic endpoints; matching publishers and subscribers connect. Data flows over the best transport both support — shared memory when they share a host and /dev/shm, UDP otherwise. Why Docker’s default network breaks it # Compose attaches services to a bridge network — a private NATed subnet. Multicast isn’t reliably routed across it, and machines on your LAN can’t reach container IPs at all.

Run the tendon-driven hand digital twin in 10 minutes

Clone, run one setup script, run one Compose command — and a webcam window and a MuJoCo viewer open side by side, with a simulated hand that closes when you close yours. Here are four ways in, from the full stack down to the bare model. The goal of this article: both windows open, and the twin follows your hand. What you need # Requirement Check with Notes Linux desktop, X11 or XWayland echo $DISPLAY → :0 Wayland sessions work through XWayland Docker Engine + Compose v2 docker compose version Built with Docker 29.8 / Compose 5.5 A webcam ls /dev/video0 Close any other app using it GPU device nodes ls /dev/dri Intel/AMD out of the box; NVIDIA needs the container toolkit (Part 18) ~5 GB of disk images: vision 2.9 GB, twin 1.7 GB No ROS installation is needed on the host — ROS 2 Jazzy lives inside the containers. Four ways to run it # Docker Compose (full stack) Single Python script Twin only, no camera Model viewer git clone https://github.com/mulhamfetna/ros2-tendon-driven-hand-mujoco-digital-twin-vision-teleoperation.git cd ros2-tendon-driven-hand-mujoco-digital-twin-vision-teleoperation ./setup_host.sh # X11 access for the containers + camera/GPU checks (once per login) docker compose up --build # builds both images, starts vision_tracker and mujoco_twin Stop with Ctrl+C, then docker compose down to release the camera.

Troubleshooting and FAQ: MuJoCo, MediaPipe and ROS 2 in Docker

Every entry here was hit, or deliberately checked, while building this project. Error messages are quoted exactly so a search for the message lands on the fix. Camera and windows # RuntimeError: Failed to open camera at index 0 # Something else holds the webcam — the standalone script, a previous container, a browser tab. Run docker compose down and close video apps. A camera opens in one process at a time. The camera is another node. ls /dev/video*, then map that device and set CAMERA_INDEX (many webcams expose /dev/video0 for frames and /dev/video1 for metadata — use the first). The device isn’t mapped. Check with docker compose config | grep video. Windows don’t open — cannot connect to X server, could not connect to display # Run ./setup_host.sh (it runs xhost +local:root). The permission resets when you log out. Make sure echo $DISPLAY on the host prints :0, or export DISPLAY before docker compose up. On Wayland, confirm XWayland is running: ls /tmp/.X11-unix/ should list X0. Black, blank or garbled OpenCV window # QT_X11_NO_MITSHM=1 must reach the container. It’s in the shared Compose environment — if a service defines its own environment:, it must merge the shared anchor with <<: *ros-env rather than replace it (Part 16).

From tutorial project to production robot: a roadmap for the tendon hand twin

The system works, and it teaches well. Getting it to drive real servos safely is a sequence of well-scoped upgrades — each one grounded in a limitation measured earlier in this series. Where it stands # Area Today Production target Hand tracking image-normalized landmarks, one global calibration metric world landmarks, per-finger calibration, a temporal filter Command mapping flexion → ±50 N; the twin is a switch flexion → tendon length; proportional curl Physics model force motors, decorative horns, no self-contact position servos on horns, tuned stiffness, contacts for grasping Middleware one topic, default QoS, open on the LAN parameters, explicit QoS, a watchdog, SROS 2 Containers privileged, host namespaces, root, xhost least privilege, non-root, optional headless Code classes copied into three files, no tests one shared package, tests, CI Hardware simulation only a servo driver on the same topic Stage 1 · Correctness Measure the right thing, command the right quantity World landmarks. Image-normalized coordinates bend angles by up to 16° with hand orientation (Part 4). Read multi_hand_world_landmarks instead, then recalibrate.