引言:一台不能随便重装的 ROS 1 NUC

最近我在帮组里一位同门处理 ROS 环境问题。实验室里还有一些 Ubuntu 20.04 + ROS 1 Noetic 的机器人 NUC;他一直用 ROS 1 控制 Franka。机器并不是不能用,驱动、Workspace 和脚本都已经配好,平时实验跑得很稳定。考虑到 ROS 1 Noetic 已经结束官方维护,我建议后续开发逐渐转向 ROS 2,但不想为此打乱现有环境。

如果只为换一个 ROS 版本,就把整台工作电脑的 Ubuntu、驱动和依赖重装一遍,代价太大。我想弄清楚的是:能不能保留 Ubuntu 20.04 + ROS 1,同时获得一套新的 ROS 2 环境?

那台 Franka NUC 还在工作,我没有直接拿它练习。自己的 NUC 正好是 Ubuntu 24.04 + ROS 2 Jazzy,这也成了我第一次比较完整地学习 Docker:先试着运行三种 ROS 环境,再把 Workspace、DDS 通信和真实 H30 IMU 接进来,最后用 Dockerfile 和 Compose 固定已经跑通的构建与启动过程。

我的主机基线是 Ubuntu 24.04.4 LTS、x86_64、内核 7.0.0-31-generic;原生 ROS_DISTRO=jazzyros2 位于 /opt/ros/jazzy/bin/ros2。下图是系统版本、内核与 ROS 路径的终端记录。

Ubuntu 24.04.4 主机与原生 ROS 2 Jazzy 环境

图 1:Ubuntu 24.04 与原生 Jazzy 环境

1. ROS 为什么经常和 Ubuntu 版本绑定

开始之前,先把“版本对不上”说清楚。ROS 安装包还依赖 Python、编译器、共享库和大量 Ubuntu 软件包。发行版维护者需要选定平台来构建和测试这些包,因此“能不能自己想办法装上”和“是否有官方二进制支持”是两回事。Ubuntu 20.04 并非绝对不能运行 ROS 2;这里讨论的是使用对应发行版的现成环境。

本文环境Ubuntu 用户空间用途
ROS 2 Lyrical26.04观察较新的 ROS 环境
ROS 2 Jazzy24.04与本文主机原生 ROS 对应
ROS 1 Noetic20.04验证较旧 ROS 1 环境

版本对应关系可查 ROS 2 Lyrical 发布资料Jazzy Ubuntu 安装文档。Noetic 已于 2025 年 5 月 31 日结束官方维护;本文把它作为旧机器人环境来研究,而不是推荐新项目采用 ROS 1。ROS 官方 EOL 说明也解释了这一点。

2. Docker 在这里到底解决什么

Docker Image(镜像)可以先理解为准备好的软件环境,Container(容器)则是从镜像启动的运行实例。对这篇文章最重要的一点是:容器有自己的 Ubuntu 用户空间,却与 Linux 主机共享内核。我的 Ubuntu 24.04 NUC 因而可以同时运行基于 26.04、24.04 和 20.04 用户空间的 ROS 容器。

Ubuntu 24.04 Host:内核 + 原生 ROS 2 Jazzy
│
├── Ubuntu 26.04 userspace + ROS 2 Lyrical
├── Ubuntu 24.04 userspace + ROS 2 Jazzy
└── Ubuntu 20.04 userspace + ROS 1 Noetic

这样做不必直接改动主机已有的 ROS 安装。至于代码、网络和硬件怎样与容器配合,后面会用真实 H30 一步步试出来。

3. Docker 安装与基础验证

在安装之前,我先看主机架构和 Docker 是否已经存在:

dpkg --print-architecture
docker --version
docker compose version

第一条查询 Debian 风格的架构名称(这台 x86_64 机器显示为 amd64);后两条分别检查 Docker 与 Compose 是否已有可用命令。

我这次怎么安装 Docker

鱼香 ROS 安装项目提供交互式工具。我这次运行下面的命令,在当时的菜单里选了 8:一键安装 Docker,而不是“ROS Docker 版”。菜单编号可能变化,照着选项文字选择即可。

