发布于 

ROS 2 多机通信与系统部署实战

系列导航:

  1. ROS 机器人仿真系统配置
  2. ROS 2 入门与环境搭建
  3. ROS 2 机器人仿真实战
  4. ROS 2 自定义消息与服务实战
  5. ROS 2 Nav2 自主导航实战
  6. ROS 2 机械臂抓取与视觉感知实战
  7. 外骨骼机器人技术概述
  8. 基于ROS 2 的外骨骼控制系统实战
  9. ROS 2 多机通信与系统部署实战(本文)

前言

当你的 ROS 2 系统从单机仿真走向实车部署,问题就从"算法对不对"变成"工程稳不稳":多台机器人怎么互相对话?网络抖了怎么办?怎么保证别人能一键复现你的环境?节点半夜挂了谁来叫醒它?

把整套部署想象成带兵:单机是散兵,多机是一支队伍。DDS 是队伍里的通信电台,Docker 是每人统一的制服和装备,systemd 是起床号,日志与监控是巡逻哨。本文要做的,就是让这支队伍"听同一个哨、走同一个步"——无论几台机器、什么环境,启动方式都稳。

读完你会搭出一套生产级 ROS 2 部署:多机用 DDS 对上话,用 Docker 打包环境,用 systemd 管理进程,用日志和监控兜底故障。这套能力是"能写节点"和"能交付系统"之间那道分水岭。

把本文拆成四件你亲手能做完的事,心里就有底了:① 给两台机器配上 ROS_DOMAIN_ID 和 ROS_IP,让它们互相看见对方的话题;② 写一个 Dockerfile + docker-compose,把 ROS 环境冻成镜像;③ 写一个 systemd unit,开机自动拉起整套编排;④ 配上健康检查 + 看门狗,节点挂了能自己爬起来。这四件事做完,你就从"会跑 demo"跨到了"能交付系统"——后者才是工程里值钱的部分。

补充一个常见误区:很多人以为"部署"就是把 launch 文件 scp 到机器上跑。错。真正的部署要解决三件事——环境一致(Docker)、进程存活(systemd)、故障可见(日志监控)。缺一个,系统在实验室能跑,到现场就跪。本文八节,其实就对应这三件事的层层加固,顺序也是从"能通信"一步步走到"能自愈"。

先说一句心里话:这一篇命令多、配置长,但主线只有一条——让所有机器在同一个域里、用同一套环境、按同一种方式启动。抓住这条线,下面那些长文件都不吓人。


动手前,先认认这几位主角

进命令之前,先把本文会反复出现的几个"角色"认个脸熟,后面就不会被名字吓住:

  • DDS:ROS 2 底层的"通信电台"。节点之间要说话,全靠它传话。它自己有好几种型号(Fast-DDS、CycloneDDS、Zenoh),像不同牌子的对讲机。
  • rmw:是"电台插槽"。ROS 2 代码通过这个插槽连到具体某款 DDS。换 DDS 其实就是换插槽里插的卡,上层代码一行不用动。
  • ROS_DOMAIN_ID:同一支队的"房间号"。号码相同才能互相听见,不同就彻底隔离。
  • Docker:把"系统 + ROS + 你的代码 + 依赖"冻成一个镜像,谁拉下来跑都一样,从根上消灭"在我机器上是好的"。
  • systemd:Linux 的"起床号",负责开机自动拉起你的 ROS,挂了还能自己爬起来。
  • QoS:通信的"语气规则"——哪些消息必须送达、哪些丢了就丢。

记住一句话:本文所有操作,都是在调这几个角色,让它们配合成一支听话的队伍。下面开始挨个拆。

一、DDS 通信原理与选型

队伍要通信,先得有电台。这一节就把 ROS 2 底层的"电台"——DDS——讲透:它是什么、有哪几种、怎么换。

为什么普通用户也要懂一点 DDS?因为一旦通信出怪问题,不懂 DDS 的人只能重启、重装、换网线三板斧,懂的人看一眼 rmw 和域配置就知道卡在哪。这一节不要求你读源码,只要建立"ROS 2 通信是分层的、可换中间的"这个直觉,后面排错就不再是黑盒。把它当背景知识,花十分钟读透,省下的是将来几个小时的抓瞎。

1.1 ROS 2 通信架构

这张图是 ROS 2 通信的"分层地图",从你写的代码一路往下,直到真正的网络。

从上看:你写的 rclcpp / rclpy 代码,经过语言无关的 rcl 层,再经过 rmw 这层"中间件接口",最后落到某个具体的 DDS 实现上。关键认知:同一个 ROS 2 程序,只要换 rmw 实现(改一个环境变量),底层 DDS 就换了,上层代码一行不用动。 后面所有"换 DDS"的操作,本质就是换 rmw。把这张图印在脑子里,后面选型、排错都靠它。

┌─────────────────────────────────────────────┐
│               ROS 2 通信栈                   │
├─────────────────────────────────────────────┤
│  rclcpp / rclpy / rclrs  (客户端库)         │
├─────────────────────────────────────────────┤
│  rcl  (ROS Client Library, 语言无关层)       │
├─────────────────────────────────────────────┤
│  rmw  (ROS Middleware Interface)             │
│  ├── rmw_fastrtps_cpp    (默认, C++)        │
│  ├── rmw_zenoh_cpp       (Zenoh, 新)        │
│  ├── rmw_cyclonedds_cpp  (轻量)             │
│  └── rmw_connextdds_cpp  (商业)             │
├─────────────────────────────────────────────┤
│  DDS Middleware  (数据分发服务)               │
│  ├── Fast-DDS / Zenoh / CycloneDDS / Connext│
└─────────────────────────────────────────────┘

记住一句话:rmw 是"可插拔的电台插槽"。今天插 Fast-DDS,明天插 Zenoh,对上层的节点完全透明。这也是 ROS 2 相比 ROS 1 最大的架构进步——通信中间件不再被写死。

讲到这你可能觉得"一张架构图而已,能当饭吃?"。能。我见过太多排错现场,两个人蹲在机器前对骂"你那节点明明没发",最后发现一个装了 CycloneDDS、一个用默认 Fast-DDS——rmw 不同,等于一个用对讲机一个用卫星电话,永远对不上。所以排查通信的第一步,永远是 echo $RMW_IMPLEMENTATION 确认两边一致。这一步能帮你省掉一半"灵异事件"。

那到底选哪个?给你一个不纠结的默认:桌面和小车先用默认的 Fast-DDS 跑通,别在选型上耗时间;树莓派、Jetson 这类内存紧张的嵌入式,直接换 CycloneDDS;要跨网段甚至跨公网(比如云端调度车间里的机器人),上 Zenoh。Connext 除非甲方点名,否则不用碰。三者都能干活,选错顶多费点资源,选不出来才真耽误事。

补充一句:确认"当前到底用哪个 DDS"别靠记忆,用命令说话——ros2 doctor --report | grep middleware 会打印出实际生效的 rmw 实现,而不是你 export 了什么。很多排错绕半天,最后发现环境里某个 .bashrc 偷偷覆盖了你的 RMW_IMPLEMENTATION,看了这行输出才恍然大悟。所以凡是和 DDS 相关的"不对劲",第一反应都是先打这行确认。

1.2 DDS 实现对比

选 DDS 不是看谁"最好",而是看你的场景:嵌入式低资源选 CycloneDDS,跨网络边缘计算选 Zenoh,通用高性能选 Fast-DDS。下面这张表先帮你快速定位,别在选型上纠结太久——三者都能把活干完,区别在资源占用和跨网能力。

特性 Fast-DDS CycloneDDS Zenoh Connext
类型 开源 C++ 开源 C 开源 Rust 商业
许可证 Apache 2.0 Eclipse Apache 2.0 商业授权
性能 极高 高 高(跨网络优化) 极高
内存占用 中 低 低 高
跨平台 Linux/Win/macOS Linux/Win/macOS 全平台 Linux/Win
多播发现 支持 支持 P2P/超级节点 支持
安全加密 TLS DDS Security TLS/RustCrypto 完整安全栈
适合场景 通用/高性能 嵌入式/低资源 跨网络/边缘计算 航空航天/军工
推荐度 ★★★★★ ★★★★ ★★★★★ (新) ★★★ (商业)

这里有个认知要摆正:换 DDS 不是"重装 ROS",而是"换一个中间件插件"。ROS 2 把 DDS 设计成可插拔层,正是为了让你不用动业务代码就能切换底层。所以看到 rmw_xxx_cpp 这种包名别慌,它只是个"适配器",装上去、指过去就能用。后面每一节的 RMW_IMPLEMENTATION 切换,都是这个机制的体现。

1.3 安装 DDS 实现

切 DDS 简单到只需要装对应的 rmw 包,再设一个环境变量——不用重装 ROS。

这一段干的事:装一个备用的 DDS 实现,并知道怎么"切"过去。 默认已是 Fast-DDS,所以第一行其实啥都不用做;想试 CycloneDDS 或 Zenoh,就 apt install 对应的 rmw 包,然后 export RMW_IMPLEMENTATION=... 即可。最后用 ros2 doctor 确认切换是否生效。你只需记住:换 DDS = 换一个 RMW_IMPLEMENTATION 环境变量。 切换后如果节点起不来,十有八九是忘了 source 对应的 setup 文件。

