Skip to content

Docker 开发环境

本教程是一份动手实操指南,从零开始介绍用 Docker 开发 ROS 2 应用程序的命令与流程。 内容不求面面俱到,但能帮你从零建立一套可用于日常开发和测试的工作流。 如果你已有 Docker 基础,可以直接跳到后面关于开发和部署的章节。 附录中还提供了一组 Docker 镜像,方便你进行 Nav2 开发或容器化部署。 同样的流程模板也适用于其他应用程序和项目。

其他有用资源:

Docker 是一种在隔离环境(称为容器)中构建、部署、测试软件的工具。 与虚拟机不同,它共享宿主机的同一个 Linux 内核,因此启动更快、资源开销更小。 在隔离环境中构建或部署软件,可以确保不同用户、机器人或服务器上运行完全相同的软件和版本。 它提供了受控且可复现的工作环境,甚至可以在与当前操作系统不同的(基于 Linux 的)系统上运行。 例如,你可以在 NVIDIA Jetson 的 Jetpack 5.1(基于 Ubuntu 20.04)上运行一个包含 ROS 2 Humble(基于 22.04)的 Docker 容器,并将该容器部署到整个机器人机队中。

Docker 术语中,镜像(image)是构建好的 Dockerfile 产物,用来创建容器。 容器则是 Docker 镜像的一个自包含、可运行的实例。 Dockerfile 是一组指令,描述如何构建镜像来搭建某种工作环境,通常还包含要在该环境中部署的应用程序。 Dockerfile 指令有很多选项,例如:

  • ARG:获取构建时参数
  • FROM:指定基础镜像
  • RUN:运行特定命令
  • WORKDIR:设置工作目录
  • COPY:复制文件或目录
  • ENV:设置环境变量

大部分指令的含义一目了然,如需了解完整列表,请参考 Docker 文档。

有两个指令值得特别说明:CMD 和 ENTRYPOINT,你会在许多 Dockerfile 的末尾看到它们。

  • ENTRYPOINT:容器启动时执行、且无法被覆盖的命令
  • CMD:容器启动时执行、但可以被覆盖的命令

在 ROS Docker 容器中,这两个指令通常用来创建一个 bash 会话并执行 ros_entrypoint.sh 脚本。 该脚本会为当前发行版加载 ROS 环境(source /opt/ros/.../setup.bash),这样进入容器后一切就绪。 当然,这些指令也可以用于更复杂的操作,比如直接运行你的应用程序或触发其他流程。

重要的 Docker 命令(Important Docker Commands)

Section titled “重要的 Docker 命令(Important Docker Commands)”

这里同样不作穷尽列举,但在继续之前有必要先了解几个常用的 Docker 命令。 下面列出的是本教程中使用的基本命令,也很可能是你日常最常用的命令:

  • docker run:运行指定 Docker 镜像以创建容器
  • docker build:构建 Dockerfile 以创建镜像
  • docker pull / push:从远程仓库拉取镜像,或将构建好的镜像推送到远程仓库
  • docker stop / kill:停止或终止正在运行的容器
  • docker ps:列出当前正在运行的容器
  • docker attach:将终端连接到后台运行的容器
  • docker exec:在指定容器中执行命令
  • docker images:列出本机上已拉取或构建的镜像

探索你的第一个容器(Exploring Your First Container)

Section titled “探索你的第一个容器(Exploring Your First Container)”

让我们从获取最新的 ROS 2 Rolling 镜像开始。 通过 OSRF DockerHub,我们可以直接拉取各种现成的 ROS 2 Docker 镜像,无需自己编写 Dockerfile 或手动构建。

Terminal window
sudo docker pull osrf/ros:rolling-desktop-full

你会看到类似下面的输出,镜像会分多层拉取,完成后终端返回提示符。

Terminal window
steve@reese:~$ sudo docker pull osrf/ros:rolling-desktop-full
rolling-desktop-full: Pulling from osrf/ros
31bd5f451a84: Already exists
d36cae3fb404: Already exists
8d68f36a56a7: Already exists
299f725c4bf1: Already exists
6e16227afc48: Already exists
02457a85146c: Downloading 83.7MB/106.5MB
fe0cbdee2808: Download complete
4b4dbddf506a: Downloading 92.86MB/98.14MB
0da90b52c355: Download complete
64de492566b2: Download complete
167d95ac0fce: Download complete
e727072615d0: Downloading 82.61MB/809.8MB
d15e176ed0af: Waiting

接下来尝试将此镜像作为容器运行:

