Docker 컨테이너 경계를 넘으면 ROS 2 노드가 서로를 못 찾을 때: ROS_AUTOMATIC_DISCOVERY_RANGE와 ROS_STATIC_PEERS 36가지 조합 중 다른 호스트에서도 통하는 칸 세어 보기
같은 PC에서는 talker와 listener가 잘 주고받던 ROS 2 노드를 Docker 컨테이너나 다른 PC로 나누자 ros2 topic list에서 상대 토픽이 사라지는 일이 있다. Docker 컨테이너 경계를 넘으면 ROS 2 노드가 서로를 못 찾을 때, Iron 이후 배포판에서 먼저 열어 볼 설정은 ROS_AUTOMATIC_DISCOVERY_RANGE와 ROS_STATIC_PEERS 두 환경변수다. ROS 2 공식 문서는 두 노드의 설정 조합을 ‘같은 호스트’ 표와 ‘다른 호스트’ 표에 36칸씩 O/X로 적어 두었다. 두 표를 칸 단위로 세면 같은 호스트에서 통하는 16칸 가운데 다른 호스트에서도 통하는 칸은 13칸이고, 빠지는 3칸에는 모두 정적 피어 없는 LOCALHOST가 끼어 있다.
이 글이 직접 실행해 확인한 것은 두 표 중 같은 호스트 쪽이다. ros:jazzy-ros-base 이미지로 띄운 컨테이너 하나 안에서 rclpy 노드 두 개로 36칸을 5번 재현했고, 다른 호스트 표와 컨테이너 두 개 사이의 동작은 공식 문서와 rcl·rmw_fastrtps 소스 코드로만 판정했다. 출발점으로 삼은 Robotics Stack Exchange 질문 “ROS2-foxy nodes can’t communicate through docker container border”도 이 표로 풀리는 사례가 아니었다. 질문자의 호스트에서는 컨테이너 쪽 노드가 ros2 node list에 보였고 메시지만 오지 않았으며, 두 환경변수는 질문자가 쓴 Foxy에는 아예 없다. 그래서 표를 꺼내기 전에 증상부터 둘로 가른다.
노드와 토픽, export로 환경변수를 바꾸는 법, ros2 topic list와 docker run을 써 본 정도면 따라올 수 있다. 공식 문서와 코드는 2026년 10월 6일에 확인했다.
상대가 안 보이는가, 보이는데 메시지가 안 오는가
ROS 2의 발견(discovery)은 노드가 같은 도메인의 다른 노드를 찾아 서로의 정보를 주고받으며 연결을 준비하는 과정이다. 노드는 시작할 때와 그 뒤 주기적으로 자기 존재를 알리고, 이 알림이 상대에게 닿아야 상대의 노드 이름과 토픽이 목록에 나타난다. 발견이 끝났다고 데이터가 오는 것은 아니다. QoS(Quality of Service, 신뢰성·보관 개수 같은 전달 조건)가 서로 맞지 않거나 실제 전송 경로가 막히면 목록에는 보여도 메시지는 오지 않는다. 발견 과정의 공식 설명도 QoS가 호환돼야 연결된다고 적는다.
그래서 증상은 두 갈래로 나뉜다.
- 상대 노드가
ros2 topic list --no-daemon에 아예 없다. 발견 단계에서 막혔다. 이 글의 표가 답하는 갈래다. - 목록에는 있는데
ros2 topic echo가 조용하다. 발견은 끝났다. 원래 질문이 이쪽이다. 채택 답변은 Fast DDS가 두 쪽을 같은 기계로 판단해 공유 메모리(SHM, shared memory: 같은 기계의 프로세스끼리 메모리 영역을 함께 써서 데이터를 넘기는 전송)를 쓰려다 실패한 것을 원인으로 들었다. Fast DDS 2.14 문서도--network=host컨테이너는 같은 호스트로 인식되므로--ipc=host가 없으면 공유 메모리 공간이 달라 통신할 수 없다고 설명한다. 다른 컨테이너의 토픽이 목록에는 보이는데 echo가 안 된다는 Fast DDS 이슈 #5396도 같은 갈래로, 메인테이너는/dev/shm권한 문제를 짚었다.
목록을 볼 때 --no-daemon을 붙이는 이유가 있다. ros2 데몬(daemon, 발견 결과를 모아 두는 백그라운드 프로세스)의 포트는 11511 + ROS_DOMAIN_ID로만 정해진다. 발견 범위 값은 포트 계산에 들어가지 않으므로, 설정을 바꿔 가며 확인할 때 이전 설정으로 떠 있던 데몬이 그대로 재사용될 수 있다(코드에서 끌어낸 추론이다). --no-daemon을 쓰거나 ros2 daemon stop 뒤에 다시 보면 이 혼동을 피할 수 있다.
두 환경변수가 정하는 것: 알림을 어디까지 보내는가
ROS 2 노드 사이의 통신은 DDS(Data Distribution Service, 노드가 서로를 찾고 데이터를 주고받는 통신 표준) 구현이 맡고, 그 사이를 RMW(ROS Middleware, ROS와 통신 구현을 잇는 층)가 연결한다. Jazzy의 기본 RMW는 Fast DDS를 쓰는 rmw_fastrtps_cpp다. 공식 문서 “Using Improved Dynamic Discovery”가 정의하는 값은 다음과 같다.
ROS_AUTOMATIC_DISCOVERY_RANGE(자동 발견 범위)SUBNET: 기본값. DDS 계열에서는 멀티캐스트(multicast, 한 번 보내면 같은 그룹이 모두 받는 전송)가 닿는 모든 노드를 찾는다.LOCALHOST: 같은 기계의 노드만 찾는다.OFF: 같은 기계에서도 찾지 않는다.SYSTEM_DEFAULT: 미들웨어 설정을 건드리지 않는다. 표에는 나오지 않는다.ROS_STATIC_PEERS(정적 피어): 세미콜론(;)으로 구분한 주소 목록이다. 문서 표현으로는 “상대의 발견 범위가 OFF가 아닌 한” 그 기계의 노드와 연결할 수 있게 해 준다.
두 변수는 Iron 릴리스에서 들어왔고, 그때 같은 기계로 제한하던 기존 ROS_LOCALHOST_ONLY는 deprecated(사용 중단 예정)가 됐다. Humble과 Foxy의 rcl에는 이 변수를 읽는 코드가 없으므로 이 글의 표는 Iron 이후 배포판에만 적용된다. 해당 문서 페이지도 Jazzy·Kilted·Rolling 판에만 있다. 문서는 또 rmw_zenoh가 두 변수를 지원하지 않는다고 밝힌다.
비유로 옮기면 SUBNET은 동네 방송을 하고, LOCALHOST는 방송을 끈 채 집 안과 명단에 있는 번호에만 전화를 건다. OFF는 전화기를 꺼 둔다. 정적 피어는 그 명단이다. 이 비유는 두 군데서 어긋나는데, 뒤에서 코드로 다시 짚는다.
72칸을 표 하나와 두 줄 규칙으로 줄이기
공식 문서의 표 하나는 노드 A 설정 6행 × 노드 B 설정 6열이다. 각 설정은 정적 피어 없음/있음 × OFF/LOCALHOST/SUBNET이고, O는 “서로 발견하고 통신한다”, X는 “발견하지도 통신하지도 않는다”는 뜻이다. 아래는 문서 원문의 두 표를 한 장으로 겹친 것이다. ‘O’는 두 표 모두 O, ‘같은 호스트만’은 같은 호스트 표에서만 O인 칸이다. 여기서 ‘피어 있음’은 상대 기계의 주소를 ROS_STATIC_PEERS에 넣었다는 뜻이다.
| A \ B | 피어 없음 OFF | 피어 없음 LOCALHOST | 피어 없음 SUBNET | 피어 있음 OFF | 피어 있음 LOCALHOST | 피어 있음 SUBNET |
|---|---|---|---|---|---|---|
| 피어 없음 OFF | X | X | X | X | X | X |
| 피어 없음 LOCALHOST | X | 같은 호스트만 | 같은 호스트만 | X | O | O |
| 피어 없음 SUBNET | X | 같은 호스트만 | O | X | O | O |
| 피어 있음 OFF | X | X | X | X | X | X |
| 피어 있음 LOCALHOST | X | O | O | X | O | O |
| 피어 있음 SUBNET | X | O | O | X | O | O |
행별 O 개수는 같은 호스트 표가 0, 4, 4, 0, 4, 4로 16칸, 다른 호스트 표가 0, 2, 3, 0, 4, 4로 13칸이다. 호스트를 나누면 3칸이 O에서 X로 바뀌고, X에서 O로 바뀌는 칸은 없다. 이 칸 수는 설정 조합의 개수일 뿐 현장에서 어떤 설정이 흔한지를 뜻하지 않는다.
72칸은 다음 두 줄로 모두 설명된다. 공식 문서에 있는 문장이 아니라 HuntLab이 표에서 끌어낸 요약이며, 72칸 전체와 하나씩 대조해 어긋나는 칸이 없음을 확인했다.
- 같은 호스트: 양쪽 모두 OFF가 아니면 O.
- 다른 호스트: 양쪽 모두 OFF가 아니고, 적어도 한쪽이 상대 주소를 정적 피어로 갖고 있거나 양쪽 모두 SUBNET이면 O.
표를 더 줄일 수도 있다. 두 표는 A와 B를 바꿔도 판정이 같은 대칭이라, 36칸은 순서가 있는 쌍이고 순서를 무시한 실제 조합은 21가지다. 그중 같은 호스트에서 통하는 조합은 10가지, 다른 호스트에서 통하는 조합은 8가지다. 제목의 ’36가지 조합’은 표의 칸 수이고, 사라지는 3칸도 서로 다른 세 상황이 아니다. 피어 없이 둘 다 LOCALHOST인 경우, 피어 없이 한쪽은 LOCALHOST이고 다른 쪽은 SUBNET인 경우라는 두 상황이 A·B 순서 때문에 3칸으로 보일 뿐이다.
칸 세기는 산술 검산 예제 example.py로 다시 셀 수 있다. 표의 O를 1, X를 0으로 바꿔 행별로 더하는 코드이고, 16, 13, 3을 포함한 8개 항목이 모두 표 재집계와 일치했다는 기록은 검산 결과 verification.json에 있다. 이것은 문서 표를 센 산술이지 네트워크나 로봇을 돌린 결과가 아니다.
코드에서 보면 LOCALHOST만 호스트 경계에서 갈리는 이유
환경변수는 rcl이 읽고, 실제 소켓 설정은 RMW가 한다. Jazzy의 rmw_fastrtps participant.cpp는 범위마다 Fast DDS를 다르게 구성한다. 참가자(participant)는 DDS에서 한 프로세스가 통신에 참여하는 단위다.
- OFF: 참가자 수 상한을 1, 즉 자기 자신으로 묶고 멀티캐스트 수신 위치를 비운다. 누구도 찾을 수 없다.
- LOCALHOST: 멀티캐스트 수신을 끄고 유니캐스트(unicast, 특정 주소 하나로 보내는 전송) 수신 위치만 남긴다. 초기 피어 목록에는 루프백(loopback, 자기 기계를 가리키는 주소)
127.0.0.1을 넣는다. - LOCALHOST 또는 SUBNET:
ROS_STATIC_PEERS의 주소를 이름 풀이(DNS)해 초기 피어 목록에 더한다. 이름을 풀지 못하면Unable to resolve peer오류로 참가자 생성 자체가 실패한다. - SUBNET + 정적 피어: 멀티캐스트 주소
239.255.0.1도 초기 피어에 넣어 방송을 유지한다.