# === Fast-DDS (默认) ===
# ROS 2 Jazzy 已内置,无需额外安装

# === CycloneDDS (轻量替代) ===
sudo apt install ros-jazzy-rmw-cyclonedds-cpp
# 切换 RMW
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp

# === Zenoh (ROS 2 Kilted Kaiju+ 新增) ===
sudo apt install ros-jazzy-rmw-zenoh-cpp
export RMW_IMPLEMENTATION=rmw_zenoh_cpp

# 验证切换
ros2 doctor --report | grep middleware

这里有个新手必踩的坑:export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp 只对当前 shell 生效,新开一个终端就不认了。想永久生效,得把这两行写进 ~/.bashrc(或容器里的 entrypoint)。另外切换 DDS 之后,务必用 ros2 doctor --report | grep middleware 确认"当前真正生效的"就是你想用的那个,别凭感觉——很多人以为切了,其实没 source 对应的 setup 文件,白调半天。

二、多机通信配置

电台有了,这一节让多台机器用它"对上暗号"。核心就是两个环境变量:ROS_DOMAIN_ID(同一支队的编号)和 ROS_IP(本机地址)。

动手前先确认一个前提:两台机器得在同一个可互通的子网里,且防火墙没把 DDS 的端口(默认 7400 起、多播 7399 等)掐了。很多"配置全对但连不上"的真相,是云服务器的安全组或公司网段的 ACL 挡了 UDP 多播。先用 ping 确认 IP 层通,再谈 ROS 层——分层排查永远比一把梭排查高效。

这一节先建立最简单的"两台机器"心智模型:一台当"感官"(跑传感器),一台当"大脑"(跑导航)。实际项目里可能是十台、百台,但配置逻辑完全一样——每台机器只要知道"我在哪个域、我叫什么 IP",就能加入队伍。先把两台跑通,再复制粘贴扩展到 N 台,是工程里最稳的推进方式,别一上来就追求大规模。

2.1 基础多机配置

场景:一台工控机 (192.168.1.100) 跑传感器驱动,另一台 (192.168.1.101) 跑导航算法。两个前提必须先满足:ROS_DOMAIN_ID 两边必须相同(这是"同一个班"的编号),ROS_IP 必须填本机真实 IP(否则 DDS 可能挑错网卡)。先读这段设定,再往下看命令——三块代码分别是在机器 A、机器 B、以及任意一台上验证。

# === 机器 A (192.168.1.100) - 传感器工控机 ===

# 设置 ROS 域 ID(两台机器必须相同)
export ROS_DOMAIN_ID=42

# 设置本机 IP(确保 DDS 使用正确的网络接口)
export ROS_IP=192.168.1.100
export ROS_LOCALHOST_ONLY=0    # 允许跨机通信

# 启动传感器节点
ros2 launch my_robot sensor_drivers.launch.py
# === 机器 B (192.168.1.101) - 导航计算机 ===

export ROS_DOMAIN_ID=42
export ROS_IP=192.168.1.101
export ROS_LOCALHOST_ONLY=0

# 启动导航节点
ros2 launch my_robot nav2.launch.py
# === 验证通信 ===

# 在机器 B 上查看机器 A 发布的话题
ros2 topic list
# 应该能看到 /scan, /imu/data 等来自机器 A 的话题

# 检查节点是否能看到对端的节点
ros2 node list
ros2 topic echo /scan --once

# 测量跨机通信延迟
ros2 topic delay /scan

跑通后你会看到这样一个现象:在机器 B 上 ros2 topic list,能列出机器 A 发的 /scan、/imu/data;反过来 A 也能看到 B 的 /cmd_vel。如果两边互相"看不见",先 echo $ROS_DOMAIN_ID 确认两边一致,再看 ROS_IP 有没有填成 127.0.0.1(那是回环,跨机必死)。

把 ROS_DOMAIN_ID 和 ROS_IP 想成"房间号 + 门牌号":同域才能听见彼此(房间号一致),IP 是对方精确找到你的地址。很多人图省事把 ROS_IP 留空,结果 DDS 挑了 Docker 的虚拟网卡或回环地址 127.0.0.1,外面死活连不进来。所以宁可麻烦点显式写 IP,也别依赖自动发现——生产环境里"显式"永远比"自动"靠谱。还有 ROS_LOCALHOST_ONLY=0 这个开关,忘了设成 0,节点就只在自己机器内部转圈,跨机必死。

一个最小的验证套路,建议你每次配完都走一遍:先在机器 A 起一个 ros2 topic pub /hello std_msgs/msg/String ...,再在机器 B 上 ros2 topic echo /hello——能收到,说明通信栈完全通了,再去接真实驱动。这个"发一个 hello"的冒烟测试,比直接起一整套传感器节点排错快得多,因为它把"通信"和"算法"两个变量拆开了。

2.2 DDS 发现服务器(大型网络必备)

节点一多(超过 20 个),默认的多播发现会淹没网络——每台机器都在喊"我在这",带宽被自己人占满。解决办法是 Discovery Server:指定几台"哨兵"服务器,大家只跟哨兵报到了事。这是从"喊话式"到"报到式"的升级。

config/fastrtps_discovery_server.xml(Fast-DDS 配置):

这段 XML 很长,但记四行就够:两个 <Server> 里的 prefix(每个服务器的唯一身份证)、<address> + <port>(去哪找它)、以及最底下 use_STATIC_EndpointDiscoveryProtocol 那两行(关掉多播,改走服务器)。其余 transport、buffer 都是性能调优,先照抄,等真遇到瓶颈再动。 下面我在关键行加了注释。

<?xml version="1.0" encoding="UTF-8"?>
<profiles xmlns="https://www.eprosima.com/XMLSchemas/fastRTPS_Profiles">

  <transport_descriptors>
    <transport_descriptor>
      <transport_id>udp_transport</transport_id>
      <type>UDPv4</type>
      <!-- 收发缓冲放大到 8MB:点云/图像这类大消息不会再被网卡丢弃 -->
      <sendBufferSize>8388608</sendBufferSize>
      <receiveBufferSize>8388608</receiveBufferSize>
    </transport_descriptor>
  </transport_descriptors>

  <participant profile_name="discovery_server" is_default_profile="true">
    <rtps>
      <!-- 本机作为参与者的唯一标识,必须和下面 Server 的 prefix 对应 -->
      <prefix>44.53.00.5f.45.50.52.4f.53.49.4d.41</prefix>

      <builtin>
        <discovery_config>
          <discoveryServersList>
            <!-- 主发现服务器(机器 A):哨兵 1 号 -->
            <Server prefix="44.53.00.5f.45.50.52.4f.53.49.4d.41">
              <metatrafficUnicastLocatorList>
                <locator>
                  <udpv4>
                    <!-- 哨兵监听的 IP 和端口,客户端就靠这个连上来 -->
                    <address>192.168.1.100</address>
                    <port>11811</port>
                  </udpv4>
                </locator>
              </metatrafficUnicastLocatorList>
            </Server>

            <!-- 备份发现服务器(机器 B):哨兵 2 号,挂一个还有另一个 -->
            <Server prefix="44.53.00.5f.45.50.52.4f.53.49.4d.42">
              <metatrafficUnicastLocatorList>
                <locator>
                  <udpv4>
                    <address>192.168.1.101</address>
                    <port>11811</port>
                  </udpv4>
                </locator>
              </metatrafficUnicastLocatorList>
            </Server>
          </discoveryServersList>

          <!-- 关键:关掉多播发现,改走上面的静态服务器列表 -->
          <use_SIMPLE_EndpointDiscoveryProtocol>false</use_SIMPLE_EndpointDiscoveryProtocol>
          <use_STATIC_EndpointDiscoveryProtocol>true</use_STATIC_EndpointDiscoveryProtocol>
        </discovery_config>
      </builtin>

      <userTransports>
        <transport_id>udp_transport</transport_id>
      </userTransports>
    </rtps>
  </participant>

</profiles>

启动发现服务器:

这段是"把哨兵请上岗"的命令。机器 A 用 FASTRTPS_DEFAULT_PROFILES_FILE 指向上面的 XML,再用 ROS_DISCOVERY_SERVER 告诉它哨兵在哪;机器 B 只需 ROS_DISCOVERY_SERVER 指向同一地址即可入队。 两台都设好后,节点之间就不再靠喊,而是找哨兵报到。配完用 ros2 topic list 验证,能跨机看到话题就说明哨兵上班了。

# 机器 A:启动主发现服务器
export ROS_DOMAIN_ID=42
export FASTRTPS_DEFAULT_PROFILES_FILE=/path/to/fastrtps_discovery_server.xml

# 通过环境变量指定这是发现服务器(Fast-DDS 特有)
export ROS_DISCOVERY_SERVER="192.168.1.100:11811"

# 正常启动节点即可
ros2 launch my_robot sensor_drivers.launch.py
# 机器 B:连接到发现服务器
export ROS_DOMAIN_ID=42
export ROS_DISCOVERY_SERVER="192.168.1.100:11811"
export ROS_IP=192.168.1.101