Terminal window
sudo docker run osrf/ros:rolling-desktop-full

你会看到它运行一秒钟后便退出了。没错,它确实在运行,但这样并没有什么实际用处。 原因是 ROS 2 Docker 镜像的 ENTRYPOINT 只是加载了 ROS 2 安装,加载完毕后程序随即退出。 如果要进入容器、在容器内做些有用的操作,就需要以交互式终端方式启动它。 使用 -it 标志即可:

Terminal window
sudo docker run -it osrf/ros:rolling-desktop-full

你现在应该会看到一个终端会话,命令提示符为 root@<some hash>:/#。 这就是你的 Docker 容器。 四处看看,它和普通的 Linux 操作系统没什么两样。 如果你进入 /opt/ros/rolling,应该会觉得很熟悉。


打开一个新终端并运行 sudo docker ps,你应该能看到系统中有一个正在运行的容器实例。 该容器的 ID 与命令提示符中的哈希值一致。 前面提到过,容器启动时会自动加载 ROS 环境,因此我们可以立即进行测试:

Terminal window
echo $ROS_DISTRO # --> rolling
ros2 run demo_nodes_cpp talker # --> [INFO] [1707513434.798456374] [talker]: Publishing: 'Hello World: 1'
touch navigator_dockerlayer.txt
l # \<-- you should see this file

一切正常。现在,如果我们退出交互式会话(输入 exit),就会回到宿主机的终端。 在第二个终端中重新运行 sudo docker ps,容器列表应该是空的,因为我们的容器已经停止运行了。 如果要查看包括已退出在内的所有容器,可以使用 -a 标志:

Terminal window
steve@reese:~$ sudo docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
7ec0e0b7487f osrf/ros:rolling-desktop-full "/ros_entrypoint.sh …" 5 minutes ago Exited (0) About a minute ago strange_tesla
9ccd97ac14f9 osrf/ros:rolling-desktop-full "/ros_entrypoint.sh …" 7 minutes ago Exited (0) 7 minutes ago zen_perlman

可以看到容器已成功退出。如果现在再次运行该 Docker 镜像,即使不加 -a,它也会出现在运行列表中。

Terminal window
sudo docker run -it osrf/ros:rolling-desktop-full

趁现在,让我们 ls 看看容器里的内容——navigator_dockerlayer.txt 文件不见了! 这在预料之中。退出容器时,那个镜像实例就被销毁了。 再次运行镜像时,生成的是一个全新的、干净的实例。 之前的数据不会保留——这是接下来需要了解的一个重要特性。 对开发来说,这简直是噩梦:误操作一下就可能丢失全部工作。 但对部署来说,这反而是一种优势:每次都可以干净地重启,不会残留上次失败会话的任何痕迹,一切从零开始。 本教程稍后会讨论如何在会话之间持久化数据,不必担心。


趁新容器还开着,来看看如何跨多个终端使用同一个容器。如果你在两个终端中分别运行 docker run,会创建两个彼此隔离的独立容器。 要在已有容器中打开新会话,需要先通过 sudo docker ps 找到容器 ID,然后用 exec 命令在其中执行 bash:

Terminal window
sudo docker exec -it bce2ad161bf7 bash # \<-- use your ID

这会在容器中打开一个新的交互式会话并执行 bash,为我们提供一个可用的 shell(容器启动时,Dockerfile 中的 CMD 自动完成了这件事)。 由于这不是一个新启动的容器,ENTRYPOINT 脚本不会运行。如果此时尝试运行 talker 演示,会找不到 ros2 命令。 解决方法很简单:手动加载 /opt/ros/rolling/setup.bash 即可。

在容器的任一终端会话中创建新文件,另一个会话也能看到,因为它们共享同一个容器。

Terminal window
touch navigator_alligator.txt
ls # \<-- see the new file
# move to the other terminal
ls # \<-- also see new file

现在,同一个容器的两个终端同时打开,我们可以做些有趣的事——运行经典的 talker/listener 演示。在两个终端中分别运行以下命令:

Terminal window
ros2 run demo_nodes_cpp talker
ros2 run demo_nodes_py listener

此时如果你打开第三个终端并运行 ros2 topic list,会看到话题少得可怜:

Terminal window
steve@reese:~$ ros2 topic list
/parameter_events
/rosout

原因在于:容器与宿主机系统是隔离的,容器内发生的一切目前无法在宿主机上访问。 让我们先退出两个容器终端实例(exit),然后讨论一些有用的 docker run 标志。 这次,我们希望将 ROS 暴露给整个系统,包括宿主机。为此,使用 --net=host 标志,它将容器的网络设置为与宿主机共享。