source <(wget -qO- http://fishros.com/install)

这条命令会下载并执行第三方脚本,运行前要确认来源和内容。我的安装结果是 Docker 29.8.1、Compose v5.5.1

想走官方安装也可以

如果不使用第三方安装工具,可以按 Docker 官方 Ubuntu 安装文档配置 apt 仓库,再安装 Engine 和 Compose 插件。这是另一条可行路线,不是本文这次实际采用的安装过程。已有 Docker 的机器先确认原配置与冲突包,不要直接照搬卸载步骤。

安装后,我用下面三个命令分别检查客户端、Compose 插件和容器运行:

docker --version
docker compose version
docker run --rm hello-world

最后一条会拉取 hello-world 镜像并启动容器,我的终端打印出了 Docker 的欢迎信息。

Docker、Compose 与 hello-world 验证

图 2:Docker、Compose 与 hello-world 验证

安装后的真实踩坑:docker group 已配置,旧 Session 尚未生效

安装脚本已经把我的用户 lyapunov 加入 docker 组,但安装后的旧登录 Session 还保留着原来的 supplementary groups。于是 docker info 能显示客户端版本,连接 /var/run/docker.sock 时却报 permission denied。我检查了系统中的组记录和 root:docker 的 socket 权限,再用 sg docker -c "docker info" 访问成功,才确定问题出在旧 Session 的组身份。

旧登录 Session 尚未获得 docker group 权限

图 3:旧登录 Session 尚未获得 docker group 权限

临时跑一条命令可以用 sg docker -c "...";想刷新当前 shell 的组环境,可以考虑 newgrp docker。更正常的长期做法是注销并重新登录。重新登录后,在终端输入下面的命令并回车,就能查看当前用户所属的组:

id

我的输出中出现了 984(docker),之后普通用户直接运行 docker 命令也正常了。不要用 chmod 666 /var/run/docker.sock 绕过权限问题;Docker 组本身有较高的主机权限,详见 Docker 安装后设置

如果你习惯用 Codex、Claude Code 或其他 Agent,这类重复操作也可以直接交给它做,但先把边界说清楚。

用 Agent 完成这一节

先检查当前 Ubuntu / ROS / Docker 环境,并告诉我 Docker 是否已经安装。

请输出:
- Ubuntu 版本、Kernel、CPU 架构;
- ROS_DISTRO;
- Docker 与 Compose 状态。

如果 Docker 已安装:
直接运行 hello-world 验证。

如果 Docker 未安装:
先给出适合当前 Ubuntu 的安装方案和将要执行的系统修改,等我确认后再安装。

不要卸载已有软件,不要修改 Host ROS 环境。
最后给出实际执行命令和验证结果。

4. ROS Docker Image 应该怎么选

Docker Official Image ros提供 ros-coreros-base 等层级。我先用 ros-base 跑命令行和驱动,不额外处理图形界面。OSRF 的 osrf/ros还提供 desktop 镜像,需要 RViz 等工具时再考虑。三个镜像都把 ROS 发行版和 Ubuntu 代号写在标签中:

实验镜像标签本次 Image ID本次显示大小
较新环境ros:lyrical-ros-base-resolute0c19f326a3391.50 GB
对应环境ros:jazzy-ros-base-noblec3706ef0a0aa1.32 GB
较旧环境ros:noetic-ros-base-focal72b8bc59035d2.94 GB

这三组都已在 NUC 上拉取。docker images ros 可以核对本机标签、Image ID 和大小;表格记录的是本次结果,Image ID 不是固定不变的版本号。

使用的三组 ROS Docker Image

图 4:使用的三组 ROS Docker Image

用 Agent 完成这一节

请依次拉取 ros:lyrical-ros-base-resolute、ros:jazzy-ros-base-noble 和 ros:noetic-ros-base-focal。
记录每组 Image 的 tag、Image ID 和大小,整理成简表。
不要删除现有 Image;最后附上实际执行的命令和结果。

5. 实验一:Lyrical——Host 不升级也能用新的 ROS 2

第一站是比主机更新的 Lyrical。我想看看:Ubuntu 24.04 不升级,能否直接使用 Ubuntu 26.04 对应的 ROS 用户空间?先记下主机的系统、内核与 ROS:

cat /etc/os-release
uname -r
echo "$ROS_DISTRO"

/etc/os-release显示用户空间版本,uname -r显示内核版本,ROS_DISTRO显示当前终端加载的 ROS。接着拉取并进入 Lyrical 镜像:

docker pull ros:lyrical-ros-base-resolute
docker run --rm -it ros:lyrical-ros-base-resolute bash

pull下载镜像;run启动容器,--rm使其退出后自动删除,-it提供交互终端。进入容器后看同样几项:

cat /etc/os-release
uname -r
echo "$ROS_DISTRO"
which ros2

容器里读到 Ubuntu 26.04.1 LTS(Resolute Raccoon)ROS_DISTRO=lyricalros2/opt/ros/lyrical/bin/ros2;Python 是 3.14.4。而 uname -r 仍是主机的 7.0.0-31-generic。较新的 ROS 环境确实进来了,主机系统却没有跟着升级。

Ubuntu 26.04 与 ROS 2 Lyrical Container

图 5:ROS 2 Lyrical Container 运行结果

用 Agent 完成这一节

请在当前 Ubuntu 24.04 Host 上完成 Lyrical Container 操作。
拉取 ros:lyrical-ros-base-resolute,启动退出后自动清理的临时 Container,读取其中的 Ubuntu 版本、ROS_DISTRO、ros2 路径、Python 版本和 uname -r。
同时读取 Host 的 Ubuntu 版本与 Kernel,将两边结果并排总结。
不要修改 Host ROS 环境;最后附实际命令和输出。

6. 实验二:Jazzy——Host 已经有了,为什么还要放进 Docker

第二组看起来有点多此一举:我的主机本来就是 Ubuntu 24.04,也原生装了 Jazzy,为什么还要在 Docker 里再运行一次 Jazzy?

因为机器人项目不只有一个。项目 A 可能需要一组 Python 库和 ROS package,项目 B 又需要另一组。都直接装在 Host 上,项目多了以后,更新一个依赖可能影响另一个已经工作的环境。我更希望 Host 保持自己的 Jazzy 开发环境,让每个项目把所需软件放进各自的容器。将来换电脑、交给组里其他人或部署到另一台 NUC,需要迁移的就不只是源码,还有项目的环境定义。

如果用过 Python 的 venv 或 Conda,可以借这个思路理解 Docker:别让不同项目的依赖互相打架。Docker 管得更宽,除了 Python package,Ubuntu 用户空间、系统库、ROS package 和启动方式也可以放进项目自己的环境里。

所以我用相同的步骤,再启动一套 Jazzy:

docker pull ros:jazzy-ros-base-noble
docker run --rm -it ros:jazzy-ros-base-noble bash

容器内用前一节的四条检查命令,得到 Ubuntu 24.04.5 LTS(Noble Numbat)ROS_DISTRO=jazzy/opt/ros/jazzy/bin/ros2 和同一个内核 7.0.0-31-generic;Python 为 3.12.3

Ubuntu 24.04 与 ROS 2 Jazzy Container

图 6:Ubuntu 24.04 与 ROS 2 Jazzy Container

用 Agent 完成这一节

当前 Host 已有 Ubuntu 24.04 + ROS 2 Jazzy。请用 ros:jazzy-ros-base-noble 再启动一套退出后自动清理的 Jazzy Container。
读取 Host 和 Container 各自的 Ubuntu 版本、ROS_DISTRO、ros2 路径、Python 版本与 Kernel。
并排比较用户空间和共享内核,附实际命令与结果;不要修改 Host 环境。

7. 实验三:Noetic——在新系统里保留 ROS 1 环境

前两个都是 ROS 2,容易让人以为 Docker 是专门给 ROS 2 用的。其实 Docker 隔离的是用户空间和软件环境,并不关心里面跑的是 ROS 1 还是 ROS 2。所以第三组我反过来试:在较新的 Ubuntu 24.04 + Jazzy 主机上,运行 Ubuntu 20.04 + ROS 1 Noetic。

使用的仍是 Docker Official Image:

docker pull ros:noetic-ros-base-focal
docker run --rm -it ros:noetic-ros-base-focal bash

进入容器后检查:

cat /etc/os-release
uname -r
echo "$ROS_DISTRO"
which roscore

这里查 ROS 1 的 roscore 路径。本次得到 Ubuntu 20.04.6 LTS(Focal Fossa)ROS_DISTRO=noetic/opt/ros/noetic/bin/roscore,Python 为 3.8.10;内核依然是主机的 7.0.0-31-generic。旧主机可以借容器使用新环境;反过来,新主机也能保留旧项目需要的软件环境。

Ubuntu 20.04 与 ROS 1 Noetic Container

图 7:Ubuntu 20.04 与 ROS 1 Noetic Container

用 Agent 完成这一节

请在当前 Ubuntu 24.04 Host 上运行退出后自动清理的 ros:noetic-ros-base-focal Container。
读取 Container 的 Ubuntu 版本、ROS_DISTRO、roscore 路径、Python 版本和 uname -r,同时读取 Host Kernel。
根据实际输出说明 Noetic 用户空间是否能运行、它看到的 Kernel 来自哪里;不要修改 Host ROS 2 环境。
最后附命令和结果。

8. 三种环境放在同一台 NUC 上

把刚才看到的版本排在一起,差别就很清楚了:

环境Ubuntu 用户空间uname -rROS 发行版
Host 原生 Jazzy24.04.4 LTS7.0.0-31-genericROS 2 Jazzy
Lyrical 容器26.04.1 LTS7.0.0-31-genericROS 2 Lyrical
Jazzy 容器24.04.5 LTS7.0.0-31-genericROS 2 Jazzy
Noetic 容器20.04.6 LTS7.0.0-31-genericROS 1 Noetic

我的 NUC 仍然是 Ubuntu 24.04。三个容器分别有 26.04、24.04 和 20.04 的用户空间,但 uname -r 都指向同一个 Host Kernel。Docker 给了我多套 ROS 环境,并没有把主机“升级”为 Ubuntu 26.04。

三组 Container 用户空间与 Host Kernel 对比

图 8:三组 Container 用户空间与 Host Kernel 对比

如果你只是想先学会 Docker,到这里已经够了

到这里,Docker 最核心的入门操作其实已经走完了:安装 Docker、拉取 Image、启动 Container,以及理解 Host 和 Container 之间的 Ubuntu 用户空间与 Kernel 关系。

如果你只是想解决“我的 Ubuntu 和 ROS 版本对不上”,做到这里其实已经可以开始自己用了。

不过我还想再往前走一步。Docker 真正放进机器人项目以后,代码放哪儿?ROS 节点怎么通信?真实串口设备怎么进 Container?最后又怎么把这些命令固定下来?

所以接下来的第 9~13 节,是我继续把 Docker 用到机器人开发里的过程。第一次接触 Docker 不必一次把这些内容全吃透,可以先看看每一节在解决什么,再按自己的需要往下做。

接下来解决的问题用途
Bind Mount代码怎么留在 Host
DDSHost 和 Container 里的 ROS 2 怎么通信
H30 IMUContainer 怎么访问真实硬件
Dockerfile怎么把环境和驱动做进 Image
Compose怎么把启动参数固定下来

9. Bind Mount:代码留在 Host

Container 能跑了,但代码总不能一直放在临时 Container 里。退出时 Container 被删除,里面的修改也可能随之消失。我希望源码仍留在 Host,用 Bind Mount把主机目录呈现给 Container。这里用显式的 --mount type=bind,Host 源目录写错时会直接报错。

我单独建了一个 mount_demo 目录,映射到 Jazzy Container 的 /workspace,再查看 Container 身份和目录:

mkdir -p "$HOME/docker_ros_blog_lab/mount_demo"
docker run --rm \
  --mount type=bind,source="$HOME/docker_ros_blog_lab/mount_demo",target=/workspace \
  ros:jazzy-ros-base-noble \
  bash -c 'id; ls -ln /workspace'

--mount共享的是同一目录,不是复制文件。我先在 Host 写下 from_host.txt,进容器确认能读;再反过来,在容器写下 from_container.txt,回到 Host 也看到了它。文件确实共享了,但马上又冒出一个 Docker 新手容易忽略的问题:谁创建的文件,回到 Host 后到底属于谁?这次容器默认以 uid=0(root) gid=0(root) 运行,Host 创建的文件是 1000:1000,容器创建的文件则是 0:0。我没有用 chmodchown 把差异盖掉;以后长期开发还得安排合适的容器用户。

Bind Mount 双向文件共享及 UID/GID 差异

图 9:Bind Mount 双向共享与 UID/GID 差异

用 Agent 完成这一节

请创建独立的 mount_demo 目录,用 Bind Mount 挂入临时 Jazzy Container。
在 Host 创建文件并从 Container 读取,再由 Container 创建文件并回到 Host 读取;显示两边文件的 UID/GID。
只操作测试目录,不用 chmodchown 掩盖差异;最后给出命令和实际结果。

10. DDS:Container 和 Host 能不能直接通信

代码能共享了,接下来是通信。我先让两端都用 Jazzy:一边是原生 Host,另一边是容器,这样先不引入跨 ROS 版本的问题。我一开始最关心的也不是 topic list 里能不能看到名字,而是另一端到底有没有收到消息。

先分清“环境变量未设置”和“实际 RMW”

位置RMW_IMPLEMENTATION实际加载可用实现
Host 原生 JazzyNOT_SETrmw_fastrtps_cpp['rmw_fastrtps_cpp']
Jazzy 容器NOT_SETrmw_fastrtps_cpp['rmw_fastrtps_cpp']

两边的 ROS_DOMAIN_IDROS_LOCALHOST_ONLY 环境变量在基线中也都未显式设置。这里有个容易看错的地方:RMW_IMPLEMENTATION=NOT_SET 只是没有用环境变量指定实现,实际加载的仍是 rmw_fastrtps_cpp。通信测试时,我临时让两边都使用 ROS_DOMAIN_ID=42,没有把它写进 .bashrc

默认 bridge:两个方向都收到实际 String 消息

我先用普通 docker run,让容器走 Docker 默认 bridge。容器发布 /docker_blog_chatter,Host 用同一 domain 订阅:

docker run --rm -it -e ROS_DOMAIN_ID=42 ros:jazzy-ros-base-noble \
  ros2 topic pub --rate 2 /docker_blog_chatter std_msgs/msg/String \
  "{data: 'hello from docker bridge'}"
ROS_DOMAIN_ID=42 ros2 topic echo /docker_blog_chatter

-e 把本次 domain 值传进容器,topic pub 周期性发送 std_msgs/msg/String。Host 的 topic echo 收到了 data: hello from docker bridge;我再反过来让 Host 在 /host_blog_chatter 发布 hello from native jazzy host,容器也收到了。在这台 NUC 上,普通 bridge 就完成了两个方向的消息传递。

Jazzy Container 与 Host 的 DDS 通信

图 10:Container 与 Host 的 DDS 通信

图 10 拍的是容器到 Host;反方向也在同一轮实验中收到了消息。

host network + IPC 对照:发现成功,数据接收失败

普通 bridge 已经能通信后,我又顺手试了 --network host--ipc host。结果反而有点意外:节点能发现对方,Host 却在 15 秒内没收到实际 String 数据。/dev/shm 里同时出现了 Container root 创建、owner 为 root:root 的 Fast DDS SHM 对象。当时比较可疑的是这里的权限问题,不过我没有继续做独立实验去证明两者之间的因果关系。

后面的 H30 实验继续用已经收到双向消息的普通 bridge。Docker 文档介绍了 host 网络模式共享主机网络命名空间的含义。

用 Agent 完成这一节

请完成一次 Host Jazzy 与 Jazzy Container 的 ROS 2 双向通信实验。

开始前:
- 记录 Host 和 Container 实际加载的 RMW;
- 两边临时使用相同 ROS_DOMAIN_ID;
- 先使用普通 docker run 默认 bridge,不改 Host 网络配置。

然后分别完成:
1. Container 发布 String,Host 实际收到;
2. Host 发布 String,Container 实际收到。

不要只根据 topic list 判断成功。
所有持续 pub / echo 命令都设置 timeout。

结束后清理临时 Container 和 ROS 进程。
最后给出实际命令、两个方向收到的消息和结果。

11. 真实硬件:让 Container 读取 H30 IMU

前面都还在软件环境里打转。对机器人开发来说,我更想知道:Container 到底能不能碰到真实硬件? 我的 NUC 接着两只 H30 IMU,这里只取其中一只 Base H30,把整条链走通:

H30 → /dev/ttyACM0 → --device → Jazzy Container → h30_imu
    → /imu/data → Docker bridge → Host 原生 Jazzy

Host 当时枚举到 /dev/ttyACM0/dev/ttyACM1 两个串口。结合稳定的 by-id 路径和现有 H30 配置,我这次选 Base H30 对应的 /dev/ttyACM0 做实验;它的 by-id 是 /dev/serial/by-id/usb-1a86_USB_Single_Serial_5B32028000-if00。图 11 拍下了当时的设备节点和 USB 属性。USB 重新插拔后,ttyACM 编号可能改变。

Host 枚举到的两路 H30 串口设备

图 11:Host 枚举到的两路 H30 串口设备

驱动原本在 /home/lyapunov/bone_drill/bone_ws/src/h30_imu。为了不碰已经工作的项目,我把这个 ROS package 单独复制到实验 Workspace /home/lyapunov/docker_ros_blog_lab/ros2_ws/src/h30_imu。它用到了 rclcppsensor_msgsgeometry_msgstf2_ros 和 POSIX termios,这次没有额外安装 ROS package 就在 ros:jazzy-ros-base-noble 中完成构建:

source /opt/ros/jazzy/setup.bash
cd /workspace/ros2_ws
colcon build --symlink-install

构建输出是 Finished <<< h30_imu [8.19s]Summary: 1 package finished [8.30s]

接着才让容器访问设备。Docker 的 --device可以只开放指定设备。我这里只需要一个串口,就用 --device=/dev/ttyACM0:/dev/ttyACM0,没有为了它给容器开启 --privileged。运行时把独立 Workspace 映射进去,并让容器使用本次测试的 ROS_DOMAIN_ID=42

docker run --rm -it \
  --device=/dev/ttyACM0:/dev/ttyACM0 \
  --mount type=bind,source=/home/lyapunov/docker_ros_blog_lab/ros2_ws,target=/workspace/ros2_ws \
  -e ROS_DOMAIN_ID=42 \
  ros:jazzy-ros-base-noble \
  bash -c 'source /opt/ros/jazzy/setup.bash && source /workspace/ros2_ws/install/setup.bash && ros2 launch h30_imu h30.launch.py port:=/dev/ttyACM0'

驱动日志显示 Opened H30 serial port /dev/ttyACM0,串口配置为 460800 baud。容器内运行的节点是 /h30_imu,可见 /imu/data/imu/euler/imu/temperature/tf;其中 /imu/datasensor_msgs/msg/Imuros2 topic hz /imu/data 测得约 200 Hz。图 12 左侧是设备映射与驱动日志,右侧是容器内的 Topic 和频率。

Container 内 H30 Driver 与约 200 Hz IMU Topic

图 12:Container 内 H30 与约 200 Hz IMU Topic

但我的目标不只是容器自己看见数据。沿用上一节已经跑通的普通 Docker bridge,我在 Host 原生 Jazzy 中使用相同的 domain 查询:

ROS_DOMAIN_ID=42 ros2 topic list | grep '^/imu'
timeout 6s env ROS_DOMAIN_ID=42 ros2 topic hz /imu/data
ROS_DOMAIN_ID=42 ros2 topic echo --once /imu/data

Host 发现了 /imu/data/imu/euler/imu/temperature,并以约 200 Hz 收到 /imu/data,还实际解析出一帧 sensor_msgs/msg/Imu。这帧包含姿态四元数、三轴角速度和三轴线加速度;静止时记录的 linear_acceleration.z=9.787308 m/s²1g 的量级一致。频率统计本身不能逐帧核对数据是否全部送达。

H30 数据从 Container 到达 Host 原生 Jazzy

图 13:H30 数据到达 Host 原生 Jazzy

图 13 保留了 Topic、频率和 IMU 消息开头。下面折叠保存同次 Host 端的完整终端输出:

Host 端完整 IMU 接收结果

=== HOST TOPICS ===
/imu/data
/imu/euler
/imu/temperature

=== HOST /imu/data RATE ===
average rate: 200.207
   min: 0.003s max: 0.007s std dev: 0.00110s window: 201
average rate: 200.079
   min: 0.003s max: 0.007s std dev: 0.00106s window: 401
average rate: 200.051
   min: 0.003s max: 0.007s std dev: 0.00105s window: 601
average rate: 200.006
   min: 0.003s max: 0.007s std dev: 0.00105s window: 802
average rate: 199.988
   min: 0.003s max: 0.007s std dev: 0.00105s window: 1002

=== HOST REAL IMU SAMPLE ===
header:
  stamp:
    sec: 1789733353
    nanosec: 692897321
  frame_id: imu_link
orientation:
  x: 0.022615016607637543
  y: 0.006782004980455353
  z: -0.7447135468902752
  w: 0.6669664897956922
orientation_covariance:
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
angular_velocity:
  x: -0.0008171282491987052
  y: 0.0013967346404935022
  z: 0.0007764969842122771
angular_velocity_covariance:
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
linear_acceleration:
  x: -0.414803
  y: 0.195497
  z: 9.787308
linear_acceleration_covariance:
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0

用 Agent 完成这一节

请把一个已经能在 Host 上工作的 ROS 2 串口传感器驱动放进 Jazzy Container 运行。

开始前先确认:
- 目标串口当前真实路径;
- 驱动 package 的位置;
- baudrate;
- Host 当前 ROS_DOMAIN_ID / RMW。

不要修改原机器人项目。
把驱动复制到独立测试 Workspace,再在 ros:jazzy-ros-base-noble 中构建。

只用 --device 精确映射目标串口,不用 --privileged。
如果缺依赖或 build 失败,先报告,不要随意修改原 package 或安装一堆软件。

启动 Driver 后检查:
- Container 内真实 Topic、消息类型和频率;
- Host 使用相同 ROS_DOMAIN_ID 后是否实际收到传感器消息。

所有持续命令设置 timeout。
结束后清理临时 Container / ROS 进程,并汇总实际命令与结果。

12. Dockerfile:把驱动做进镜像

前面 H30 已经跑起来了,但驱动源码还放在 Host 的独立 Workspace 中,Container 靠 Bind Mount 才拿得到它。开发时这样很方便;如果要把环境交给组里其他人,总不能每次都先解释“请把这个目录放到这个路径,再挂进去”。既然流程已经走通,我就想试着把 Jazzy 环境和 h30_imu 驱动真正做进 Image。

最终使用的 docker/Dockerfile 很短:

FROM ros:jazzy-ros-base-noble

WORKDIR /opt/h30_ws

COPY ros2_ws/src/h30_imu /opt/h30_ws/src/h30_imu

RUN . /opt/ros/jazzy/setup.sh && \
    colcon build --symlink-install

CMD ["bash", "-c", "source /opt/ros/jazzy/setup.bash && source /opt/h30_ws/install/setup.bash && ros2 launch h30_imu h30.launch.py port:=/dev/ttyACM0"]

FROM沿用前面验证的 Jazzy ros-baseCOPY取的是独立实验 Workspace 中的驱动副本;RUN在构建时完成 colcon buildCMD让容器启动后直接运行 H30 节点。从实验目录 /home/lyapunov/docker_ros_blog_lab 构建时,我执行的是:

docker build --no-cache --progress=plain \
  -f docker/Dockerfile -t docker-ros-h30:jazzy .

日志中出现 Finished <<< h30_imuSummary: 1 package finished,生成 docker-ros-h30:jazzy。这次 docker images 显示镜像约 1.31 GB

Dockerfile 构建 h30_imu 自定义镜像

图 14:Dockerfile 构建 H30 镜像

我还单独运行了这个新镜像:只映射 /dev/ttyACM0不再挂载 Host 的 ros2_ws。驱动照样启动,容器内有 /imu/data/imu/euler/imu/temperature/tf/imu/data199.98 Hz;Host 原生 Jazzy 约 199.93 Hz,并收到真实 sensor_msgs/msg/Imu

做到这里我才觉得 Dockerfile 真正有用了:以后不用再记得“当时这台电脑到底怎么配出来的”,环境本身也有了可以重新构建的来源。

用 Agent 完成这一节

请把前面已经跑通的 ROS 2 传感器流程固化成最小 Dockerfile。

要求:
- 使用已经验证的 ROS base image;
- COPY 独立实验 Workspace 中的目标 package;
- 在 build 时完成 colcon build;
- 不修改原机器人项目;
- 不为了“优化”额外安装未经验证的依赖。

Build 自定义 Image 后,
不要再挂载 Host ROS Workspace,只映射真实硬件设备重新启动 Driver。

确认 Container Topic 和 Host 实际消息都正常。
最后给出 Dockerfile、build 命令、Image 信息和运行结果。

13. Compose:把启动参数写进配置

Dockerfile 解决后,还有最后一个麻烦:环境不用重新配了,但每次启动 H30 仍要记得 --deviceROS_DOMAIN_ID 和那串 docker run 参数。这些是服务启动时需要的条件,我把它们收进 docker/compose.yaml

services:
  h30_imu:
    build:
      context: ..
      dockerfile: docker/Dockerfile
    image: docker-ros-h30:jazzy
    devices:
      - /dev/ttyACM0:/dev/ttyACM0
    environment:
      - ROS_DOMAIN_ID=42
    restart: unless-stopped

这里的 build.context: .. 相对 docker 目录指向实验根目录,Dockerfile 因而能 COPY 那份独立的 h30_imu 源码。image 命名构建结果;devices 精确开放串口;environment 沿用已验证的 domain。驱动启动命令已经写在镜像的 CMD 中。进入 docker 目录后,启动只需:

docker compose up

Compose 会创建自己的项目网络,不等于普通 docker run 的默认 bridge。我原本想,如果默认网络不通,就回到前面用过的 bridge。结果没指定 network_mode 也通了:Compose 创建 docker_default 后,Host 看到了 IMU Topic,还收到了约 200 Hz/imu/data 和真实 sensor_msgs/msg/Imu 消息。最终就没额外写网络参数。

Docker Compose 启动 H30 并由 Host 接收 IMU 数据

图 15:Compose 启动 H30 并向 Host 发布数据

用 Agent 完成这一节

请把已经验证通过的 docker run 启动方式改成最小 compose.yaml。

固化:
- image / build;
- 目标设备映射;
- ROS_DOMAIN_ID;
- Driver 启动方式。

不要使用 privileged,也不要预先加入 host network。

先运行 docker compose config 检查最终配置,
再 docker compose up。

使用 Compose 默认网络,
确认 Container 内传感器 Topic 正常,并确认 Host 实际收到消息。

如果默认网络失败,先报告现象,不要自动尝试一堆网络方案。

验证完成后 docker compose down。
最后给出 compose.yaml、实际启动命令和结果。

14. 常见问题与排错

这次最费解的并不是镜像命令,而是“明明进了 docker 组,却访问不了 socket”。如果你也遇到 docker infopermission denied,先看系统组记录、socket owner 和当前 Session 的 id。旧登录会话没刷新时,可以临时用 sg docker -c "命令",或在当前 shell 考虑 newgrp docker;长期还是注销重登。别用 chmod 666 /var/run/docker.sock 把 socket 向所有用户开放。

其余问题我习惯沿着链路往下查,而不是一看到没有数据就怪 DDS 或传感器:

看到什么接着查什么
容器没有预期 ROS 命令镜像标签、ROS 环境是否加载
Workspace 文件不可见或不能写Bind Mount 源目录、容器身份、文件 UID/GID
Host 找不到容器 Topic两边节点是否启动、ROS_DOMAIN_ID、实际 RMW、网络
只能在 topic list 看到名称再用 ros2 topic hzros2 topic echo 看真实消息
容器看不到 IMU 串口Host 设备是否仍存在、--device 是否映射、容器内设备节点
串口可见但 H30 Node 打不开port、460800 baud、权限、是否有进程占用
容器有 /imu/data,Host 没数据domain、RMW、网络、消息类型,再看 Host 的 hzecho

本文的 H30 排查顺序就是 Host Device → --deviceport / baudrate → Node → Topic → Host Subscriber

15. 回到最初那台 ROS 1 NUC

那台 Franka NUC 还在正常跑实验,所以这次我一直没去动它。前面的多版本 ROS、DDS 和真实 H30 都是在自己的 Ubuntu 24.04 NUC 上试的。

以后真的要迁过去,我会先把原来的 Ubuntu 20.04 + ROS 1 环境完整保留,再按那台机器自己的 CPU、Kernel、Franka 驱动和网络条件,把 Jazzy Container 一点点加进去。至少现在已经不是“Docker 到底能不能干这个”的问题了,而只是换一台机器重新把条件核对一遍。

换到另一台机器后,软件环境可以复用,但 Kernel、架构和硬件还是它自己的。

16. Docker 能解决什么,不能解决什么

Docker 帮我做的事Docker 本身替代不了的事
隔离 Ubuntu / ROS 用户空间Host Kernel 与底层硬件驱动
把依赖和启动方式写进镜像与 ComposeROS 1 代码迁移到 ROS 2
让项目环境更容易复现、迁移和部署另一种 CPU 架构的兼容性
精确映射本文需要的串口设备实际存在、权限和传感器状态

17. 总结

最开始做这件事,其实只是因为那台 Ubuntu 20.04 + ROS 1 NUC 不方便重装。我想找一种不破坏原环境,又能继续尝试新 ROS 环境的方法,所以才真正开始用 Docker。

自己完整走一遍之后,我对 Docker 的理解已经不只是“在 Ubuntu 里再跑一个 Ubuntu”了。代码怎么留在 Host、ROS 2 怎么跨 Container 通信、真实串口设备怎么进去、驱动怎么做进 Image、启动参数怎么交给 Compose,这些都是机器人项目里迟早会碰到的问题。

对我来说,这次最大的变化不是记住了多少 Docker 命令,而是以后再遇到一台“已经配好、不能随便重装,但又需要新环境”的机器人电脑时,多了一条可以先试的路。当然 Docker 也不是万能层,Kernel、硬件和 ROS 1 到 ROS 2 的代码迁移还是各自的问题。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