ROS 2 Nodes Can’t See Each Other? ROS_AUTOMATIC_DISCOVERY_RANGE

한국어로 읽기

What happened when I ran all 36 combinations

I started two rclpy nodes, A and B, inside one ROS 2 Jazzy container and tried every pairing of settings from the official “same host” discovery table: ROS_AUTOMATIC_DISCOVERY_RANGE set to OFF, LOCALHOST or SUBNET, each with and without ROS_STATIC_PEERS=127.0.0.1. That’s 6 settings for A × 6 for B = 36 cells. Each cell got its own ROS_DOMAIN_ID (20 + cell index) so cells couldn’t see each other. In every cell, each node published a String every 0.1 s and watched for 4 s. A cell counted as working only if both nodes saw the other in the ROS graph and both received at least one message.

16 of the 36 cells worked, in all 5 runs. Every cell where either node was OFF failed, and every other cell worked. Setting a static peer on an OFF node changed nothing. rcl threw the peer away, and it didn’t print the warning I expected.

Environment: image ros:jazzy-ros-base (ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca), Python 3.12.3, rclpy 7.1.12-1noble.20260902.053513, RMW rmw_fastrtps_cpp (the default). The container ran with --network none (loopback only), 1 CPU and 1 GiB. The same script ran 5 times. All 5 runs exited with code 0 and took between 33.22 s and 33.39 s each.

Check Prediction written before the run (plan.md) Measured (5 runs)
Working cells out of 36 16 16 in every run
Working cells per row (no peer: OFF, LOCALHOST, SUBNET; with peer: OFF, LOCALHOST, SUBNET) 0, 4, 4, 0, 4, 4 0, 4, 4, 0, 4, 4 in every run
Cells that disagree with “works unless either side is OFF” 0 0 in every run
Pairs whose result changes when A and B swap settings 0 0 in every run
Cells where only one side saw or received the other no prediction 0 in every run
Static peers rcl actually applied in the 12 OFF + peer contexts 0 (dropped) 0 in all 12 contexts, every run
ROS_STATIC_PEERS will be ignored warnings in those 12 contexts 12 0 in every run
Static peers applied in the 24 LOCALHOST/SUBNET + peer contexts no prediction 1 in all 24 contexts, every run
A set to lowercase subnet, B set to SUBNET Invalid value warning, treated as LOCALHOST, still works on the same host LOCALHOST with 0 peers, 1 warning, both sides discovered and received, every run
A has ROS_LOCALHOST_ONLY=1 + SUBNET + peer, B set to SUBNET warning, forced to LOCALHOST, 0 peers, still works LOCALHOST with 0 peers, 1 warning, both sides discovered and received, every run

In the working cells, both nodes saw each other and got their first message within 0.42 s of creating their nodes. The effective range and peer count come from rcl’s own DEBUG lines (Automatic discovery range is ... and Static peers count is N), not from what I set.

Raw output from run 1:

{"A": "-L", "B": "-L", "ae": "L0", "be": "L0", "as": 0.31, "bs": 0.31, "ar": 0.31, "br": 0.31, "an": 18, "bn": 11, "ok": 1, "pred": 1}
{"A": "-L", "B": "pO", "ae": "L0", "be": "O0", "as": null, "bs": null, "ar": null, "br": null, "an": 0, "bn": 0, "ok": 0, "pred": 0}
{"A": "pO", "B": "pS", "ae": "O0", "be": "S1", "as": null, "bs": null, "ar": null, "br": null, "an": 0, "bn": 0, "ok": 0, "pred": 0}
{"trap": "lowercase_subnet", "ae": "LOCALHOST/0", "invalid_warn": 1, "localhost_only_warn": 0, "as": 0.3, "bs": 0.2, "ar": 0.3, "br": 0.3, "code": [0, 0]}
{"trap": "localhost_only_1", "ae": "LOCALHOST/0", "invalid_warn": 0, "localhost_only_warn": 1, "as": 0.3, "bs": 0.2, "ar": 0.3, "br": 0.2, "code": [0, 0]}
{"summary": "same_host_table", "cells": 36, "o_cells": 16, "o_by_row": [0, 4, 4, 0, 4, 4], "seen_by_row": [0, 4, 4, 0, 4, 4], "rule_mismatch": [], "asymmetric_cells": 0, "one_sided_cells": [], "off_peer_warnings": 0, "off_peer_contexts": 12, "off_peer_contexts_peers_dropped": 12, "peer_contexts_not_off_peer_kept": 24, "effective_range_mismatch": 0, "effective_missing": 0, "log_level_errors": 0, "first_seen_s_min_max": [0.0, 0.41], "first_rx_s_min_max": [0.0, 0.42], "worker_setup_s_max": 0.99, "worker_threads_max": 84, "worker_failures": 0, "window_s": 4.0, "sweep_s": 22.65, "total_s": 32.62, "rmw": "rmw_fastrtps_cpp(default)"}