ros2 launch my_robot nav2.launch.py

你可能会问"我三五个节点,也要搞发现服务器这么重的东西吗?" 不用。Discovery Server 是给"节点多到多播开始吵"的场景准备的——经验值大概 20 个节点以上,或者跨子网时。小系统老老实实用默认多播就行。等哪天你 ros2 topic list 要等好几秒才刷出来,那就是多播风暴的信号,再回来开服务器。别提前优化,过早优化是万恶之源。

配完发现服务器怎么确认它真生效了?最直接的办法:在哨兵服务器那台机器上 ros2 topic list,如果能跨机看到对端话题,说明"报到"成功;如果只有本机话题,八成是 XML 里 <address> 写错或端口被防火墙挡了。Linux 上 sudo ufw allow 11811 放行一下,或者用 netstat -tlnp | grep 11811 确认进程真的在监听,这一步能省掉大量"配置对的但连不上"的困惑。

2.3 CycloneDDS 配置

Fast-DDS 的 XML 写起来啰嗦,CycloneDDS 是更轻量、配置更短的选择,嵌入式设备尤其喜欢它。两者二选一即可,不用都配。

config/cyclonedds.xml(更简洁的替代方案):

CycloneDDS 的配置比 Fast-DDS 清爽得多。同样只需盯几行:<NetworkInterfaceAddress> 选网卡(写 auto 让它能自己选)、<Peer> 列表填"要通信的对端 IP"、<MaxMessageSize> 调大防止大消息被切坏。 下面我在关键行加了注释,其余 Trace 那块是调试时看日志用的,可留可删。

<?xml version="1.0" encoding="UTF-8"?>
<CycloneDDS>
  <Domain>
    <General>
      <!-- auto 让 CycloneDDS 自己挑一张能通的网卡 -->
      <NetworkInterfaceAddress>auto</NetworkInterfaceAddress>
      <AllowMulticast>true</AllowMulticast>
      <!-- 单条 DDS 消息上限,点云/图像动辄几 KB,调小会被分片 -->
      <MaxMessageSize>65500B</MaxMessageSize>
    </General>

    <Internal>
      <!-- 套接字缓冲放大,高吞吐时不丢包 -->
      <SocketReceiveBufferSize min="8MB"/>
      <SocketSendBufferSize min="8MB"/>
    </Internal>

    <Discovery>
      <Peers>
        <!-- 对端清单:把要通信的机器 IP 都列出来 -->
        <Peer address="192.168.1.100"/>
        <Peer address="192.168.1.101"/>
        <Peer address="192.168.1.102"/>
      </Peers>
    </Discovery>

    <Tracing>
      <!-- 调通前可设 verbose 看发现过程,稳了就改 warning 少打日志 -->
      <Verbosity>warning</Verbosity>
      <OutputFile>stdout</OutputFile>
    </Tracing>
  </Domain>
</CycloneDDS>
# 使用 CycloneDDS + 配置文件
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
export CYCLONEDDS_URI=file:///path/to/cyclonedds.xml

看到这你大概发现了:Fast-DDS 的 XML 像写论文,CycloneDDS 的 XML 像写便条。这不是审美差异,是设计取向——CycloneDDS 把"多播发现 + 自动选网卡"做成默认行为,所以配置极短;Fast-DDS 把每个旋钮都暴露给你,所以长但可控。新手先用 CycloneDDS 把通信跑通,再按需要切 Fast-DDS 做精细调优,是性价比最高的路线。两者二选一即可,别俩都配,徒增混乱。

用 CycloneDDS 时有件事要记牢:它的配置文件通过 CYCLONEDDS_URI 环境变量指过去,且这个变量和 RMW_IMPLEMENTATION 必须同时设对——只切了 rmw 却忘了指 URI,节点会回退到默认配置,你以为用了 Cyclone 其实没用。所以切换 DDS 永远是两个动作打包做:export RMW_IMPLEMENTATION=... 加 export CYCLONEDDS_URI=...(或 Zenoh 的 ZENOH_CONFIG),漏一个都等于白切。

2.4 Zenoh 配置(跨网络推荐)

前面两种都基于 DDS 的多播/单播发现,适合局域网。Zenoh 是 ROS 2 Kilted 新增的 Tier 1 RMW,特别适合跨网络、边缘计算——它不像 DDS 那样靠多播"广播找人",而是可以走一个中心路由器,天然适合机器跨网段甚至跨公网。

# 安装
sudo apt install ros-jazzy-rmw-zenoh-cpp

# 基础配置
export RMW_IMPLEMENTATION=rmw_zenoh_cpp

# 路由器模式(大型网络推荐)
# 机器 A 作为 Zenoh 路由器
zenohd --listen tcp/192.168.1.100:7447

# 其他机器连接路由器
export ZENOH_CONFIG='{
  "connect": { "endpoints": ["tcp/192.168.1.100:7447"] },
  "scouting": { "multicast": { "enabled": false } }
}'

Zenoh 配置文件(zenoh_config.json5):

这段 JSON5 看着吓人,其实大多可不动。只需看两块:transport.tcp/udp 决定走什么底层(跨公网记得把 tls 那段注释打开做加密),routing.peer.mode 设成 "router" 就是中心路由模式。 其余 qos、性能都是可选调优,初学时照抄即可。

{
  // 网络配置
  transport: {
    link: {
      // TLS 加密(生产环境推荐):跨公网必开,把证书路径填进去
      // tls: {
      //   ca_certificate: "/path/to/ca.crt",
      //   server_certificate: "/path/to/server.crt",
      //   server_private_key: "/path/to/server.key",
      // }
    },
    tcp: {
      enabled: true,
    },
    udp: {
      enabled: true,
      multicast: {
        enabled: true,
        address: "224.0.0.224",
        port: 7447,
      },
    },
  },
  // QoS 配置
  qos: {
    // 点云等大数据用可靠传输,避免丢帧导致建图裂缝
    default_pub_congestion_control: "block",
  },
  // 性能调优
  routing: {
    peer: {
      // 路由器模式:所有节点通过路由器转发,适合大规模跨网络
      mode: "router",
    },
  },
}

Zenoh 的思路和其他两家根本不同:它不靠"广播找人",而是所有节点连到一个中心路由器(zenohd),由路由器转发。好处是跨公网、跨 NAT 都通——DDS 在多播受限的网络里会懵,Zenoh 不会。代价是你得先保证路由器那台机器活着,它挂了大家就失联。如果你的机器人要出车间、上 5G、跨到云,Zenoh 目前最省心;但它相对新,生态和踩坑帖比 Fast-DDS 少,遇到怪问题查资料会费劲点,心理准备要有。

三、Docker 容器化部署

通信搞定,接着统一"制服"——用 Docker 把 ROS 2 环境连同你的代码一起打包,别人拉下来就能跑,不再有"在我机器上是好的"这种扯皮。这一节能帮你从"我会装环境"晋升到"我能交付环境"。

选基础镜像有个铁律:版本必须 pin 死。写 FROM ros:jazzy-ros-base 比 FROM ros:latest 安全——哪天 ROS 发了新版本,latest 会让你的镜像 silently 变样,昨天能跑的今天炸了。容器化的核心承诺是"可复现",而可复现的第一条就是"基础环境锁版本"。同理,apt 装的包也尽量锁大版本,别让 apt-get upgrade 在天凉好个秋的夜里偷偷改了你的世界。

3.1 基础 Dockerfile

Dockerfile:

这个 Dockerfile 用"两阶段构建":第一阶段 builder 里编译你的包,第二阶段 runtime 只拷贝编译结果,镜像体积小一半。你只需关注四个地方:FROM 选对 ROS 发行版(jazzy)、rosdep install 装系统依赖、colcon build 编译、最后 ENV ROS_DOMAIN_ID 和 RMW_IMPLEMENTATION 设好默认环境。 下面关键行都标了注释。

# === Stage 1: 构建阶段 ===
FROM ros:jazzy-ros-base AS builder