Terminal window
sudo docker run -it --net=host osrf/ros:rolling-desktop-full

在这个会话中运行 ros2 run demo_nodes_py talker,现在就可以从宿主机订阅它了:

Terminal window
steve@reese:~$ ros2 topic echo /chatter
data: 'Hello World: 0'
---
data: 'Hello World: 1'
---
data: 'Hello World: 2'
---

接下来看看如何让容器在交互式终端会话退出后继续运行。 很多时候我们需要容器在后台持续运行,-d 标志(分离模式,detached)就是为此设计的。 先用 sudo docker ps 确认当前没有运行中的容器,然后使用该标志启动新容器:

Terminal window
sudo docker run -it --net=host -d osrf/ros:rolling-desktop-full

命令运行片刻后即返回。sudo docker ps 现在应该显示一个正在运行的容器。 复制容器 ID,然后 attach 到它:

Terminal window
sudo docker attach e1d7e035a824 # \<-- use your ID

你现在应该进入了终端会话。完成工作后,如果想停止容器,可以直接输入 exit 退出,这同时会终止容器。 如果想让容器继续运行,可以使用 Control+P+Q 快捷键分离终端而不停止容器。 两种方式都可以用 ps 来验证容器状态。 如果容器仍在运行而你想从外部停止它,可以执行以下命令(可能需要几秒钟):

Terminal window
sudo docker stop e1d7e035a824 # \<-- use your ID

最后,docker images 用于查看本机上已构建或拉取的、可供使用的 Docker 镜像。这个列表会随时间不断扩充。

Terminal window
steve@reese:~$ sudo docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
osrf/ros rolling-desktop-full 7cd0c5068235 6 days ago 3.86GB

注意:如果看到 Failed to create Shared Memory Manager 或类似错误,请使用 --shm-size=100mb 参数来增大容器中的共享内存缓冲区大小。

理解 ROS Docker 镜像(Understanding ROS Docker Images)

Section titled “理解 ROS Docker 镜像(Understanding ROS Docker Images)”

了解了 Docker 的基本特性并体验过 Rolling Desktop Full 容器后,让我们更详细地看看 ROS 中可用的 Docker 镜像。 OSRF 托管了一个 DockerHub 仓库,包含所有 ROS 发行版的镜像,可以直接拉取使用。 每个发行版都有几个变体:

  • ros-core:仅包含 ROS 核心通信协议和实用工具
  • ros-base:在 ros-core 基础上增加了 pluginlib、bond、actions 等核心实用工具
  • perception:在 ros-base 基础上增加了 image common、pipeline、laser filters、laser geometry、vision opencv 等
  • desktop:在 ros-base 基础上增加了 tutorials、lifecycle、rviz2、teleop 和 rqt
  • desktop-full:包含 desktop、perception 和 simulation

这些与通过 apt install ros-rolling-desktop-full 安装的效果相同,只是以容器形式提供。 每个变体都使用 FROM 基于前一个变体构建,然后安装相应的二进制包。 选择哪个取决于你的应用需求,但 osrf/ros:<distro>-ros-base 是开发和部署的不错默认选择。 本教程使用 desktop-full,主要是为了方便,让 RViz2 等工具开箱即用。

你可以像之前一样拉取和使用它们,例如:

Terminal window
sudo docker pull ros:rolling-ros-base
sudo docker pull osrf/ros:humble-desktop

注意,某些镜像需要 osrf/ 前缀,另一些则不需要。带 osrf/ 前缀的镜像由 OSRF 发布,不带前缀的属于官方 Docker 库。 一般来说,desktop 级别的镜像使用 osrf/ 前缀,而 ros-core 和 ros-base 不带前缀。

基于 Docker 的开发(For Docker-Based Development)

Section titled “基于 Docker 的开发(For Docker-Based Development)”

如前所述,在 Docker 容器中创建或修改的文件,在容器退出后不会保留。 如果想让开发成果在容器之间持久保存,可以在运行容器时将一个卷(volume)挂载(mount)到容器中。 简单来说,就是把宿主机上的指定目录链接到容器内,使其可以在容器中读取、修改和删除,变更也会同步到容器外部。 这样,即使关闭容器,工作成果也会保留在本地文件系统中,和不用容器开发一样。 这种方式还有一个很大的优势:你可以在一个容器中构建工作空间,销毁该容器,然后在新容器实例中继续开发和重新构建——只要满足两个条件:(1)两次使用相同的镜像;(2)容器内的挂载路径一致。