How to read those lines: in A/B, a leading - means no static peer and p means one was set. The second letter is O, L or S for OFF, LOCALHOST or SUBNET. ae/be show what rcl actually applied: the first letter of the range, then the number of static peers. as/ar are the seconds until A first saw B and first received a message from it, and an is how many messages A received. Look at the third line. Node A was given ROS_STATIC_PEERS=127.0.0.1, but rcl reports O0: OFF with zero peers. Nothing in the output says the peer was dropped.

So on one machine, the official same-host table held cell for cell. The only prediction that missed was the warning. In Jazzy’s rcl/init.c, when the range is OFF rcl never reads ROS_STATIC_PEERS at all. The “will be ignored” warning only fires when peers were passed through init options in code. If you set peers through environment variables, they disappear without a word. The lowercase subnet result is the other quiet trap. On one machine it works, so you won’t notice that rcl turned it into LOCALHOST until you split the nodes across machines or containers.

This run doesn’t tell you how two separate hosts or two separate containers behave. I come back to that below.

First: can you not see the node, or can you see it but get no data?

“ROS 2 nodes can’t communicate across Docker” covers two different failures. Work out which one you have before you touch any discovery settings.

  • The other node doesn’t show up in ros2 node list or ros2 topic list --no-daemon. That’s a discovery failure, and the two environment variables in this article are the first thing to check.
  • The node and topic show up, but ros2 topic echo stays silent. Discovery already worked. The problem is data delivery: usually Fast DDS shared memory, sometimes QoS. The discovery table can’t help you here.

The well-known Robotics Stack Exchange question “ROS2 Foxy nodes can’t communicate through Docker container border” is the second kind. The asker could see the container’s /minimal_subscriber from the host with ros2 node list, but no messages arrived. The accepted answer blamed Fast DDS shared-memory transport. With --net host, Fast DDS decides both sides are on the same machine and tries shared memory, and that fails when /dev/shm isn’t shared. eProsima’s Fast DDS 2.14 Docker guide says the same thing: with --network=host you also need --ipc=host. Fast-DDS issue #5396 shows the same symptom on Humble, there caused by /dev/shm permissions (root on one side, a normal user on the other). The fixes there are running both sides as the same user or forcing FASTDDS_BUILTIN_TRANSPORTS=UDPv4.

Also note that the question was on Foxy. The two variables below don’t exist on Foxy or Humble.

What ROS_AUTOMATIC_DISCOVERY_RANGE and ROS_STATIC_PEERS do

Both variables arrived in ROS 2 Iron, where they replaced ROS_LOCALHOST_ONLY (Iron release notes). They work on Iron, Jazzy, Kilted and Rolling. Humble’s rcl has no code for them, so setting them there does nothing. They’re also not supported by rmw_zenoh (official docs).

  • ROS_AUTOMATIC_DISCOVERY_RANGE sets how far a node announces itself.
    • SUBNET (default): multicast, so any node the multicast reaches.
    • LOCALHOST: only nodes on the same machine.
    • OFF: nobody, not even on the same machine.
    • SYSTEM_DEFAULT: leaves the middleware’s own settings alone.
  • ROS_STATIC_PEERS is a semicolon-separated list of addresses. A node can connect to nodes at those addresses “as long as their discovery range is not OFF”.

