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

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

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 17: This Article
“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:

  1. 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+.
  2. Peers exchange their topic endpoints; matching publishers and subscribers connect.
  3. 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.

network_mode: host removes the network namespace: the containers use the host’s interfaces directly, and discovery behaves as if ROS were installed natively.

Option Discovery between containers LAN visibility Network isolation
Bridge (default) unreliable (multicast) good
Bridge + static peers / Discovery Server ✔ with config needs port mapping good
network_mode: host — this project none

Scope: domain ID and discovery range
#

ROS_DOMAIN_ID: 42
ROS_AUTOMATIC_DISCOVERY_RANGE: SUBNET
  • ROS_DOMAIN_ID=42 partitions the DDS space. Work on the default domain 0 stays separate; pick any free 0–101.
  • ROS_AUTOMATIC_DISCOVERY_RANGE (Jazzy and later):
Value Who can discover the nodes
LOCALHOST processes on this machine only
SUBNET — this project any machine on the local subnet with the same domain ID
OFF nobody automatically — use static peers
SYSTEM_DEFAULT the middleware’s own default

From another computer:

export ROS_DOMAIN_ID=42
ros2 topic echo /hand/target_flexions

Nothing arrives? Same subnet, host firewall open for UDP 17900–17930, and no Wi-Fi client isolation or multicast filtering. For networks that drop multicast, set static peers with ROS_STATIC_PEERS=<ip> (Jazzy+) or use a wired link.

Shared memory: the fast path between the containers
#

Fast DDS’s shared-memory transport writes a message once into a segment under /dev/shm, and the subscriber reads it in place — no UDP packets, no kernel network stack, no loopback copies. Across containers, that needs sharing:

Requirement Compose setting Without it
The same /dev/shm ipc: host each container gets a private 64 MB /dev/shm; segments are invisible to the peer and Fast DDS falls back to UDP
A shared process view pid: host precaution, not tested without it — Fast DDS tracks segment and port ownership with lock files to clean up after dead peers; sharing the PID namespace avoids cross-namespace ambiguity
Compatible users both run as root the subscriber can’t open the publisher’s segments

Evidence
#

During testing, a subscriber in mujoco_twin received a JointState published from vision_tracker. While it ran, the vision container listed Fast DDS segments — including those of the subscriber living in the other container:

$ ls /dev/shm | grep fast        # inside vision_tracker
fastrtps_5a080c43d95a7658
fastrtps_5a080c43d95a7658_el
fastrtps_837f37c7f519d02a
fastrtps_837f37c7f519d02a_el
fastrtps_port17913

fastrtps_port17913 follows the same RTPS port formula as UDP for domain 42: \(7400 + 250 \times 42 + 11 + 2 \times 1 = 17913\), the unicast user-data port of participant 1. The *_el files are segment locks. All of it is visible to both containers only because of ipc: host.

flowchart LR
    subgraph VT["🐳 vision_tracker"]
        P["publisher"]
    end
    subgraph MT["🐳 mujoco_twin"]
        S["subscriber"]
    end
    P -- "write once" --> SHM[("/dev/shm
fastrtps_* segments
ipc: host")] SHM -- "read in place" --> S P -. "discovery · UDP 17900+
network_mode: host" .- S

Does it matter for five floats?
#

Not much — UDP loopback would carry five floats at 30 Hz fine. Shared memory earns its keep once you publish images: streaming the annotated camera frame as sensor_msgs/Image is about 0.9 MB per 640×480 RGB frame, 30 times a second. The configuration is in place so that extension is free.

What it costs, and how to harden it
#

Choice Cost Production alternative
network_mode: host no network isolation dedicated VLAN; or bridge + Fast DDS Discovery Server / static peers
ipc: host containers can read any host SHM segment a shared named volume at /dev/shm for just these two services
pid: host containers see every host process as above, or accept UDP between containers
SUBNET discovery anyone on the LAN on domain 42 can read — and publish — hand commands LOCALHOST for demos; SROS 2 (DDS Security) for authenticated, encrypted topics
Anyone on your subnet with ROS_DOMAIN_ID=42 can publish to /hand/target_flexions and move the twin. Harmless for a simulator and handy for teaching. Before that topic drives real servos, enable SROS 2 or restrict discovery.
Mulham Fetna
Author
Mulham Fetna
Renaissance Engineer
ROS 2 Tendon-Driven Hand MuJoCo Twin - This article is part of a series.
Part 17: This Article

Related

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

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.

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

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.

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.