使用 -v 标志即可实现挂载。虽然还有其他方法,但这是最直接的。 参数格式为 -v what/local/dir:/absolute/path/in/container。 如果我们在工作空间根目录启动容器,以下命令会启动容器、共享宿主机网络,并将当前工作空间(.)映射到容器内的 /my_ws_docker 目录:

Terminal window
sudo docker run -it --net=host -v .:/my_ws_docker osrf/ros:rolling-desktop-full
ls
cd my_ws_docker
touch navigator_activator.txt

在另一个终端中打开你的工作空间,应该能看到该文件已同步到本地。接下来在容器中运行 rosdep 安装依赖,然后就可以构建工作空间了。

Terminal window
apt update
rosdep init
rosdep update
rosdep install -r -y --from-paths . --ignore-src
colcon build

现在,用 VSCode 或任何喜欢的编辑器修改代码,修改都会同步到容器中,直接用于构建和测试。 如果你同时使用多个 ROS 发行版,或者需要使用宿主机不支持的发行版(例如在 NVIDIA Jetson 的 Jetpack 5.1 上使用 Humble),这种方式尤其有用。 不过,每次启动新容器都要手动等待依赖安装,时间久了会很麻烦。 因此,在某个 ROS Docker 镜像基础上构建自己的自定义开发镜像(包含应用程序所需的包和环境)是个好办法。 这样,进入容器后就可以立即开始构建,无需重复安装。

构建开发镜像(Building a Development Image)

Section titled “构建开发镜像(Building a Development Image)”

构建自定义镜像很简单。Docker 镜像的构建指令定义在 Dockerfile 中。 通常以 FROM 开头,指定基础镜像。在我们的示例中,使用的是 ROS 2 Rolling 镜像。 然后通过一系列 RUN 命令安装和配置依赖,这样启动容器时一切就绪。 附录中提供了一个示例开发镜像,可用于开发 Nav2。它基于 Rolling ros-base,下载 Nav2 源码,并通过 rosdep 安装所有依赖。 这些步骤完成后,镜像就可以用于任何 Nav2 的后续构建。

使用 docker build 构建此镜像:

Terminal window
sudo docker build -t nav2deps:rolling .

其中 -t 设置镜像的标签名称,供后续使用。 需要注意的是:即使安装空间和构建空间会同步到宿主机工作空间,在 Docker 容器内编译的产物也无法在本地直接运行。 此外,该示例开发镜像会升级软件包,这会打破系统和 ros-base 已安装软件包的严格版本控制。 在部署场景中,你需要确保所有软件包版本一致。但对于 ROS 2 Rolling 而言,由于处于活跃开发中,其 ABI 和 API 本身不保证稳定, 因此升级反而有好处——源代码可以基于最新版本进行构建。

Docker 中的可视化(Visualizations from Docker)

Section titled “Docker 中的可视化(Visualizations from Docker)”

有些读者跳到这一节时可能会发现,启动涉及 GUI(RQt、RViz2、Gazebo)的应用程序后会崩溃,窗口无法显示。 Docker 的隔离不仅限于网络,还包括图形显示等资源。 因此,需要专门为 GUI 显示开放通道。

  • --privileged:绕过大量将容器与宿主机隔离的检查。这种方式简单粗暴。
  • --env="DISPLAY=$DISPLAY:设置 GUI 使用的显示(display)
  • --volume="${XAUTHORITY}:/root/.Xauthority":从 XServer 获取图形显示所需的关键信息

将以上参数组合使用,就可以在 Docker 容器内打开 RViz2 了:

Terminal window
sudo docker run -it --net=host --privileged \
--env="DISPLAY=$DISPLAY" \
--volume="${XAUTHORITY}:/root/.Xauthority" \
osrf/ros:rolling-desktop-full
Terminal window
rviz2

如果仍然遇到错误,请查阅文档找到适合你环境的正确参数组合。 (即使需要反复尝试,找到可用的组合通常也不超过 10 分钟。) 如果你使用的是 NVIDIA Jetson 硬件,请参考官方文档,了解适用于你 Jetpack 版本的正确参数。

基于 Docker 的部署(For Docker-Based Deployment)

Section titled “基于 Docker 的部署(For Docker-Based Deployment)”

这里不展开细节,但 Docker 不仅适用于开发,同样适用于应用部署。 你可以在机器人、云服务器等环境中运行镜像实例,将其作为自包含的微服务或机器人应用系统。