Here is what Jazzy’s rmw_fastrtps participant setup does with each value:

  • OFF caps the participant count at one (itself) and removes the multicast listener. Nothing can be found.
  • LOCALHOST removes the multicast listener but keeps a unicast one. It sends announcements only to 127.0.0.1 and to the static peers.
  • SUBNET keeps multicast. If static peers are set too, it adds them and still keeps the multicast address 239.255.0.1.
  • For LOCALHOST and SUBNET, each static peer is resolved by DNS. If a name doesn’t resolve, participant creation fails with Unable to resolve peer.

Put simply: SUBNET broadcasts to the neighbourhood, LOCALHOST stops broadcasting and only calls its own machine and the numbers on its list, and OFF switches the phone off. A LOCALHOST node with no peers can’t tell any other machine it exists. It still answers incoming unicast, though, so if the other side has this node’s address on its list and calls first, the two can connect. That’s my reading of why one static peer on either side is enough in the official table. It matches the table, but I didn’t check it at the packet level.

The official tables, reduced to two rules

The “Using Improved Dynamic Discovery” page has two 6 × 6 tables, one for nodes on the same host and one for nodes on different hosts. That’s 72 cells. Counting them, I got 16 working cells on the same host and 13 on different hosts. Both tables are symmetric, so the 36 cells are really 21 distinct pairings: 10 work on the same host and 8 on different hosts. These two rules match all 72 cells:

  • Same host: it works if neither node is OFF.
  • Different hosts: it works if neither node is OFF and either at least one side lists the other in ROS_STATIC_PEERS or both are SUBNET.

These rules are my summary. The documentation doesn’t state them. Here is the different-host table rebuilt from the documentation (not measured; ✓ = discover and communicate). Rows are node A, columns are node B. “+peer” means that node’s ROS_STATIC_PEERS points at the other host.

A \ B OFF LOCALHOST SUBNET OFF +peer LOCALHOST +peer SUBNET +peer
OFF ✗ ✗ ✗ ✗ ✗ ✗
LOCALHOST ✗ ✗ ✗ ✗ ✓ ✓
SUBNET ✗ ✗ ✓ ✗ ✓ ✓
OFF +peer ✗ ✗ ✗ ✗ ✗ ✗
LOCALHOST +peer ✗ ✓ ✓ ✗ ✓ ✓
SUBNET +peer ✗ ✓ ✓ ✗ ✓ ✓

Moving the nodes from one host to two turns 3 cells from working to broken, and nothing goes the other way. Those 3 cells are really two situations, counted in both A/B orders:

  1. Both nodes are LOCALHOST and neither has a static peer.
  2. One is LOCALHOST, the other SUBNET, and neither has a static peer.

This is the classic “works on my laptop, breaks when I split it” case. A LOCALHOST node with no peers only ever reached its own loopback.

One assumption sits under every “+peer ✓” cell: the address must actually reach the other node. The table doesn’t spell this out.

Check your two nodes in this order

Run this in each container or on each machine. Stop the daemon first. The ros2 daemon’s port depends only on ROS_DOMAIN_ID (source), so a daemon started under different discovery settings can be reused and show you a stale view.

env | grep -E '^(ROS_|RMW_)' | sort
ros2 daemon stop
ros2 topic list --no-daemon