이 목록에서 3칸이 왜 빠지는지가 바로 보인다. 피어 없는 LOCALHOST 노드가 알림을 보내는 곳은 127.0.0.1뿐이다. 같은 기계라면 그 주소로 상대 프로세스에 닿지만, 다른 기계에는 자기 존재를 알릴 길이 없다. SUBNET 상대가 방송을 해도 LOCALHOST 쪽은 멀티캐스트를 듣지 않는다. 피어 없는 LOCALHOST가 한쪽에라도 끼면 다른 호스트에서 X가 되는 이유다.
비유가 어긋나는 첫 지점은 “한쪽 명단만으로 충분하다”는 칸이다. LOCALHOST 노드도 유니캐스트로는 계속 듣고 있으므로, 상대가 이 노드의 주소를 명단에 넣고 먼저 알려 오면 응답할 수 있다. 전화를 걸지는 않아도 걸려 온 전화는 받는 셈이고, 그래서 표에서 둘 다 LOCALHOST여도 한쪽에만 피어가 있으면 O다. 이 응답 경로는 표의 판정과 맞아떨어지는 해석이지만 패킷 수준에서 확인하지는 않았다. 두 번째 지점은 앞에서 본 대로다. 발견에 성공해도 데이터 전달은 별개의 단계다.
OFF는 rcl 단계에서 이미 갈린다. rcl init.c는 범위가 OFF이면 정적 피어를 읽지 않는다. 표에서 OFF 행과 열이 피어 유무와 상관없이 모두 X인 것과 같은 결과다.
표 밖에서 결과를 바꾸는 설정도 두 가지 있다. rcl discovery_options.c는 값을 대소문자까지 정확히 비교하고, 그 밖의 값은 Invalid value ... assuming localhost only 경고를 남긴 뒤 LOCALHOST로 처리한다. subnet처럼 소문자로 쓰면 SUBNET이 아니라 LOCALHOST가 된다. 또 ROS_LOCALHOST_ONLY가 켜져 있으면 범위가 LOCALHOST로 고정되고 정적 피어 수는 0이 된다. 오래된 .bashrc에 이 줄이 남아 있으면 새 변수를 아무리 바꿔도 소용이 없다.
Jazzy 컨테이너 하나에서 같은 호스트 36칸을 다시 돌려 보기
여기까지는 문서와 코드를 읽은 결과다. 실제 rclpy 노드가 같은 호스트 표 그대로 움직이는지, 코드를 읽고 예상한 함정이 실제로 그렇게 나타나는지는 따로 확인해야 했다. 그래서 ROS 2 Jazzy 이미지 컨테이너 하나 안에서 같은 스크립트를 5번 돌렸다.
스크립트는 자기 자신을 워커 프로세스 두 개로 띄우고, 두 프로세스를 표의 노드 A와 노드 B로 쓴다. 칸마다 두 워커가 rclpy Context를 새로 열기 직전에 ROS_AUTOMATIC_DISCOVERY_RANGE, ROS_STATIC_PEERS(127.0.0.1), ROS_LOCALHOST_ONLY를 그 칸의 값으로 바꾸고, 칸끼리 섞이지 않게 칸 k는 ROS_DOMAIN_ID 20+k를 쓴다. 각 노드는 0.1초마다 상대가 구독하는 토픽에 문자열을 발행하고, 4초 동안 상대 노드 이름이 그래프에 처음 보인 시각과 상대 메시지를 처음 받은 시각을 잰다. 양쪽 모두 상대를 보고 양쪽 모두 메시지를 받아야 O로 센다. rcl이 실제로 적용한 범위와 정적 피어 수는 rcl의 DEBUG 로그 Automatic discovery range is ..., Static peers count is N에서 읽었다.
재현 스크립트와 실행별 원본 출력은 실험 저장소 디렉터리에 있다. 코드는 experiment.py, 실행 전 예측과 시행착오는 plan.md, 5회 원본 출력은 results.json이다. Docker가 설치된 Linux에서 다음처럼 같은 이미지 다이제스트로 돌릴 수 있다.
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 # 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"
정상이라면 칸마다 JSON 한 줄씩 36줄, 함정 확인 2줄, 마지막 요약 1줄이 나온다. 요약 줄의 o_cells가 16이 아니거나 rule_mismatch가 빈 목록이 아니면 같은 호스트 표와 다르게 동작한 것이다.
처음부터 이 방식이었던 것은 아니다. 첫 시험 실행에서 36칸 판정은 예측과 같았지만, OFF에 정적 피어를 준 문맥 12개에서 ROS_STATIC_PEERS will be ignored 경고가 한 번도 나오지 않았다. rcl의 init.c를 다시 읽으니 범위가 OFF이면 정적 피어를 읽는 함수를 아예 호출하지 않아 피어 수가 0으로 남고, 경고는 “피어 수가 1 이상이면서 OFF”일 때만 찍힌다. 환경변수로는 이 경고에 도달할 수 없고, 코드에서 init options에 피어를 직접 넣었을 때만 해당한다는 뜻이다. 경고 개수로는 피어가 버려졌는지 알 수 없으므로, 앞에서 말한 DEBUG 로그로 칸별 실제 설정을 기록하도록 바꿨다. 첫 문맥에서 로그 레벨이 늦게 적용될 수 있어 rclpy.init() 전에 rcl 로거를 DEBUG로 올리는 줄도 더했다.
측정 환경은 ros:jazzy-ros-base 이미지(ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca), Python 3.12.3, rclpy 7.1.12-1noble.20260902.053513, RMW는 기본값 rmw_fastrtps_cpp다. 컨테이너는 --network none(루프백만 있음), 1 CPU, 1 GiB로 제한했고 같은 스크립트를 5번 실행했다. 5번 모두 종료 코드 0이었고, 한 번에 33.22~33.39초가 걸렸다.
| 확인 항목 | 실행 전 예측(plan.md) | 실측(5회) |
|---|---|---|
| 36칸 중 O | 16칸 | 5회 모두 16칸 |
| 행별 O(피어 없음 OFF·LOCALHOST·SUBNET, 피어 있음 OFF·LOCALHOST·SUBNET) | 0, 4, 4, 0, 4, 4 | 5회 모두 0, 4, 4, 0, 4, 4 |
| “양쪽 모두 OFF가 아니면 O” 규칙과 다른 칸 | 0칸 | 5회 모두 0칸 |
| A·B를 바꾸면 판정이 달라지는 쌍 | 0쌍 | 5회 모두 0쌍 |
| 한쪽만 상대를 보거나 받은 칸 | 예측 없음 | 5회 모두 0칸 |
| OFF + 정적 피어 문맥 12개에서 rcl이 적용한 피어 수 | 0개(버려짐) | 5회 모두 12개 문맥 전부 0개 |
같은 12개 문맥의 ROS_STATIC_PEERS will be ignored 경고 |
12번 | 5회 모두 0번 |
| LOCALHOST·SUBNET + 정적 피어 문맥 24개의 피어 수 | 예측 없음 | 5회 모두 24개 문맥 전부 1개 |
A만 subnet(소문자), B는 SUBNET |
Invalid value 경고 뒤 LOCALHOST, 같은 호스트라 O |
5회 모두 LOCALHOST·피어 0개, 경고 1번, 서로 발견·수신 |
A만 ROS_LOCALHOST_ONLY=1 + SUBNET + 피어, B는 SUBNET |
경고 뒤 LOCALHOST 고정, 피어 0개, O | 5회 모두 LOCALHOST·피어 0개, 경고 1번, 서로 발견·수신 |
O 칸에서는 노드를 만든 뒤 0.42초 안에 양쪽 모두 상대를 보고 첫 메시지를 받았다. X 칸 20개는 5회 모두 4초 동안 상대 이름도 메시지도 한 번 보지 못했다.
첫 번째 실행의 원본 출력 일부를 그대로 옮긴다.
{"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)"}
출력의 A·B에서 앞 글자 -는 정적 피어 없음, p는 있음이고 뒤 글자 O·L·S는 OFF·LOCALHOST·SUBNET이다. ae·be는 rcl이 실제로 적용한 범위의 첫 글자와 정적 피어 수다. 세 번째 줄에서 A는 ROS_STATIC_PEERS=127.0.0.1을 받았지만 ae가 O0, 즉 OFF에 피어 0개로 처리됐고 경고는 없었다. as·ar은 A가 상대를 처음 본 시각과 첫 메시지를 받은 시각(초), an은 받은 메시지 수다.
같은 호스트 표는 이 환경에서 칸 단위로 재현됐고, 예측과 다르게 나온 것은 OFF + 정적 피어 경고 한 항목이다. 이 차이는 첫 시험 실행에서 이미 드러나 위에 적은 대로 원인을 확인했다. 실무에서 기억할 사실은 OFF에 넣은 정적 피어가 환경변수 경로에서는 경고 없이 버려진다는 점이다. 소문자 subnet도 같은 기계에서는 아무 문제 없이 통하기 때문에, 노드를 다른 기계로 나누기 전까지는 드러나지 않는다.
이 측정으로 끌어낼 수 없는 것도 있다. --network none에는 루프백만 있으므로 SUBNET 칸이 O로 나왔다고 실제 LAN에서 멀티캐스트가 전달된다는 뜻은 아니며, 같은 기계의 통신은 공유 메모리나 루프백으로도 성립한다. 정적 피어 주소도 127.0.0.1뿐이라 “다른 호스트에서 한쪽 피어만으로 O”라는 표의 주장은 이 실험으로 확인되지 않았다. 한 프로세스가 칸마다 참가자를 여러 개 만든 구조라 실제 배치와 자원 사용은 다르고, 확인한 RMW는 rmw_fastrtps_cpp 하나다.
내 두 노드를 표에 대입하는 순서
각 컨테이너나 PC의 셸에서 관련 변수를 모두 출력하고, 데몬을 끈 뒤 목록을 다시 본다.
env | grep -E '^(ROS_|RMW_)' | sort
ros2 daemon stop
ros2 topic list --no-daemon
그다음 아래 순서로 판정한다. 앞 단계에서 걸리면 뒤 단계는 볼 필요가 없다.
ROS_LOCALHOST_ONLY=1이 남아 있는가. Jazzy rcl은 이 값을 우선해 범위를 LOCALHOST로 고정하고 정적 피어를 버린다. 지우고 새 변수로만 설정한다.- 값을 대문자로 정확히 썼는가.
OFF,LOCALHOST,SUBNET,SYSTEM_DEFAULT외의 값은 LOCALHOST로 처리된다. - 한쪽이라도 OFF인가. OFF인 노드는 피어 목록과 상관없이 아무도 찾지 못한다. Jazzy ros2cli는 OFF이면서 피어가 비어 있을 때 경고 문구에서
ROS_STATIC_PEERS설정을 해결책 중 하나로 제시하지만, 노드의 실제 설정을 정하는 rcl과 공식 표, 위 실측은 모두 OFF에서 피어를 무시한다. OFF인 노드의 범위를 LOCALHOST나 SUBNET으로 바꾼다. - 피어 없는 LOCALHOST가 끼어 있는가. 다른 호스트에서 X가 되는 두 상황이 여기다. LOCALHOST를 유지하고 싶다면 적어도 한쪽의
ROS_STATIC_PEERS에 상대 기계의 주소를 넣는다. 공식 표상 한쪽이면 충분하고, 양쪽에 넣는 것은 그보다 보수적인 설정이다. 격리가 필요 없다면 LOCALHOST를 SUBNET으로 바꿔도 된다. - 양쪽 모두 SUBNET인데도 안 보이는가. 두 변수로 설명되지 않는 상황이다. 멀티캐스트가 실제로 전달되는지, 방화벽,
ROS_DOMAIN_ID와RMW_IMPLEMENTATION이 양쪽에서 같은지를 본다.
4번의 설정은 예를 들어 노드 B 쪽 셸에서 다음과 같다. 주소는 노드 A가 있는 기계의 실제 주소로 바꾼다. 주소를 여러 개 넣을 때 쓰는 세미콜론은 셸에서 명령 구분자로 읽히므로 따옴표로 감싼다.
export ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST
export ROS_STATIC_PEERS='192.168.1.20' # 노드 A가 있는 기계의 주소
# 여러 대라면: export ROS_STATIC_PEERS='192.168.1.20;192.168.1.21'
정적 피어의 O는 상대에게 실제로 도달하는 주소를 넣었다는 가정 위의 판정이다. 호스트 이름을 넣었다면 그 이름이 양쪽에서 풀리는지도 확인한다.
실제 로봇에서 이 값을 바꿀 때는 원격 조종 노드와 비상정지 토픽도 서로 안 보일 수 있다. 하드웨어 비상정지 버튼처럼 ROS 통신과 무관한 정지 수단이 동작하는지 먼저 확인하고, 모터 전원을 끈 상태에서 설정을 바꿔 목록을 확인한 뒤 구동한다. 이 글의 실험은 시뮬레이터나 실물 로봇 없이 노드 두 개만 띄운 것이다.
확인 문제
1. 노드 A는 ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST, 노드 B는 기본값이고 둘 다 ROS_STATIC_PEERS가 비어 있다. 같은 PC에서는 통했는데 다른 PC로 나눴다. 통할까?
통하지 않는다. 다른 호스트 표에서 빠지는 3칸 중 하나다. A는
127.0.0.1에만 알리고 B의 멀티캐스트는 듣지 않는다. A의 피어에 B 주소를 넣거나, B의 피어에 A 주소를 넣거나, A를 SUBNET으로 바꾸면 표상 O가 된다.
2. 노드 A를 OFF로 두고 대신 ROS_STATIC_PEERS에 B의 주소를 넣었다. B는 SUBNET이다.
같은 호스트든 다른 호스트든 X다. rcl은 OFF일 때 정적 피어를 읽지 않으며, 위 실측에서도 OFF 문맥의 피어는 모두 0개로 처리됐고 경고도 없었다. 피어를 더하는 것이 아니라 A의 범위를 바꿔야 한다.
3. 두 노드 모두 LOCALHOST이고, B에만 A의 주소를 정적 피어로 넣었다. 서로 다른 PC다.
공식 표상 O다. 둘 다 LOCALHOST인 네 조합(피어 없음/있음 × 없음/있음) 가운데 다른 호스트에서 X인 것은 양쪽 모두 피어가 없는 한 칸뿐이다. 다른 호스트 판정이라 위 실험의 범위 밖이다.
4. 두 PC 모두 export ROS_AUTOMATIC_DISCOVERY_RANGE=subnet이라고 소문자로 적었다.
두 노드 모두 경고와 함께 LOCALHOST로 처리되고 피어도 없으므로 다른 PC 사이에서는 X다. 같은 PC에서는 O라서 “한 PC에서는 되는데 나누면 안 된다”는 증상과 정확히 겹친다.
Docker로 옮길 때 아직 비어 있는 칸
제목의 ‘Docker 컨테이너 경계’가 표의 ‘다른 호스트’와 같은지는 ROS 문서가 말하지 않는다. Fast DDS 문서는 네트워크 스택을 공유하지 않는 컨테이너끼리는 서로 다른 호스트로 감지해 UDP로 통신한다고 쓰지만, 이것은 공유 메모리와 UDP 중 무엇을 쓸지에 관한 문장이다. 여기에 LOCALHOST가 127.0.0.1에만 알린다는 코드를 합치면, 각자 자기 루프백을 가진 브리지 네트워크 컨테이너 둘은 피어 없는 LOCALHOST일 때 다른 호스트 표처럼 X가 될 것으로 예상된다. --network host 컨테이너끼리는 루프백을 공유하므로 같은 호스트 표에 가까울 것으로 예상되지만, 이때는 발견 뒤 공유 메모리 단계에서 막히는 원래 질문의 갈래로 넘어갈 수 있다. 두 문장 모두 직접 실행하지 않은 예상이다.
ROS 2 문서의 커뮤니티 기고 예제는 기본 브리지에 띄운 컨테이너 두 개에서 추가 설정 없이 talker와 listener를 실행하는 방법을 싣고 있다. 기본값 SUBNET끼리는 표에서 항상 O라는 점과 어긋나지 않지만, HuntLab이 실행해 본 예제는 아니다. 양쪽에 피어를 넣어야 했다는 Foxy·Cyclone DDS XML·VPN 기반 예제 저장소 michaelchi08/ros2_docker_examples의 관찰도 RMW와 설정 방식이 달라 이 표와 바로 비교할 수 없다.
컨테이너나 PC로 노드를 처음 나눌 때는 두 변수를 기본값 SUBNET으로 두고 시작하는 편이 가장 덜 헷갈린다. 같은 네트워크의 다른 로봇과 섞이지 않으려고 LOCALHOST를 쓴다면 적어도 한쪽에 상대 주소를 정적 피어로 넣어야 하고, OFF는 그 노드를 완전히 고립시킬 때만 쓴다. Humble 이하나 rmw_zenoh를 쓰고 있다면 이 표는 적용되지 않는다. 브리지 네트워크 컨테이너 두 개와 물리적으로 다른 PC 두 대에서의 실제 O/X는 아직 측정하지 않은 다음 확인 항목이다.
노드는 목록에 보이는데 메시지가 오지 않는 갈래라면, 발견 범위가 아니라 전달 조건을 볼 차례다. ROS 2 토픽 이름이 같은데 메시지가 안 올 때: QoS 호환성을 먼저 계산하기에서 그 다음 점검을 이어 갈 수 있다.
참고 링크
- ROS 2 Documentation — Using Improved Dynamic Discovery (Rolling 원문 rst, 커밋 c7b14b7) · Rolling 최신 원문 · Jazzy HTML — 두 변수의 정의와 두 표, 2026-10-06 확인
- ros2/rcl Jazzy
init.c—ROS_LOCALHOST_ONLY우선, OFF일 때 정적 피어 무시 - ros2/rmw_fastrtps Jazzy
participant.cpp— 범위별 Fast DDS 구성 - Fast DDS 2.14 — Leveraging Fast DDS SHM in Docker deployments — 컨테이너와 공유 메모리 전송
- 실험 스크립트
experiment.py· 5회 원본 출력results.json
이 글에는 제휴·협찬이 없다.