# 安装构建依赖
RUN apt-get update && apt-get install -y \
    python3-colcon-common-extensions \
    python3-rosdep \
    git \
    cmake \
    build-essential \
    && rm -rf /var/lib/apt/lists/*

# 创建工作空间
WORKDIR /ros2_ws/src

# 复制包源码(只拷 src,构建缓存更友好)
COPY packages/ ./packages/

# 安装包依赖:rosdep 一次性补齐所有系统级依赖
WORKDIR /ros2_ws
RUN rosdep init 2>/dev/null || true && \
    rosdep update && \
    rosdep install --from-paths src --ignore-src -r -y

# 编译:Release 优化,symlink-install 让改代码不用重 build
RUN . /opt/ros/jazzy/setup.sh && \
    colcon build --cmake-args -DCMAKE_BUILD_TYPE=Release \
    --symlink-install

# === Stage 2: 运行阶段 ===
FROM ros:jazzy-ros-base AS runtime

# 安装运行时依赖(导航/SLAM 等按需加,别贪多撑大镜像)
RUN apt-get update && apt-get install -y \
    ros-jazzy-nav2-bringup \
    ros-jazzy-slam-toolbox \
    ros-jazzy-ros-base \
    python3-pip \
    && pip3 install transforms3d \
    && rm -rf /var/lib/apt/lists/*

# 复制编译结果(只拿 install,不带源码和构建中间文件)
COPY --from=builder /ros2_ws/install /opt/ros2_ws/install

# 复制配置文件
COPY config/ /config/
COPY launch/ /launch/

# 设置环境变量:默认域 ID 和 DDS 实现,容器里直接生效
ENV RMW_IMPLEMENTATION=rmw_fastrtps_cpp
ENV ROS_DOMAIN_ID=0

# 入口脚本
COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
CMD ["ros2", "launch", "/launch/nav_bringup.launch.py"]

为什么用两阶段?因为编译工具链(gcc、colcon、rosdep)体积不小,但运行时根本用不到。把编译留在 builder、只把成品 install 拷进 runtime,镜像能从几 GB 砍到几百 MB,传输和启动都快。这是容器化最值得养成的习惯。

有人会杠:"我直接 apt 装 ROS 不行吗,非得 Docker?" 行,但你得保证每台机器、每个同事的 Ubuntu 版本、ROS 补丁、Python 依赖分毫不差。现实是这根本做不到,于是就有了经典的"在我机器上是好的"。Docker 把"操作系统 + ROS + 你的代码 + 依赖"冻结成一个镜像,谁拉下来都是同一份。它换来的是:部署从"按文档一步步装"变成"docker compose up"。前期写 Dockerfile 花的时间,在第三次重装系统时全赚回来。

顺带两个能救命的 Docker 习惯:第一,写个 .dockerignore,把 build/、logs/、.git/ 排除掉,否则 COPY 时把几个 GB 的构建垃圾塞进镜像上下文,构建慢到怀疑人生;第二,把"不常变"的步骤(装依赖、rosdep)放在 Dockerfile 前面,"常变"的(你的源码)放最后,这样改一行代码只重跑最后一层,缓存命中前面全部,迭代速度快十倍。

补充:entrypoint 里 set -e 很关键——它让脚本在任何命令失败时立刻退出,而不是带着错误继续执行后面的命令,最后给你一个"看似跑完实则半残"的容器。配合 exec "$@"(用 exec 替换进程而非起子进程)能让 SIGTERM 信号正确传给你的 ROS 进程,优雅关闭才生效。这两行是 Docker 化 ROS 的必备姿势,缺了 exec 你会发现 docker stop 要等很久才杀掉进程。

3.2 入口脚本

entrypoint.sh:

这个脚本是容器的"起床第一件事":先 source 两层 ROS 环境(基础镜像 + 你的工作空间),再设置 DDS 相关环境变量,最后把"启动命令"原样执行。 注意 hostname -I 那段是自动猜本机 IP——多机通信里 ROS_IP 填错是最常见故障,让脚本自动填能少踩坑。

#!/bin/bash
set -e

# Source ROS 环境:先基础镜像,再你的工作空间(顺序不能反)
source /opt/ros/jazzy/setup.bash
source /opt/ros2_ws/install/setup.bash

# 设置 DDS 配置:用 ${VAR:-默认值} 允许从 docker run -e 覆盖
export RMW_IMPLEMENTATION=${RMW_IMPLEMENTATION:-rmw_fastrtps_cpp}
export ROS_DOMAIN_ID=${ROS_DOMAIN_ID:-0}

# 如果设置了 DISCOVERY_SERVER,写入配置(这里只是打印提示,真配置在 XML/ENV)
if [ -n "$ROS_DISCOVERY_SERVER" ]; then
    echo "Discovery Server: $ROS_DISCOVERY_SERVER"
fi

# 设置 ROS_IP(自动检测):多机通信最怕 IP 错,自动取第一张网卡地址
if [ -z "$ROS_IP" ]; then
    export ROS_IP=$(hostname -I | awk '{print $1}')
fi
echo "ROS_IP: $ROS_IP"
echo "ROS_DOMAIN_ID: $ROS_DOMAIN_ID"
echo "RMW: $RMW_IMPLEMENTATION"

# 执行传入的命令(即 CMD 或 docker run 后面的命令)
exec "$@"

entrypoint 里那段 hostname -I | awk '{print $1}' 是"自动填本机 IP"的关键。为什么不直接写死 ROS_IP=192.168.1.100?因为镜像是通用的,换台机器 IP 就变了,写死就得改镜像。用自动探测,同一个镜像能在传感器机、导航机之间随便漂。但如果你的机器有多张网卡(比如板载以太网 + 无线),hostname -I 可能取到无线 IP,那就要换成更精准的探测逻辑,别迷信自动——自动探测错了比写死更隐蔽。

为什么用 Compose 而不是一条条 docker run?因为一套机器人系统往往有五六个容器,手动起每个都要敲一长串参数,漏一个就错位。Compose 把这些参数写成一份声明式文件,一条 up 全拉起、一条 down 全收摊,还能定义依赖顺序。它本质是"把运维操作也代码化"——和 Dockerfile 把环境代码化是一脉相承的思想。

3.3 Docker Compose 多容器编排

docker-compose.yaml:

这个文件一口气定义了 6 个容器(传感器、导航、抓取、监控、可视化、Zenoh 路由)。你只需盯三件事:每个服务的 ROS_DOMAIN_ID 必须一致(同一支队)、network_mode: host 让容器直接用宿主机网卡(多机通信必须,否则 DDS 发现不到)、volumes 里把 /dev 和配置文件挂进去。 下面我在每个服务上标了注释,其余 restart / command 照抄即可。

version: '3.8'

services:
  # === 传感器驱动容器 ===
  sensors:
    build:
      context: .
      dockerfile: Dockerfile.sensors
    container_name: robot_sensors
    network_mode: host          # 需要 host 网络访问 USB/GPU
    privileged: true            # 访问 USB 设备
    restart: unless-stopped
    environment:
      - ROS_DOMAIN_ID=42
      - RMW_IMPLEMENTATION=rmw_fastrtps_cpp
      - ROS_IP=192.168.1.100
    volumes:
      - /dev:/dev               # USB 设备映射(雷达/相机等)
      - ./logs/sensors:/ros2_logs
    command: >
      ros2 launch my_robot sensor_drivers.launch.py

  # === 导航容器 ===
  navigation:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: robot_navigation
    network_mode: host
    restart: unless-stopped
    environment:
      - ROS_DOMAIN_ID=42
      - RMW_IMPLEMENTATION=rmw_fastrtps_cpp
      - ROS_IP=192.168.1.101
    volumes:
      - ./maps:/maps
      - ./config:/config
      - ./logs/nav:/ros2_logs
    command: >
      ros2 launch my_robot nav2.launch.py
      map:=/maps/warehouse.yaml

  # === 抓取容器 ===
  manipulation:
    build:
      context: .
      dockerfile: Dockerfile.manipulation
    container_name: robot_manipulation
    network_mode: host
    privileged: true
    restart: unless-stopped
    environment:
      - ROS_DOMAIN_ID=42
      - RMW_IMPLEMENTATION=rmw_fastrtps_cpp
    volumes:
      - /dev:/dev
      - ./logs/manipulation:/ros2_logs
    command: >
      ros2 launch pick_place_robot pick_place.launch.py
      use_sim:=false

  # === 监控容器 ===
  monitoring:
    image: ros:jazzy-ros-base
    container_name: robot_monitoring
    network_mode: host
    restart: unless-stopped
    environment:
      - ROS_DOMAIN_ID=42
    volumes:
      - ./logs/monitor:/ros2_logs
    command: >
      ros2 run robot_monitor monitor_node

  # === foxglove 可视化 ===
  foxglove:
    image: ghcr.io/foxglove/studio:latest
    container_name: foxglove_studio
    ports:
      - "8080:8080"
    environment:
      - ROS_DOMAIN_ID=42
    command: --disable-ssl

  # === Zenoh 路由器 ===
  zenoh-router:
    image: eclipse/zenoh:latest
    container_name: zenoh_router
    network_mode: host
    restart: unless-stopped
    command: zenohd --listen tcp/0.0.0.0:7447

编排文件看着长,本质就是一份"服务清单":谁依赖谁、用什么网络、挂什么盘、跑什么命令。日常你只需要记住三条:docker compose build 构建、docker compose up -d 拉起、docker compose logs -f <服务名> 看某个容器的日志。

关于 network_mode: host 多说一句:Docker 默认给容器一套独立网络命名空间,DDS 的多播发现跨不过去,所以多机 ROS 几乎必开 host。开了之后容器直接用宿主机网卡,发现才正常。代价是容器间网络隔离变弱,但对"一台机器跑一套 ROS"的机器人场景,这代价可忽略。privileged: true 则是为了让容器访问 /dev 下的 USB 设备(雷达、相机),没有它 lsusb 都看不到设备。这两行是多机 ROS 容器能跑起来的命门,别随手删。

3.4 构建与运行

这段是日常操作:构建所有镜像、后台拉起、看日志、进容器排错、收摊。 记住 docker compose up -d sensors navigation 可以只起部分服务——调试时别一次性全起,先起传感器确认有数据再起导航。

# 构建所有镜像
docker compose build

# 启动全部容器
docker compose up -d

# 查看日志
docker compose logs -f navigation

# 仅启动特定服务
docker compose up -d sensors navigation

# 进入容器调试
docker compose exec navigation bash

# 停止并清理
docker compose down

日常调试有个省心顺序:先 docker compose up -d sensors 单独起传感器,确认有 /scan 数据了,再起 navigation,最后起 manipulation。别一次性全起——一旦全起后某个容器疯狂重启,你很难判断是谁拖垮了谁。分阶段拉起,问题定位能快一倍;真上线时再用一条 docker compose up -d 全拉,心里也有底。

3.5 NVIDIA GPU 支持(深度学习推理)

Dockerfile.gpu:

需要 GPU 推理(比如视觉检测)时,基础镜像之外要启用 NVIDIA 运行时。关键就两行 ENV:开 NVIDIA_VISIBLE_DEVICES 让容器看到卡,开 NVIDIA_DRIVER_CAPABILITIES 暴露驱动能力;运行时还要加 --gpus all。 装 CUDA 工具链那段按需保留。

FROM ros:jazzy-ros-base

# NVIDIA CUDA 支持
ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility,video

# 安装 CUDA 工具
RUN apt-get update && apt-get install -y \
    nvidia-cuda-toolkit \
    && rm -rf /var/lib/apt/lists/*

# ... 其他安装步骤 ...

# 运行时需要 nvidia-container-toolkit
# 安装: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html
# 运行 GPU 容器
docker run --gpus all \
  --network host \
  --privileged \
  -e ROS_DOMAIN_ID=42 \
  -v /dev:/dev \
  my_robot_gpu:latest

GPU 容器最容易翻车的地方不是写 Dockerfile,而是宿主机没装 nvidia-container-toolkit。镜像里设 NVIDIA_VISIBLE_DEVICES=all 只是"声明要卡",真正把卡透传进来的是宿主机的 toolkit + 运行时的 --gpus all。如果 docker run 报"could not select device driver",九成是 toolkit 没装或没重启 docker 服务。这一节只在你真要跑视觉/深度学习推理时才需要,纯导航小车用不上,可直接跳过。

四、系统启动管理

制服穿好,这一节请"起床号"——用 systemd 让整套系统在开机时自动列队,挂了还能自己爬起来。没有它,你每上电都得手动敲一堆命令,机器人就还是"实验室玩具"。

为什么是 systemd 而不是写个开机脚本或 cron?因为 systemd 能管"进程死活"——它知道进程是跑了还是挂了,挂了能按规则重启,还能把日志收进 journald 统一查询。cron 只能定时触发、管不了持续进程;裸脚本则要靠你手动盯着。只要你的 ROS 是"常驻服务",systemd 就是标准答案,没有之一。

再补一句关于 WantedBy=multi-user.target:它决定服务在哪个"运行级别"启动。robot 是没图形界面的服务器,用 multi-user.target(纯命令行模式)就对了;别写成 graphical.target,那是带桌面的环境,服务器上可能根本不会到达这个状态,结果服务永远不启动。这个坑很隐蔽——enable 成功了,重启却没拉起,八成是 target 选错。

4.1 systemd 服务配置

/etc/systemd/system/ros2-robot.service:

这个 unit 文件让机器人上电即起整套 Docker 编排。只需盯几行:After=network.target docker.service 保证网络先于 ROS 起;ExecStart 用 docker compose up --abort-on-container-exit 跑编排;Restart=on-failure + RestartSec 让它崩了自动重试。 下面关键行都标了注释。

[Unit]
Description=ROS 2 Robot Navigation Service
After=network.target docker.service
Requires=docker.service

[Service]
Type=simple
User=robot
WorkingDirectory=/home/robot/robot_ws
Environment=ROS_DOMAIN_ID=42
Environment=RMW_IMPLEMENTATION=rmw_fastrtps_cpp
# 启动前先拉最新镜像,避免跑着旧版
ExecStartPre=/usr/bin/docker compose -f /home/robot/robot_ws/docker-compose.yaml pull
# 前台跑编排,systemd 才能管它的生死
ExecStart=/usr/bin/docker compose -f /home/robot/robot_ws/docker-compose.yaml up --abort-on-container-exit
ExecStop=/usr/bin/docker compose -f /home/robot/robot_ws/docker-compose.yaml down
Restart=on-failure
RestartSec=10

# 日志:统一交给 journald,后面用 journalctl 查
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ros2-robot

[Install]
WantedBy=multi-user.target
# 启用并启动服务
sudo systemctl daemon-reload
sudo systemctl enable ros2-robot
sudo systemctl start ros2-robot

# 查看状态
sudo systemctl status ros2-robot
journalctl -u ros2-robot -f

配完 enable 后,机器重启会自动拉起整套系统——这才是"部署"和"跑通 demo"的区别。改了 service 文件记得先 daemon-reload 再 restart,否则改动不生效。

systemd 的"启动顺序"是新人最容易栽的地方。After=network.target docker.service 的意思是"等网络和 Docker 都起来了,我才起"。如果漏了 docker.service,很可能 ROS 容器在 Docker 自身还没就绪时就尝试启动,然后秒挂。还有 Type=simple + 前台运行 docker compose up 的组合:systemd 只认"前台进程"的死活,你若用 -d 后台跑,systemd 会以为服务已退出,开机自启就失灵。这些细节差一个,整套自动拉起就崩。

还有一个容易踩的坑是"用户"。service 文件里 User=robot 指用 robot 这个系统用户跑容器,它必须存在(没有就 useradd -m robot),并且要对工作目录、docker 组有权限,否则 docker compose 会因权限不足直接失败。生产环境别用 root 跑 ROS——一旦节点有漏洞被攻破,攻击者就拿到整台机器。用普通用户 + 加入 docker 组,是既安全又不麻烦的折中。

4.2 健康检查脚本

scripts/health_check.sh:

这是"巡逻哨":定时检查核心节点在不在、关键话题有没有数据、TF 有没有发布、CPU/内存有没有爆。 脚本逻辑是遍历一份"必查清单",任一项失败就累加错误数,最后退出码等于错误数——systemd 或看门狗据此判断是否要拉起。你只需关注 check_node / check_topic 两个函数和最后的资源判断段,其余是彩色输出装饰。

#!/bin/bash
# ROS 2 系统健康检查脚本

set -euo pipefail

ROS_DOMAIN_ID=${ROS_DOMAIN_ID:-0}
WARN_COLOR='\033[1;33m'
ERROR_COLOR='\033[0;31m'
OK_COLOR='\033[0;32m'
NC='\033[0m'

# 检查某个话题是否在发布数据:用 ros2 topic hz 看有没有平均速率
check_topic() {
    local topic=$1
    local timeout=$2
    if timeout $timeout ros2 topic hz $topic --window 3 2>&1 | grep -q "average rate"; then
        echo -e "${OK_COLOR}[OK]${NC} $topic 正在发布"
        return 0
    else
        echo -e "${ERROR_COLOR}[FAIL]${NC} $topic 无数据"
        return 1
    fi
}

# 检查某个节点是否在线
check_node() {
    local node=$1
    if ros2 node info $node &>/dev/null; then
        echo -e "${OK_COLOR}[OK]${NC} $node 运行中"
        return 0
    else
        echo -e "${ERROR_COLOR}[FAIL]${NC} $node 未找到"
        return 1
    fi
}

echo "================================"
echo "  ROS 2 系统健康检查"
echo "  Domain ID: $ROS_DOMAIN_ID"
echo "  时间: $(date)"
echo "================================"

ERRORS=0

# 检查核心节点
echo ""
echo "--- 核心节点 ---"
for node in /patrol_controller /robot_monitor /amcl /controller_server /planner_server /bt_navigator; do
    check_node $node || ((ERRORS++))
done

# 检查关键话题
echo ""
echo "--- 关键话题 ---"
for topic_data in "/scan:5" "/odom:5" "/cmd_vel:5" "/robot/status:10" "/map:5"; do
    topic=$(echo $topic_data | cut -d: -f1)
    timeout=$(echo $topic_data | cut -d: -f2)
    check_topic $topic $timeout || ((ERRORS++))
done

# 检查 TF
echo ""
echo "--- TF 变换 ---"
if timeout 3 ros2 topic echo /tf --once &>/dev/null; then
    echo -e "${OK_COLOR}[OK]${NC} TF 正在发布"
else
    echo -e "${ERROR_COLOR}[FAIL]${NC} TF 无数据"
    ((ERRORS++))
fi

# 检查系统资源
echo ""
echo "--- 系统资源 ---"
CPU_USAGE=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d. -f1)
MEM_USAGE=$(free | grep Mem | awk '{printf "%.0f", $3/$2 * 100}')

echo "CPU: ${CPU_USAGE}%"
echo "内存: ${MEM_USAGE}%"

# 资源超 90% 只警告不报错,避免误杀正在高负载的任务
if [ "$CPU_USAGE" -gt 90 ]; then
    echo -e "${WARN_COLOR}[WARN]${NC} CPU 使用率过高"
fi
if [ "$MEM_USAGE" -gt 90 ]; then
    echo -e "${WARN_COLOR}[WARN]${NC} 内存使用率过高"
fi

echo ""
echo "================================"
if [ $ERRORS -eq 0 ]; then
    echo -e "${OK_COLOR}系统状态: 正常${NC}"
else
    echo -e "${ERROR_COLOR}系统状态: $ERRORS 个问题${NC}"
fi
echo "================================"

# 退出码 = 错误数,供上层(看门狗/systemd)判断
exit $ERRORS

这个脚本的退出码设计很关键:错误数直接当退出码,上层的看门狗读到非 0 就知道该重启。资源超 90% 故意只警告不计入错误——否则高负载任务会被误杀。你接自己项目时,把节点名和话题名换成你自己的即可。


健康检查脚本写得再好,也得有人读它的结果。常见两种接法:小项目用 journalctl -u ros2-robot -f 实时盯着就够;团队级部署建议接 Prometheus + Grafana,把错误数变成曲线图,手机就能收告警。脚本里"资源超 90% 只警告不报错"是有意设计——避免高负载任务被误杀引发雪崩,你抄的时候别顺手改成也计入错误。另外注意退出码等于错误数,上层看门狗正是靠它判断是否要拉起。

五、日志与诊断

队伍跑起来了,这一节讲"出了事怎么查"——ROS 2 自带一套日志和诊断工具,从调日志级别到录包回放都有。把它们当你的"急诊箱"。

先说清日志去哪了:ROS 2 节点的日志默认打到 stderr,被 systemd 接进 journald,用 journalctl -u ros2-robot 查;如果你想落地成文件,就用本节那种 > file 2>&1 重定向。两套别混用——既重定向到文件又指望 journalctl 看到,会扑空。调试时优先 journalctl(实时、带时间戳、能 -f),要发给别人分析再导成文件。

还有个细节:日志级别是"最低显示门槛",不是"只显示这一级"。设成 WARN 会显示 WARN/ERROR/FATAL 全部,INFO 和 DEBUG 被滤掉;设成 DEBUG 则五档全显示。所以调试时从 INFO 调到 DEBUG 是"从安静到啰嗦",不是"只显示调试信息"。理解这点,你才不会纳闷"我设了 ERROR 怎么还刷 INFO"。

5.1 ROS 2 日志系统

这套命令管"打个什么级别的日志"。 级别从 DEBUG 到 FATAL 递增,默认 INFO。调试时把某个节点调到 DEBUG 能看到内部细节,但别长期开——日志刷屏也拖性能。下面 --log-level ros.my_package:=DEBUG 这种"按包细调"最实用,可以只让你关心的包啰嗦起来,别的保持安静。

# 设置日志级别
ros2 run my_package my_node --ros-args --log-level debug

# 日志级别: DEBUG < INFO < WARN < ERROR < FATAL

# 按节点过滤日志
ros2 run my_package my_node --ros-args \
  --log-level ros.rcl:=INFO \
  --log-level ros.my_package:=DEBUG

# 日志输出到文件
ros2 run my_package my_node --ros-args \
  --log-level info \
  > /ros2_logs/my_node_$(date +%Y%m%d_%H%M%S).log 2>&1

日志级别调节是"现场调试的眼睛"。默认 INFO 只报关键事件;当你怀疑某节点内部逻辑出错却又不报错时,把它单独调到 DEBUG,内部每一步都打印出来,问题往往一目了然。但 DEBUG 日志量极大,生产环境长期开会把磁盘写满、还会拖慢节点——原则是"调完就降回去"。按包细调(ros.my_package:=DEBUG)能让你只盯关心的包,不被别的日志淹了,比全局开 DEBUG 文明得多。

5.2 ros2doctor 系统诊断

ros2 doctor 是"体检仪":一键扫出环境、网络、节点配置的常见病。 加 --report 看明细,--report-verbose 更细,grep -A5 Network 快速定位网络那段。新机器跑不起来,先 ros2 doctor 扫一遍,能省下半个下午的瞎猜。

# 完整诊断报告
ros2 doctor --report

# 检查特定项
ros2 doctor --report-verbose

# 只检查网络
ros2 doctor --report | grep -A5 "Network"

ros2 doctor 最好养成"新机器到手先扫一遍"的习惯。它不只查网络,还查环境变量冲突、包版本缺失、DDS 配置异常。很多"莫名其妙连不上"在 doctor 报告里其实已经用红字标出来了,只是你没看。把 --report 输出里标 WARN/ERROR 的行全过一遍,比盲猜快十倍——尤其在借来的机器或同事的环境上跑之前,这步能救你一下午。

5.3 ros2bag 数据录制与回放

ros2 bag 是"黑匣子":把话题录下来,事后慢慢回放分析,不用现场复现 bug。 录制时用 -a 全录或只挑关键话题;--compression 用 zstd 省空间;回放 --clock 让仿真时间对齐。下面这段是日常最常用的一套。

# 录制所有话题
ros2 bag record -o session_$(date +%Y%m%d_%H%M%S) -a

# 录制特定话题
ros2 bag record -o nav_session \
  /scan /odom /cmd_vel /map /robot/status /tf /tf_static

# 带压缩录制(减少存储空间)
ros2 bag record -o compressed_session \
  --compression-mode file --compression-format zstd \
  /scan /odom /cmd_vel

# 列出已录制的 bag
ros2 bag list

# 查看 bag 信息
ros2 bag info nav_session

# 回放
ros2 bag play nav_session --clock  # 带 /clock 话题
# 回放时调整速率
ros2 bag play nav_session --rate 0.5  # 0.5 倍速
ros2 bag play nav_session --rate 2.0  # 2 倍速

# 从 bag 中提取话题数据到 CSV
ros2 bag play nav_session --storage sqlite3 &
sleep 1
ros2 topic echo /robot/status --field battery_level \
  --once > battery.csv

录包是调试利器:现场把异常录下来,回实验室慢慢放,复现成本几乎为零。一个实用技巧——只录 /scan /odom /tf 等核心话题,别 -a 全录,否则磁盘几分钟就满。

ros2 bag 最香的用法是"现场录、回去放"。比如机器人在车间偶发抖动,你人不在现场,就让值班同事 ros2 bag record -a 录一段发你;你回实验室 ros2 bag play 一放,等于把那几分钟的全部传感器数据流完整重演,复现零成本。唯一要注意:-a 全录会飞快吃磁盘,几 GB 的 SSD 几分钟就满。所以实战里我习惯只录 /scan /odom /tf /cmd_vel 这几个核心话题,按需再加,别贪全。

5.4 性能分析工具

这段是"量体重":延迟、带宽、CPU 各用什么命令。 ros2 topic delay/bw 看单话题表现,ros2 node list + top 看进程资源,FASTRTPS_STATISTICS=1 能扒出 DDS 内部统计。按需取用,别一上来全开。

# === 话题延迟 ===
ros2 topic delay /scan --window 10

# === 话题带宽 ===
ros2 topic bw /camera/depth/color/points

# === 节点 CPU/内存占用 ===
# 使用 ros2_node_monitor(需单独安装)
ros2 node list
# 结合 top/htop 查看
top -p $(pgrep -f "ros2")

# === DDS 内部统计 ===
# Fast-DDS 内置统计
export FASTRTPS_STATISTICS=1
ros2 run my_node my_node  # 启动后可查看 DDS 统计

# === 系统级监控 ===
# 实时 CPU/GPU/网络监控
htop
nvidia-smi -l 1      # GPU 监控
iftop                # 网络带宽

性能分析别一上来全开。先用 ros2 topic bw 看单个大话题(比如点云)的带宽,估算网卡够不够;再 top -p 盯住某个节点的 CPU。只有确认有瓶颈,才去动 DDS 缓冲、消息尺寸这些底层参数。盲目调优往往引入新 bug——先测量、后动手,这条原则在性能优化里比任何具体参数都重要。

5.5 优雅关闭与看门狗

scripts/launch_with_watchdog.sh:

看门狗解决一个现实问题:节点半夜静默挂了,队伍不能就这么瘫着。脚本逻辑是"拉起系统 → 定时跑健康检查 → 错误超阈值就杀掉重启",最多重试 MAX_RESTARTS 次。 你只需关注 WATCHDOG_INTERVAL(多久查一次)、MAX_RESTARTS(最多重启几次)和那段 HEALTH 判断——它调用前面写好的 health_check.sh,错误数超 3 就动手。

#!/bin/bash
# 带看门狗的 ROS 2 启动脚本

LAUNCH_FILE=${1:-"nav_bringup.launch.py"}
WATCHDOG_INTERVAL=30  # 健康检查间隔 (秒)
MAX_RESTARTS=5        # 最大重启次数

restart_count=0

cleanup() {
    echo "收到关闭信号,执行优雅退出..."
    # 发送 SIGTERM 到 ROS 节点
    pkill -TERM -f "ros2"
    sleep 3
    # 强制终止残留进程
    pkill -9 -f "ros2" 2>/dev/null || true
    exit 0
}

trap cleanup SIGINT SIGTERM

# 超过最大重启次数就放弃,避免无限重启把机器拖死
while [ $restart_count -lt $MAX_RESTARTS ]; do
    echo "================================"
    echo "  启动 ROS 2 系统 (第 $((restart_count+1)) 次)"
    echo "  时间: $(date)"
    echo "================================"

    # 启动系统
    ros2 launch my_robot $LAUNCH_FILE &
    ROS_PID=$!

    # 看门狗循环:系统活着就定期体检
    while kill -0 $ROS_PID 2>/dev/null; do
        sleep $WATCHDOG_INTERVAL

        # 健康检查:调脚本,取退出码当错误数
        HEALTH=$(bash /scripts/health_check.sh 2>/dev/null; echo $?)
        if [ $HEALTH -gt 3 ]; then
            echo "[看门狗] 检测到严重问题 (errors=$HEALTH),准备重启..."
            kill -TERM $ROS_PID
            sleep 5
            break
        fi
    done

    ((restart_count++))
    echo "系统已退出,等待 5 秒后重启..."
    sleep 5
done

echo "已达到最大重启次数 ($MAX_RESTARTS),停止尝试"
exit 1

看门狗和前面的 health_check 是搭档:一个负责"查",一个负责"动手"。生产环境里这套组合能让你睡安稳觉——真出问题,系统自己就爬起来了。


看门狗里 MAX_RESTARTS=5 那个上限不是随便写的。若没有上限,一个必挂的 bug 会让系统陷入"挂→重启→挂→重启"的死循环,CPU 和日志被占满,连 ssh 上去杀进程都困难。设个 5 次上限,既给了临时恢复的机会,又防止无限重启把机器拖死。真到第 5 次还在挂,说明不是偶发,该人去看了——这也正是"自动兜底"和"人工兜底"的分界线。

六、QoS 深度配置

队伍通信的"语气"也得定:哪些消息必须送达、哪些丢了就丢。这就是 QoS(服务质量)。配错 QoS 是最隐蔽的故障之一——两边都在跑,却"互相对不上暗号"。

先给你吃颗定心丸:绝大多数 ROS 2 应用,用默认 QoS 就能跑得好好的,你不必一上来就精调。只有当出现"收不到消息""延迟异常""内存暴涨"这类具体症状时,才需要回来动 QoS。这一节的价值是"出问题时知道往哪调",而不是"每个项目都重写一遍"。所以通读一遍建立直觉即可,不必背参数。

但有一个默认行为要提前知道:ROS 2 的发布者和订阅者默认都是"Reliable + 历史缓存 10 帧",唯独话题的默认 QoS 在不同接口间可能不一致——比如你用 ros2 topic pub 手动发数据,它默认是 BestEffort,而你的订阅节点若是 Reliable,两端就对不上。所以"手动测试收不到"未必是代码错,先 ros2 topic info /xxx --verbose 看一眼默认 QoS,能省掉这种假故障。

6.1 QoS 策略对比

策略 Reliable + Transient Reliable + Volatile BestEffort + Volatile
可靠性 保证送达 保证送达 尽力而为
历史缓存 持久化,新订阅者收到 只发最新 只发最新
适合 /map, /tf_static /cmd_vel, 服务 /scan, /image
延迟 较高 中等 最低
带宽 中等 中等 最低

这张表先建立直觉:QoS 本质是在"可靠性"和"延迟/带宽"之间做取舍。传感器数据每秒几十帧,丢一帧下一帧立刻补上,用 BestEffort 最划算;控制指令丢一帧车可能撞墙,必须用 Reliable;地图和 TF 是"静态基准",后上线的节点也得立刻拿到,所以用 Transient(持久化)。记住这个排序,后面写代码配 QoS 就不会懵——先想"这消息丢了会怎样",再去选策略。

6.2 自定义 QoS 配置

这段 Python 演示三种典型 QoS 怎么配。记住口诀:传感器用 BestEffort(丢一帧无所谓,新帧马上来)、指令用 Reliable(命令不能丢)、地图/TF 用 Transient(新节点上线也要拿到最新那张图)。 depth 是缓存帧数,传感器留 5 帧够了。

# Python QoS 配置示例
from rclpy.qos import (
    QoSProfile, QoSReliabilityPolicy,
    QoSDurabilityPolicy, QoSHistoryPolicy,
    QoSLivelinessPolicy
)

# 高频传感器数据(摄像头/激光雷达):丢了就丢,别阻塞
sensor_qos = QoSProfile(
    reliability=QoSReliabilityPolicy.BEST_EFFORT,
    history=QoSHistoryPolicy.KEEP_LAST,
    depth=5,            # 只保留最新 5 帧,内存可控
    durability=QoSDurabilityPolicy.VOLATILE,
    lifespan=None,
    liveliness=QoSLivelinessPolicy.AUTOMATIC,
    deadline=None,      # 可设置: Duration(sec=0, nsec=100_000_000) = 100ms
)

# 关键指令(速度控制):必须可靠,丢命令会出事
cmd_qos = QoSProfile(
    reliability=QoSReliabilityPolicy.RELIABLE,
    history=QoSHistoryPolicy.KEEP_LAST,
    depth=10,
    durability=QoSDurabilityPolicy.VOLATILE,
)

# 静态数据(地图、TF):新节点上线也要立刻拿到
static_qos = QoSProfile(
    reliability=QoSReliabilityPolicy.RELIABLE,
    history=QoSHistoryPolicy.KEEP_LAST,
    depth=1,
    durability=QoSDurabilityPolicy.TRANSIENT_LOCAL,  # 新订阅者也能收到
)

QoS 配错最常见的症状是"一边发、一边死活收不到":多半是发布端用 Reliable、订阅端用 BestEffort(或反过来),两端"语气"不合就不聊。排错时先 ros2 topic info /xxx --verbose 看两端的 QoS 是否匹配。

配 QoS 有个常见误区:depth 设得越大越安全。其实 depth 只是"缓存多少历史帧",设太大既占内存,又让新订阅者一上来被旧数据淹没。传感器留 5~10 帧足够,静态数据(地图/TF)留 1 帧即可。记住:depth 是缓冲不是保险箱,可靠性靠的是 Reliable 策略本身,别指望靠加大 depth 来"补"不可靠。

6.3 C++ QoS 配置

C++ 侧更简洁:SensorDataQoS 一行就是"BestEffort + 缓存5帧"的传感器预设;自定义时 reliable() / transient_local() / keep_last(1) 链式调用即可。 下面订阅地图就用这个自定义 QoS,保证后启动的节点也能拿到那张静态地图。

#include <rclcpp/qos.hpp>

// 传感器 QoS
rclcpp::SensorDataQoS sensor_qos();
// 等效于: BEST_EFFORT + VOLATILE + KEEP_LAST(5)

// 自定义 QoS
rclcpp::QoS custom_qos(10);
custom_qos.reliable();
custom_qos.transient_local();
custom_qos.keep_last(1);

auto sub = this->create_subscription<nav_msgs::msg::OccupancyGrid>(
    "/map", custom_qos,
    std::bind(&MyNode::map_callback, this, std::placeholders::_1));

一个常被忽略的点:QoS 是"发布端和订阅端协商"的,不是单方面说了算。两端策略不兼容时,ROS 2 不会报错,而是"静默连不上"——这正是它最坑的地方。一旦遇到"明明都在跑却收不到",第一反应就是 ros2 topic info /xxx --verbose 看两端 QoS,八成能抓到一方 Reliable、一方 BestEffort 的错配。所以 C++ 里我推荐直接用 SensorDataQoS 这种官方预设,少写一行就少一个配错的机会。

七、安全配置

队伍再强,也要防"自己人认错人"、防"外人混进来"。这一节讲怎么把不同机器人隔在不同的域、不同的号里。

给安全排个优先级:先做"网络隔离"(domain + namespace),这是零成本、立竿见影的;再做"最小权限"(systemd 用非 root 用户、容器不加不必要的 capability);最后才上"传输加密"(DDS Security / Zenoh TLS)。前两步能挡掉绝大多数意外,加密是给"真的有人能物理摸到你的网络"这个极端场景准备的。按这个顺序投入,性价比最高。

最后提醒:加密不是免费午餐。DDS Security / TLS 会给每个消息加解密,CPU 占用和延迟都会上升,高频大消息(点云、图像)尤其明显。所以别"为了安全全加密",而是"只在跨不可信网络的那一段加密"。局域网内部可信链路,明文跑反而更顺畅。安全是权衡,不是开关。

7.1 DDS Security

DDS Security 给通信加密鉴权,适合跨公网或多人共享网络。 CycloneDDS 原生支持,Fast-DDS 需编译时开启。下面这行 CYCLONEDDS_URI 指向一个安全库,把认证库路径填进去即可启用。跨公网部署前,这一步几乎是必做的。

# Fast-DDS 安全配置(需要编译时启用)
# CycloneDDS 原生支持 DDS Security

# CycloneDDS 安全配置
export CYCLONEDDS_URI='<CycloneDDS><Domain><Security><Authentication><LibraryPath>/path/to/security/lib</LibraryPath></Authentication></Security></Domain></CycloneDDS>'

DDS Security 听着高大上,但说实话:局域网内、可信环境里,你可以先不碰它。它是给"跨公网、或网络上可能有不速之客"的场景准备的。一旦启用,证书管理本身又是一套活——所以建议是:先让系统跑稳,安全作为"上线前的最后一道加固"再做,别一开始就背着加密的包袱调通信,那样出问题都不知道是通信没通还是加密配错。

7.2 网络隔离

最简单有效的隔离手段:ROS_DOMAIN_ID 分区 + ROS_LOCALHOST_ONLY 锁本机。 不同机器人用不同 ID,彼此就像在不同的房间,谁也听不见谁;调试机临时切到某 ID 才能"旁听"那一队。

# 使用 ROS_DOMAIN_ID 隔离不同机器人
# 机器人 1
export ROS_DOMAIN_ID=1
# 机器人 2
export ROS_DOMAIN_ID=2
# 调试工具
export ROS_DOMAIN_ID=1  # 只能看到 Domain 1 的节点

# 仅本地通信
export ROS_LOCALHOST_ONLY=1

网络隔离最容易被忽视的是 ROS_LOCALHOST_ONLY。它默认在桌面版是 0,但某些容器镜像或脚本会设成 1,导致节点只在本地转圈、跨机死活看不见。排错时把它和 ROS_IP 一起列进检查项,能少走很多弯路。一句话:凡是"本机能通、跨机不通",先怀疑这两个变量,再怀疑 DDS 配置。

7.3 节点隔离(Namespace)

当多台同构机器人同域时,用 namespace 给每台加前缀,话题自动变成 /robot_1/scan、/robot_2/scan,互不干扰。 这比改 DOMAIN_ID 更轻量,适合"几台一样的车在一个队里"的场景。

# 不同机器人使用不同命名空间
ros2 launch my_robot nav2.launch.py namespace:=/robot_1
ros2 launch my_robot nav2.launch.py namespace:=/robot_2

# 话题会自动变为: /robot_1/scan, /robot_2/scan
# 互不干扰

这里分清两个工具:ROS_DOMAIN_ID 是"跨域硬隔离",不同域完全不通;namespace 是"同域内部加前缀",各聊各的还能互通。一个管"分队",一个管"队内编号",别混用。


把三种隔离手段放一起就清楚了:ROS_DOMAIN_ID 是"不同房间、彻底不通",适合把红队蓝队彻底分开;ROS_LOCALHOST_ONLY 是"只跟自己玩",适合单机调试时防被别的机器干扰;namespace 是"同屋不同名",适合几台同构机器人共享一个域又互不串台。实战里最常用的是 namespace + 同一 domain 的组合,既互通又好管理。这两个工具别混用:domain 管"分队",namespace 管"队内编号"。

八、故障排查速查表

真到现场,别从头翻文档。这张表是"急诊手册",对着现象查原因:

现象 可能原因 排查命令
看不到对方节点 ROS_DOMAIN_ID 不一致 echo $ROS_DOMAIN_ID
话题无数据 QoS 不匹配 ros2 topic info /topic --verbose
跨机延迟高 多播风暴 启用 Discovery Server
规划失败 TF 缺失/错误 ros2 run tf2_tools view_frames
传感器超时 USB 带宽不足 lsusb -t,减少传感器数量
内存持续增长 rosbag 未关闭 / 点云泄漏 ros2 bag list,检查回调
节点启动失败 依赖缺失 ros2 pkg list | grep pkg
Docker 网络不通 未用 host 模式 --network host

这张表建议你截图钉在显示器旁。现场出问题,先按表定位大类,再深入——比凭直觉乱试快得多。


用这张表有个诀窍:先按"现象"定位到"可能原因"那一列,再拿"排查命令"去验证,别一上来就乱试。比如"看不到对方节点",第一反应不是重启,而是 echo $ROS_DOMAIN_ID 看两边是不是同一个房间号——十次有六次就是它。把这张表存成手机备忘录,现场比翻文档快得多。

常见问题(FAQ)

把读者最容易卡住的问题集中答一遍,省得你逐个翻章节:

Q:换了 DDS 后节点起不来了,怎么办?
A:先 ros2 doctor --report | grep middleware 确认当前生效的 rmw,再确认对应的 setup 文件已经 source。九成情况是环境变量没生效,或 rmw 包根本没装。

Q:两台机器 ros2 topic list 互相看不见?
A:按本篇检查清单从上往下过,最常见就是 ROS_DOMAIN_ID 不一致,或 ROS_IP 填成了 127.0.0.1 回环地址。

Q:Docker 里能编译,容器外运行却没网络?
A:多半忘了 network_mode: host,DDS 的多播发现在 Docker 默认网络命名空间里跨不过去。

Q:systemd 服务 enable 了,重启却不拉起?
A:检查 After=docker.service 是否漏了,以及 WantedBy 是不是 multi-user.target,还有 Type 与是否前台运行是否匹配。

Q:两边节点都在跑,却收不到消息?
A:基本是 QoS 不匹配,用 ros2 topic info /xxx --verbose 看两端策略,八成一方 Reliable 一方 BestEffort。

Q:CPU 莫名其妙跑满?
A:检查是不是有人长期开着 DEBUG 日志刷屏,或 rosbag 一直在录没关,把磁盘和 CPU 都吃满了。

Q:容器里 lsusb 看不到 USB 设备?
A:没加 privileged: true 或没把 /dev 挂进去,设备根本没透传进容器。

Q:Zenoh 和 Fast-DDS 能混用吗?
A:不能。rmw 必须两端一致,否则就像对讲机异频,永远对不上暗号。

Q:镜像太大推不上去?
A:用两阶段构建 + .dockerignore,别把 build、logs、.git 这些垃圾塞进镜像上下文。

Q:看门狗一直重启停不下来?
A:调小 MAX_RESTARTS,别让一个必挂的 bug 陷入"挂→重启→挂"的死循环把机器拖死。

本篇调试检查清单

部署出问题,按这张清单从上往下过一遍,能解决九成现场故障:

  • [ ] 两台机器互相看得见吗? 在两台机器上各跑一次 ros2 topic list,对比话题列表是否一致;不一致先查 ROS_DOMAIN_ID。
  • [ ] RMW 一致吗? 两端 echo $RMW_IMPLEMENTATION,必须相同,否则像对讲机异频。
  • [ ] IP 填对了吗? ROS_IP 必须是真实网卡地址,不能是 127.0.0.1;多网卡机器警惕自动探测取错。
  • [ ] 容器用 host 网络了吗? 多机 ROS 的容器忘了 network_mode: host,DDS 发现不到对端。
  • [ ] QoS 匹配吗? 收不到消息时 ros2 topic info /xxx --verbose 看两端策略是否兼容。
  • [ ] systemd 顺序对吗? After=docker.service 漏了,容器会在 Docker 没就绪时秒挂。
  • [ ] 看门狗在干活吗? journalctl -u ros2-robot -f 看有没有反复重启,有就查健康检查的退出码。
  • [ ] 日志级别调对了吗? 调试开 DEBUG,稳定后降回 INFO,别让日志把磁盘写满。
  • [ ] 防火墙放行了吗? 发现服务器、Zenoh 路由器的端口(如 11811、7447)被 ufw 挡住时,跨机同样连不上。

把这九条存成开机自启的"体检表",每次部署前扫一遍,能省下大量现场救火的时间。遇到清单之外的怪问题,记住一个总原则:先确认"网络通不通、配置对不对",再怀疑"代码错没错"——ROS 2 部署里八成的故障都在前两项。

下一步往哪走

本篇把"一支队伍怎么听同一个哨"讲完了:电台(DDS 多机)、制服(Docker)、起床号(systemd)、巡逻哨(日志监控)、语气(QoS)、番号(域隔离)。这些合起来,才是一套能在真车上稳跑的 ROS 2 系统。

如果你是从系列第一篇跟过来的,下面这份清单可以当"毕业考":不靠文档、凭记忆把这八个模块串成一条能开机自启的部署链路,你就真正把 ROS 2 从"会写节点"带到了"能交付系统"。部署稳了之后,下一步就是往里塞更聪明的算法——而那时你会发现,地基打牢的好处是:调算法时再也不用分心去怀疑"是不是环境又没起来"。

回顾完整系列:

  1. ROS 机器人仿真系统配置(Noetic 基础)
  2. ROS 2 入门与环境搭建(Kilted Kaiju 新特性)
  3. ROS 2 机器人仿真实战(Gazebo + MoveIt2)
  4. ROS 2 自定义消息与服务实战(msg/srv/action)
  5. ROS 2 Nav2 自主导航实战(SLAM + 路径规划)
  6. ROS 2 机械臂抓取与视觉感知实战(Pick & Place)
  7. 外骨骼机器人技术概述(sEMG + 阻抗控制)
  8. 基于ROS 2 的外骨骼控制系统实战(Hardware Interface)
  9. ROS 2 多机通信与系统部署实战(DDS + Docker)