通常,你会将 ENTRYPOINT 设置为启动一个脚本,由该脚本负责拉起并运行你的应用程序。 例如,可以使用附录中的部署镜像,通过 ENTRYPOINT 启动机器人导航的根启动文件 tb3_simulation_gazebo_launch.py 或类似文件。 甚至可以让容器通过 systemd 在系统启动时自动拉起,实现应用程序的容器化开机自启。

学完本教程后,你应该能够:

  • 拉取任何 ROS 发行版的官方 ROS 2 Docker 镜像,并根据需求选择合适的镜像类型
  • 了解 ROS 2 Docker 容器的结构,以及 Dockerfile 镜像描述的核心部分
  • 理解 Docker 的文件系统和网络隔离,以及如何在开发场景中绕过这些限制
  • 以分离模式(detach)运行 Docker 容器以执行长时任务
  • 将开发工作空间挂载到容器中进行开发
  • 基于 ROS 镜像构建自定义 Docker 镜像,满足开发依赖和配置需求
  • 在 Docker 中进行 GUI 可视化和仿真

最后需要指出,--privileged 标志是一种「大锤」式的做法。如果希望避免使用它,可以逐项找出让可视化工作所需的各个单独权限。 另外,--privileged 还会启用宿主机操作系统的输入处理,使操纵杆(joystick)和传感器等硬件接口的运行更加方便。 在生产环境中如果不便使用 --privileged,你可能需要深入了解系统,只开放硬件所需的接口。

后续可以探索的方向:

  • 编写配置文件来封装开发用的 docker run 参数
  • 编写 bash 脚本,支持多种 docker run 配置并自动执行
  • 了解更多 Docker 特性,例如 compose、将自定义容器推送到 DockerHub,以及镜像版本管理
  • 限制和规范宿主机资源使用
  • 配置系统 以避免每条 Docker CLI 命令都使用 sudo
  • 关注生产环境问题,如构建缓存管理、安全性、多阶段构建等,以充分利用 Docker

希望这些内容足以帮你起步!

—— 你的友好邻居导航员(Your Friendly Neighborhood Navigators)

Section titled “Nav2 开发镜像(Nav2 Development Image)”

该容器会下载但不会构建 Nav2。 它会拉取所有依赖项,这样运行容器时就具备在任何 ROS 2 发行版(包括 Rolling)上立即构建和使用 Nav2 所需的一切。

Terminal window
ARG ROS_DISTRO=rolling
FROM ros:${ROS_DISTRO}-ros-core
RUN apt update \
&& DEBIAN_FRONTEND=noninteractive apt install -y --no-install-recommends --no-install-suggests \
ros-dev-tools \
wget
WORKDIR /root/nav2_ws
RUN mkdir -p ~/nav2_ws/src
RUN git clone https://github.com/ros-navigation/navigation2.git --branch main ./src/navigation2
RUN rosdep init
RUN apt update && apt upgrade -y \
&& rosdep update \
&& rosdep install -y --ignore-src --from-paths src -r
Section titled “Nav2 部署镜像(Nav2 Deployment Image)”

该镜像会下载并安装 Nav2(Rolling 版本,从源码构建),或者直接从二进制包安装,最终得到一个自包含的、包含运行 Nav2 所需一切的镜像。 你可以从这里前往快速入门进行测试!

Terminal window
ARG ROS_DISTRO=rolling
FROM ros:${ROS_DISTRO}-ros-core
RUN apt update \
&& DEBIAN_FRONTEND=noninteractive apt install -y --no-install-recommends --no-install-suggests \
ros-dev-tools \
wget
# For Rolling or want to build from source a particular branch / fork
WORKDIR /root/nav2_ws
RUN mkdir -p ~/nav2_ws/src
RUN git clone https://github.com/ros-navigation/navigation2.git --branch main ./src/navigation2
RUN rosdep init
RUN apt update && apt upgrade -y \
&& rosdep update \
&& rosdep install -y --ignore-src --from-paths src -r
RUN . /opt/ros/${ROS_DISTRO}/setup.sh \
&& colcon build --symlink-install
# For all else, comment the above Rolling lines and replace with below
# RUN rosdep init \
# && apt update && apt upgrade -y \
# && rosdep update \
# && apt install -y \
# ros-${ROS_DISTRO}-nav2-bringup \
# ros-${ROS_DISTRO}-navigation2 \
# ros-${ROS_DISTRO}-turtlebot3-gazebo