Discovery and RMW
There Is No roscore
Nodes find each other by multicast DDS discovery, which means any ROS 2 node on the same subnet joins the same graph by default. Two people debugging robots on one office network will see each other’s topics.
That is the whole design: no central process to start, nothing to be a single point of failure, and a node can be restarted without the graph noticing. The cost is that the graph’s membership is a property of the network rather than of a configuration file, so when it is wrong the answer is in network configuration rather than in ROS.
Discovery happens in two stages. Participants announce themselves on a multicast address (SPDP), then exchange their publisher and subscriber lists with each peer directly (SEDP). Discovery traffic grows with the square of the participant count, which is why a graph of 200 nodes behaves differently from one of 20, and why composition into fewer processes (../01_Core_Concepts/04_Executors_Lifecycle_and_Composition.ipynb) helps at scale.
ROS_DOMAIN_ID
ROS_DOMAIN_ID partitions the network. Set it to the same integer on every machine that should talk, and a different one elsewhere:
export ROS_DOMAIN_ID=42The default is 0, which is also everyone else’s default, so on a shared LAN stray nodes from other projects appear in ros2 topic list. Choosing a non-default domain per project is the cheapest isolation available.
The domain is not a label, it is arithmetic on port numbers. The RTPS specification derives ports from the domain:
discovery multicast port = 7400 + 250 * domain_id
user multicast port = 7400 + 250 * domain_id + 1
discovery unicast port = 7400 + 250 * domain_id + 10 + 2 * participant_id
So domain 0 listens around 7400 and domain 42 listens around 17900. Two consequences:
- A
tcpdumpfilter must match the domain.portrange 7400-7500captures domain 0 only; for domain 42 it isportrange 17900-18000. A filter copied from a tutorial explains a lot of “no packets are being sent” conclusions that were really no packets being captured. - Stay in 0-101 on Linux. Higher domains push the derived ports into the ephemeral range, where they collide with ordinary applications. The protocol allows up to 232; the practical ceiling is
Limiting Discovery Range
To stop discovery leaving the machine entirely:
export ROS_LOCALHOST_ONLY=1 # Humble and earlier
export ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST # Jazzy and laterROS_LOCALHOST_ONLY is deprecated from Iron onward and replaced by ROS_AUTOMATIC_DISCOVERY_RANGE, which takes four values:
| Value | Meaning |
|---|---|
OFF |
no automatic discovery at all; only ROS_STATIC_PEERS |
LOCALHOST |
this machine only |
SUBNET |
the local subnet (the default) |
SYSTEM_DEFAULT |
whatever the RMW’s own configuration says |
OFF combined with explicit ROS_STATIC_PEERS gives fully deterministic discovery, which is the right tool when you want to eliminate the network as a variable. See 01_Multi_Machine_and_Zenoh.ipynb.
Note that these settings are read by the RMW at participant creation, so they must be set before a node starts, and changing them in one shell does not affect nodes already running in another.
RMW Implementations
The DDS implementation is swappable (rmw_fastrtps_cpp is the default, rmw_cyclonedds_cpp the common alternative) via RMW_IMPLEMENTATION. Every node in a graph must use implementations that interoperate, so change it everywhere or nowhere.
sudo apt install ros-jazzy-rmw-cyclonedds-cpp
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
ros2 doctor --report | grep -A2 "RMW MIDDLEWARE"| Implementation | Notes |
|---|---|
rmw_fastrtps_cpp |
the Jazzy default (eProsima Fast DDS). Has the discovery server; XML profiles for tuning |
rmw_cyclonedds_cpp |
Eclipse Cyclone DDS. Smaller, and its interface selection is easier to control, which matters on a multi-homed machine |
rmw_zenoh_cpp |
not DDS at all; router-based, designed for lossy links and WAN. See 01_Multi_Machine_and_Zenoh.ipynb |
rmw_connextdds |
RTI Connext, commercial, certifiable |
A mixed graph is the failure to watch for: different vendors are interoperable in principle, through the RTPS wire protocol, and in practice mixed graphs produce nodes visible to some peers and not others. Standardise per project.
One sharp edge worth knowing: if RMW_IMPLEMENTATION names a package that is not installed, every process fails immediately. In Python that surfaces as an rclpy process exiting with status 1 and very little explanation, which under a test runner looks like the test framework’s fault. This has been hit in practice on this site’s own hardware, after a distribution upgrade replaced the installed RMW packages; see the piros2 project notes.
Interface Pinning on a Multi-Homed Machine
This is the problem most likely to cost an evening, and it appears on any development machine that runs Docker, a VPN, or both.
A typical dev box has several interfaces that are not the LAN:
docker0 172.17.0.1/16
br-808fe3a7a7d5 172.18.0.1/16
tailscale0 (no IPv4)
wg0 10.8.0.3/32 # WireGuard
enp6s18 192.168.2.109/24 # the only one that reaches the robot
By default the DDS implementation enumerates every interface and picks one, often a bridge or the VPN rather than the LAN. The node then advertises itself at an address the robot cannot route to. The symptoms are distinctive and none of them says “wrong interface”:
- discovery is slow or intermittent
ros2 topic listshows the topic butros2 topic echosits silent- it works in one direction only
VPN interfaces make this worse than Docker alone, because they are routable-looking but lead somewhere the robot is not. Pin the interface explicitly rather than relying on enumeration order.
Cyclone DDS, which is the easier one to control:
<?xml version="1.0" encoding="UTF-8" ?>
<CycloneDDS xmlns="https://cdds.io/config">
<Domain id="any">
<General>
<Interfaces>
<NetworkInterface name="enp6s18" priority="default" multicast="default" />
</Interfaces>
<AllowMulticast>true</AllowMulticast>
</General>
</Domain>
</CycloneDDS>export CYCLONEDDS_URI=file:///home/me/.config/cyclonedds/cyclonedds.xmlFast DDS uses its own XML profile and FASTRTPS_DEFAULT_PROFILES_FILE, with a whitelist of addresses inside a UDP transport descriptor.
Two hard-won notes on where that file lives and what breaks it:
- Pin by interface name and know that names change. A Proxmox guest’s interface renamed itself from
ens18toenp6s18across an upgrade (same IP, new predictable-name scheme) and broke Cyclone’s pin, because the configured name no longer existed. The failure is silent: DDS falls back to choosing for itself. - Keep the config outside any directory that is synced or deleted. A config templated into a workspace that is synced with
rsync --deletevanishes on the next sync, after which DDS binds whatever interface it likes with no error anywhere.
Generating the file per host from one template, with the interface as the only variable, is what keeps two machines’ configs from drifting in structure while differing in the one field that should differ.
The Discovery Server
For larger graphs, Fast DDS offers a discovery server: a known process that participants register with, replacing multicast discovery with unicast to a rendezvous point.
# the server
fastdds discovery --server-id 0 --ip-address 192.168.2.109 --port 11811
# every client
export ROS_DISCOVERY_SERVER=192.168.2.109:11811
ros2 daemon stop && ros2 daemon startWorth it when: multicast is blocked or rate-limited, the graph is large enough that n-squared discovery traffic is measurable, or discovery must cross a subnet boundary.
The cost is a process that has to be running before anything else, and which becomes the thing that was forgotten when nothing can see anything. It can be made redundant (several servers, clients listing all of them), which is worth doing before depending on it.
Diagnosing
# what can this machine actually see
ros2 node list
ros2 topic list
# the full environment and middleware report
ros2 doctor --report
# is anything publishing, and how fast
ros2 topic hz /scan
ros2 topic info /scan --verbose # endpoints and their QoS
# are DDS packets crossing the wire at all (domain 42 -> 17900)
sudo tcpdump -i enp6s18 -n 'udp and portrange 17900-18000'
# what the environment is on a remote machine
ssh robot -t 'bash -lc "env | grep -E \"^ROS_|^RMW_|^CYCLONEDDS\""'Restart the daemon after changing any ROS_* or DDS environment variable:
ros2 daemon stop && ros2 daemon startThe ros2 CLI talks to a background daemon that caches discovery state, and it will keep reporting the old view otherwise. This alone explains a large share of “I fixed it and nothing changed”.
Which layer owns the symptom:
| Symptom | Layer |
|---|---|
node not in ros2 node list on either machine |
the node, or the RMW is missing |
| node visible locally, not remotely | discovery: domain, discovery range, interface, or multicast |
visible remotely, echo silent |
either a wrong advertised address, or a QoS mismatch |
| both endpoints listed with profiles, no data | QoS: see ../01_Core_Concepts/03_QoS_Profiles.ipynb |
| data arrives, callback does not run | the executor: see ../01_Core_Concepts/04_Executors_Lifecycle_and_Composition.ipynb |
| works after a long delay | discovery succeeded eventually; suspect multicast being rate-limited |
The distinction in the middle two rows is the one worth internalising, because it separates a network problem from a QoS problem, and ros2 topic info --verbose is what tells them apart.