Then go down this list. Once a step explains the problem, you can stop there.

  1. Is ROS_LOCALHOST_ONLY=1 still set? It’s often left over in an old .bashrc. Jazzy’s rcl lets it win: the range is forced to LOCALHOST and static peers are dropped (init.c). Unset it and use only the new variables.
  2. Is the value spelled exactly, in upper case? Anything other than OFF, LOCALHOST, SUBNET or SYSTEM_DEFAULT logs Invalid value and becomes LOCALHOST (discovery_options.c). subnet in lower case is LOCALHOST.
  3. Is either side OFF? An OFF node finds nobody, whatever is in its peer list. Jazzy’s ros2cli prints a warning for OFF with no peers that suggests setting ROS_STATIC_PEERS as one fix. Don’t follow that part. rcl, the official table and the run above all show that OFF ignores peers. Change the range to LOCALHOST or SUBNET instead.
  4. Is there a LOCALHOST node with no peers? That’s where the two different-host failures live. If you want to keep LOCALHOST, put the other machine’s address in ROS_STATIC_PEERS on at least one side. The official table says one side is enough. Setting it on both sides is a more cautious choice. If you don’t need the isolation, switch to SUBNET.
  5. Both sides are SUBNET and you still see nothing? Then these two variables aren’t the cause. Check that multicast actually crosses your network, the firewall, and that ROS_DOMAIN_ID and RMW_IMPLEMENTATION match on both sides.

For step 4, on node B’s machine (replace the address with the real address of node A’s machine):

export ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST
export ROS_STATIC_PEERS='192.168.1.20'
# several machines: export ROS_STATIC_PEERS='192.168.1.20;192.168.1.21'

Quote the list. Without quotes, the shell reads the semicolon as the end of the command. If you use host names, check that they resolve on both sides, because an unresolvable peer makes Fast DDS fail to create the participant.

If this is a real robot, changing these values can also hide your teleop node and emergency-stop topic from each other. Before you change anything, make sure a stop that doesn’t depend on ROS works, such as the hardware e-stop button. Change the settings with motor power off and check the node list before you drive.

What about two Docker containers?

The ROS documentation doesn’t say whether two containers count as “same host” or “different hosts”. Here is what’s documented and what’s only inferred.

  • Documented: Fast DDS decides whether it’s on the same host from network interfaces. Containers that don’t share a network stack are detected as different hosts and talk over UDP. With --network=host they’re the same host, and then they also need --ipc=host or shared-memory delivery fails (Fast DDS 2.14 docs). That statement is about choosing a transport, not about discovery range.
  • Inferred, not tested: each bridge-network container has its own 127.0.0.1. LOCALHOST only announces to loopback and static peers, so two bridge containers on LOCALHOST with no peers should behave like the different-host table and fail. Two --network host containers share loopback and should behave like the same-host table, but they can still fail later at the shared-memory step.

What this test doesn’t show

  • It covered only the same-host half of the documentation. The different-host table, container-to-container behaviour, and bridge vs --network host were not measured.
  • --network none has only loopback. SUBNET cells working there doesn’t mean multicast works on your LAN, because same-machine traffic can also go over shared memory or loopback.
  • The only static peer used was 127.0.0.1, so the table’s claim that one peer on one side is enough across hosts is still untested.
  • Each worker process opened several ROS contexts (one per cell). Resource use isn’t the same as a normal one-node-per-process setup. The first-seen times are counted from when each worker finished creating its nodes, so they can read 0 and aren’t discovery latency figures.
  • Only rmw_fastrtps_cpp was tested. Cyclone DDS and Connext weren’t checked, and rmw_zenoh doesn’t support these variables.
  • The tables and code were read on 2026-10-06, pinned to specific commits, and may change in later releases.

Reproduce it

The script, the plan with its pre-run predictions, and the raw output from all 5 runs are in the reproduction folder: experiment.py, plan.md, results.json. On Linux with Docker:

mkdir ros2-discovery-check && cd ros2-discovery-check
curl -fsSLO https://raw.githubusercontent.com/sungpyo9053/blog/main/experiments/ros2-docker-discovery-range-static-peers-combinations/experiment.py
sha256sum experiment.py   # expect 0b52dc6ca6d785833ec191935c963a72372c055c6f72e0e83dd281401ee610e3
docker run --rm --network none --cpus 1 --memory 1g -v "$PWD":/work \
  ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca \
  bash -c "source /opt/ros/jazzy/setup.bash && python3 /work/experiment.py"

You should get 36 cell lines, 2 trap lines and a summary line. If o_cells isn’t 16 or rule_mismatch isn’t empty, your setup doesn’t match the same-host table.

Sources